Accelerate marketplace growth by ensuring buyers instantly connect with the right service providers. Build a seamless discovery experience with thoughtful schema design from the start.
Whether a service marketplace succeeds depends mainly on how effectively buyers connect with the right providers. While this is often viewed as a search challenge, it is fundamentally a data-modeling issue. The flaw becomes evident months later when search filters prove too limited to match real buyer queries.
The mistake is treating provider profiles as content. Content is written once and read many times, which is exactly what a CMS is for. A provider profile is edited by its owner, must be findable by attributes nobody thought of at launch, and must not appear anywhere until somebody approves it.
This guide builds the discovery component of a marketplace on Webflow Cloud: the provider and service schema, the profile pages, filtered search across providers, and self-service editing with an approval gate. Payments are deliberately out of scope, and we point to that build at the end.
What do you need to build a service marketplace in Webflow Cloud?
You need a Webflow Cloud app with a database binding, authentication so providers can edit their own profiles, and an early decision about whether profiles are CMS items or database rows.
Confirm these prerequisites before you start:
- A Webflow Cloud app on Next.js 15 or higher, with Node.js 22 or later locally
- A SQLite binding declared in a committed
wrangler.json, which Webflow Cloud provisions for you at deploy time - Authentication for providers, since profile editing has to be scoped to whoever owns the profile. Webflow's native User Accounts were sunset on 29 January 2026, so this comes from a third-party provider: we have Webflow Cloud setups for Clerk, Auth0 roles and Supabase Auth
- A written list of the attributes buyers will filter on, because those attributes decide your schema rather than the other way round
Here are the three ways to hold provider profiles and what each one costs you:
| Approach | Provider pages are | Search and filtering | Best when |
|---|---|---|---|
| Webflow CMS collection | CMS items an editor curates | Designer-set filters, so buyer-facing faceting needs custom code | Providers are vetted and few, and SEO matters most |
| App database with rendered routes | Pages your app renders per provider | SQL, or an external index you sync | Providers self-serve and the data changes often |
| Database plus a CMS marketing layer | App pages, with CMS for landing content | SQL on the app side | You need both self-service and editor-owned pages |
| Approach → Provider pages are → Search and filtering → Best when |
|---|
| Webflow CMS collection |
| CMS items an editor curates |
| Designer-set filters, so buyer-facing faceting needs custom code |
| Providers are vetted and few, and SEO matters most |
| App database with rendered routes |
| Pages your app renders per provider |
| SQL, or an external index you sync |
| Providers self-serve and the data changes often |
| Database plus a CMS marketing layer |
| App pages, with CMS for landing content |
| SQL on the app side |
| You need both self-service and editor-owned pages |
Most service marketplaces end up in the third row, and get there painfully after starting in the first. If providers will ever edit their own profiles, start with a database: the CMS has no concept of a row a particular signed-in person may change, and Webflow's own User Accounts feature, the closest native thing, was sunset on 29 January 2026 along with its APIs.
Watch the ceilings too, because they decide the first row rather than taste does. A free Starter site allows 50 CMS items, Basic includes no CMS at all, and Premium carries 20,000 items across 40 collections. "Vetted and few" hides a 50-provider cliff if you are prototyping on Starter.
Once you decide that, everything else follows from the schema. Here's how.
5 steps to build a service marketplace with provider profiles
The build is a schema that separates providers from what they sell, public profile pages, filtered search, self-service editing, and an approval step before anything goes live.
Search comes after the schema on purpose, because what you can filter on depends entirely on how you store things.
1. Separate providers from the services they offer
Start with the relationship, not the fields. A provider is a person or business; a service is something they do for a price. Collapsing those two into one table is the decision you cannot walk back.
Declare the binding first, with migrations_dir pointing at the folder holding your migration:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "marketplace",
"compatibility_date": "2025-04-15",
"d1_databases": [
{
"binding": "DB",
"database_name": "marketplace",
"database_id": "placeholder",
"migrations_dir": "migrations"
}
]
}
That property is load-bearing. Webflow Cloud applies the migrations in the directory it names when you deploy, so omitting it leaves you with a provisioned database and no tables, failing at the first query rather than at deploy. The database_id can stay a placeholder, since the platform substitutes the real one.
The schema below keeps providers and services apart, and keeps categories separate again:
-- migrations/0001_marketplace.sql
CREATE TABLE providers (
-- A surrogate id, not the identity provider's user id. Swapping
-- auth provider is a real scenario, and keying every foreign key
-- on a vendor's subject turns that into a schema migration.
id TEXT PRIMARY KEY,
auth_subject TEXT NOT NULL UNIQUE, -- the provider's user id
slug TEXT NOT NULL UNIQUE, -- the profile URL, stable once public
display_name TEXT NOT NULL,
headline TEXT,
bio TEXT,
location TEXT,
-- Nothing is browsable until a human approves it.
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'pending', 'published')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE services (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL REFERENCES providers(id),
title TEXT NOT NULL,
description TEXT,
price_cents INTEGER, -- nullable: "contact for a quote" is a real answer
duration_mins INTEGER,
active INTEGER NOT NULL DEFAULT 1
);
-- A provider can serve several categories, so this is its own table
-- rather than a column you later regret.
CREATE TABLE provider_categories (
provider_id TEXT NOT NULL REFERENCES providers(id),
category TEXT NOT NULL,
PRIMARY KEY (provider_id, category)
);
CREATE INDEX providers_status_idx ON providers (status, location);
CREATE INDEX services_provider_idx ON services (provider_id, active);
Three choices in there are worth explaining. price_cents is nullable because "contact for a quote" is a legitimate answer in service marketplaces, and a zero would lie about it.
Categories live in their own table because a provider who does two things is normal, and a comma-separated column makes filtering by category a string search. And status defaults to draft, so a new profile is invisible until something explicitly publishes it.
Note also that id is a surrogate rather than the identity provider's subject, which lives in its own unique column. It costs nothing now and makes swapping auth providers later a single-column update instead of rewriting every foreign key. And the slug is unique and separate from the display name.
Once a profile URL is public, it is in somebody's bookmarks and Google's index, so it shouldn't change just because a provider renamed their business. You finish this step with a schema you could add a booking table to without rewriting.
2. Build the public profile page
The profile is the page everything else points to, so it renders on the server and reads directly from the database.
Here is the route, keyed on the slug:
// app/providers/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getCloudflareContext } from '@opennextjs/cloudflare'
export default async function ProviderProfile({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const { env } = getCloudflareContext()
// Filter on status as well as slug. Without it, an unapproved or
// withdrawn profile stays reachable to anyone holding the URL.
const provider = await env.DB.prepare(
`SELECT id, display_name, headline, bio, location
FROM providers
WHERE slug = ? AND status = 'published'`
)
.bind(slug)
.first<{
id: string
display_name: string
headline: string | null
bio: string | null
location: string | null
}>()
if (!provider) notFound()
// Type the query, not the callback. `.all()` with no type argument
// returns Record<string, unknown>[], and annotating the map
// callback instead fails under strictFunctionTypes.
const { results: services } = await env.DB.prepare(
`SELECT title, description, price_cents, duration_mins
FROM services
WHERE provider_id = ? AND active = 1`
)
.bind(provider.id)
.all<{
title: string
description: string | null
price_cents: number | null
duration_mins: number | null
}>()
return (
<main>
<h1>{provider.display_name}</h1>
{provider.headline && <p>{provider.headline}</p>}
{provider.location && <p>{provider.location}</p>}
{provider.bio && <section>{provider.bio}</section>}
<h2>Services</h2>
<ul>
{services.map((s) => (
<li key={s.title}>
{s.title}
{s.price_cents !== null
? ` — from ${(s.price_cents / 100).toFixed(2)}`
: ' — contact for a quote'}
</li>
))}
</ul>
</main>
)
}
The key condition is status = 'published' alongside the slug. Filtering on the slug alone means every draft and every withdrawn profile stays reachable to anyone who has the URL, which is both a moderation hole and, once a provider has left your platform, a genuine problem. Returning a 404 rather than a "not published" message also avoids confirming which slugs exist.
Note the awaited params, which is how dynamic route parameters arrive in Next.js 15. Keep the two queries separate rather than joining: a join across services multiplies the provider row per service, and you spend render time de-duplicating it.
You finish this step with a profile page that renders only approved providers.
3. Make providers findable with real filters
Search is where the schema pays off or does not. Buyers want filters for category, location, and price, and each has a different query shape.
Build the clause from the filters actually present:
// lib/providers.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
type Filters = {
category?: string
location?: string
maxPrice?: number
page?: number
}
const PAGE_SIZE = 24
export async function searchProviders(filters: Filters) {
const { env } = getCloudflareContext()
// Build the WHERE clause from present filters only, and always
// bind values. String-concatenating a filter into SQL is how a
// directory becomes an injection target.
const where: string[] = ["p.status = 'published'"]
const binds: unknown[] = []
if (filters.category) {
where.push(
'EXISTS (SELECT 1 FROM provider_categories c ' +
'WHERE c.provider_id = p.id AND c.category = ?)'
)
binds.push(filters.category)
}
if (filters.location) {
where.push('p.location = ?')
binds.push(filters.location)
}
if (typeof filters.maxPrice === 'number') {
where.push(
'EXISTS (SELECT 1 FROM services s ' +
'WHERE s.provider_id = p.id AND s.active = 1 ' +
'AND s.price_cents IS NOT NULL AND s.price_cents <= ?)'
)
binds.push(filters.maxPrice)
}
const offset = ((filters.page ?? 1) - 1) * PAGE_SIZE
const { results } = await env.DB.prepare(
`SELECT p.id, p.slug, p.display_name, p.headline, p.location,
(SELECT MIN(price_cents) FROM services s
WHERE s.provider_id = p.id AND s.active = 1) AS from_price
FROM providers p
WHERE ${where.join(' AND ')}
ORDER BY p.display_name
LIMIT ? OFFSET ?`
)
.bind(...binds, PAGE_SIZE, offset)
.all()
return results
}
The price filter is the one people get wrong. A buyer filtering by price is asking, "does this provider do anything within my budget?", not "is this provider's average price low?", which is why it uses EXISTS against their active services rather than an aggregate. The from_price subquery then gives the listing card something honest to display.
Every value is bound rather than interpolated. Concatenating a filter into SQL is how a public directory becomes an injection target, and a search endpoint is the most exposed surface. Pagination is in there from the start for the same reason: an unpaginated directory query is fine with fifty providers and a denial of service with fifty thousand.
By the end of this step, you have a search function that delivers precisely the filtered results your users expect, without exposing unintended data.
4. Let providers edit their own profile
Self-service is the point of a marketplace, and it is also where a directory turns into an app with a permissions model.
Every write follows the same rule the read side does not need: the provider ID comes from the session, never from the request body. A handler that accepts a provider ID lets any signed-in provider edit any profile, and it will look like a perfectly reasonable API until somebody tries it.
Editing a published profile raises a question worth deciding deliberately: does the change go live immediately, or does it return to pending? Immediate publishing is friendlier, but it means an approved provider can put anything on your site.
Re-review is safer, but it annoys providers who fixed a typo. A middle path is to re-review only when the fields that matter change, such as the headline and the bio, while letting contact details and prices through.
Whichever you choose, keep the editable field list explicit rather than spreading a submitted object into an update. A provider should not be able to set their own status, and an allow-list prevents that entirely rather than merely making it unlikely.
You finish this step with providers able to maintain their own listings without being able to publish themselves.
5. Put an approval step in front of publishing
The status column from step one only means something once something moves it, so the last step is the review queue.
The mechanics are small: an admin-only route that lists providers with status = 'pending', and an action that sets them to published. What matters is that only an admin can publish, gated by a role claim rather than a hidden URL.
Give the queue a reason field. Rejecting a profile without telling the provider why generates a support conversation for every rejection, and a sentence in the rejection is cheaper than answering the same email forty times.
Watch for the ordering trap when you test this. Because search filters on published and profiles filter on published, a provider who completes onboarding sees nothing on the public site and reasonably concludes the signup failed.
Tell them their profile is under review when they submit it, rather than leaving them to guess. You finish this step with a marketplace where nothing appears publicly until a person decides it should.
What causes service marketplaces to fail on Webflow Cloud?
Marketplace bugs cluster around visibility: things that should be public aren't, things that shouldn't be public are, and the listing doesn't match the profile.
These four cover most cases, and the first generates the most support tickets.
A provider is live but does not appear in search
Cause: The profile satisfies one visibility rule and not another. The usual specifics are a provider published with no active services, so any price filter excludes them, or a category value that does not match the filter option exactly because one was entered by hand and the other comes from a select.
Fix: Query the provider row directly and compare every field the search clause touches against what the filters submit, including case and whitespace. The structural fix is to stop free-typing categories: a fixed list, stored as a constant and used to populate both the provider form and the filter control, removes the entire class of mismatch.
Also decide explicitly whether to list a provider with no services, because leaving it implicit means the answer changes depending on which filters are applied.
Draft or removed profiles are still reachable
Cause: A query filtered on the slug or ID without also filtering on status. Search hides them because search filters properly, so profiles look gone until someone follows an old link or a search engine indexes the page during a window when it was public.
Fix: Add the provider status condition to every read that surfaces a provider, not only the list. Services carry no status of their own, only active, so a service is reachable exactly when its provider is published and the row is active, which is why the check belongs on the provider side.
Then check what your app returns for a withdrawn provider: a 404 is right, a page shell with empty fields is not, and a 500 means the code assumed a row it did not get. If the profile was ever publicly indexed, returning a proper 404 is also what tells search engines to drop it.
The listing card and the profile show different prices
Cause: The two pages compute price differently. A card showing a "from" price derived at query time and a profile listing every service will disagree the moment an inactive or quote-only service is involved, because each query decides what counts.
Fix: Derive the displayed price in one place and have both pages read it. Note the asymmetry that makes this subtle: the from_price subquery filters on active = 1 and gets null exclusion for free, because MIN() skips nulls by default. The profile page must not exclude nulls, since quote-only services are exactly what it has to render. So the shared condition is active = 1, and the null handling differs on purpose rather than by oversight.
Where a provider has only quote-only services, say so explicitly rather than falling back to a zero, which reads as free.
Search slows down as the directory grows
Cause: Filters running against unindexed columns, or an unpaginated query. Both are invisible during development, because a seeded database of twenty providers makes every query fast regardless of how it is written.
Fix: Index the columns the WHERE clause uses, which for the schema above means status with location, and provider with active on services. Then look at the sort, because that is where this query actually degrades: ORDER BY p.display_name is unindexed, so every search builds a temporary B-tree over all matching rows before LIMIT is applied. An index on (status, display_name) lets the sort come off the index instead.
Deep OFFSET pages compound it, since the database still walks the rows it is skipping, which is the argument for keyset pagination once a directory gets large. Confirm pagination happens in the query rather than by slicing results afterward, since fetching everything and discarding most of it costs the same as no pagination.
Fetch the listing in one query rather than one per card, but be precise about why. Webflow Cloud's six-simultaneous-outgoing-request cap covers fetch, KV, R2, Cache, Queues and raw sockets; database queries are not on that list, and SQLite has its own budget of 1,000 queries per invocation.
So a query per card is a round-trip problem rather than a connection-cap one, which still makes one query the right answer.
What to build next in your Webflow Cloud marketplace
Discovery is half a marketplace. The other half is the transaction, and it is a genuinely separate build with its own failure modes.
The natural next step is taking money and splitting it, which means connected accounts and a payment that routes most of its value to the provider who did the work. If your catalog is single-seller instead, our guide to selling digital products covers the simpler path with Checkout Sessions.
Booking is the other common addition for service marketplaces, since a provider's availability is the constraint buyers care about most after price. That pairs naturally with the services table above, which already carries a duration.
For deeper customization beyond what a directory needs, Webflow Cloud's docs cover supported frameworks, storage primitives (including SQLite), and how environments and deployments fit together.
Frequently asked questions
Should provider profiles be CMS items or database rows?
Database rows if providers edit their own profiles, which is the usual case. The CMS has no notion of a row a particular signed-in person may change, so self-service editing means either a database or a lot of custom plumbing around the CMS API.
Can I still use the Webflow CMS for anything?
Yes, and it's a recommended practice. Category landing pages, editorial content, and help articles all belong there, where an editor can change them without touching your app. Keep the provider records themselves in the database.
How do I stop providers publishing themselves?
Keep status out of the editable field list and make publishing an admin-only action gated on a role claim. If a provider can set any field they submit, they can set their own status, no matter what the interface shows them.
Do I need a search service like Algolia?
Not at first. SQL filters handle category, location and price comfortably for a few thousand providers, and Webflow Cloud's SQLite includes the FTS5 full-text module, which gives you relevance ranking and prefix matching for as-you-type. Reach for a dedicated search service when you need typo tolerance, which full-text search does not provide, or when relevance tuning becomes a job of its own.
Where do payments fit into this?
Deliberately outside this build. Splitting a payment between your platform and a provider needs connected accounts and its own data model, and bolting it onto a directory tends to produce a schema that serves neither job well.




