Acelera el crecimiento de tu mercado asegurándote de que los compradores conecten al instante con los proveedores de servicios adecuados. Crea una experiencia de descubrimiento fluida con un diseño de esquema bien pensado desde el principio.
El éxito de un mercado de servicios depende principalmente de la eficacia con la que los compradores conectan con los proveedores adecuados. Aunque a menudo se ve como un reto de búsqueda, es fundamentalmente un problema de modelado de datos. El fallo se hace evidente meses después, cuando los filtros de búsqueda resultan demasiado limitados para satisfacer las consultas reales de los compradores.
El error está en tratar los perfiles de los proveedores como contenido. El contenido se escribe una vez y se lee muchas, que es exactamente para lo que sirve un CMS. El perfil de un proveedor lo edita su dueño, debe ser localizable mediante atributos en los que nadie pensó al lanzar el sitio y no debe aparecer en ninguna parte hasta que alguien lo apruebe.
Esta guía construye el componente de descubrimiento de un mercado en Webflow Cloud: el esquema de proveedores y servicios, las páginas de perfil, la búsqueda filtrada de proveedores y la edición de autoservicio con un control de aprobación. Los pagos quedan deliberadamente fuera del alcance, y al final indicamos cómo realizar esa parte.
¿Qué necesitas para construir un mercado de servicios en Webflow Cloud?
Necesitas una aplicación de Webflow Cloud con enlace a base de datos, autenticación para que los proveedores puedan editar sus propios perfiles y una decisión temprana sobre si los perfiles serán elementos del CMS o filas de la base de datos.
Confirma estos requisitos previos antes de empezar:
- Una aplicación de Webflow Cloud en Next.js 15 o superior, con Node.js 22 o posterior instalado localmente
- Un enlace a SQLite declarado en un
wrangler.jsonarchivo comprometido, que Webflow Cloud aprovisiona para ti en el momento del despliegue - Autenticación para proveedores, ya que la edición de perfiles debe estar limitada a quien sea el dueño del perfil. Las cuentas de usuario nativas de Webflow dejaron de estar disponibles el 29 de enero de 2026, por lo que esto proviene de un proveedor externo: tenemos configuraciones de Webflow Cloud para Clerk, roles de Auth0 y Supabase Auth
- Una lista escrita de los atributos por los que filtrarán los compradores, porque esos atributos son los que definen tu esquema y no al revés
Aquí tienes las tres formas de gestionar los perfiles de proveedores y lo que te cuesta cada una:
| Enfoque | Las páginas de proveedores son | Búsqueda y filtrado | Ideal cuando |
|---|---|---|---|
| Colección CMS de Webflow | Elementos del CMS que un editor gestiona | Filtros configurados en el diseñador, por lo que el facetado para el comprador requiere código personalizado | Los proveedores están verificados y son pocos, y el SEO es lo más importante |
| Base de datos de la aplicación con rutas renderizadas | Páginas que tu aplicación renderiza por proveedor | SQL o un índice externo que sincronices | Los proveedores se gestionan solos y los datos cambian a menudo |
| Base de datos más una capa de marketing CMS | Páginas de la aplicación, con CMS para el contenido de las landing pages | SQL en el lado de la aplicación | Necesitas tanto páginas de autoservicio como páginas gestionadas por editores |
| Enfoque → Las páginas de proveedor son → Búsqueda y filtrado → Ideal cuando |
|---|
| Colección CMS de Webflow |
| Elementos del CMS que un editor gestiona |
| Filtros configurados en el diseñador, por lo que el facetado para el comprador requiere código personalizado |
| Los proveedores están verificados y son pocos, y el SEO es lo más importante |
| Base de datos de la aplicación con rutas renderizadas |
| Páginas que tu aplicación renderiza por proveedor |
| SQL o un índice externo que sincronices |
| Los proveedores se gestionan solos y los datos cambian a menudo |
| Base de datos más una capa de marketing CMS |
| Páginas de la aplicación, con CMS para el contenido de las landing pages |
| SQL en el lado de la aplicación |
| Necesitas tanto páginas de autoservicio como páginas gestionadas por editores |
La mayoría de los mercados de servicios terminan en la tercera fila, y llegan allí con mucho esfuerzo tras empezar en la primera. Si los proveedores van a editar sus propios perfiles alguna vez, empieza con una base de datos: el CMS no tiene el concepto de una fila que una persona concreta pueda modificar al iniciar sesión, y la función de Cuentas de Usuario de Webflow, lo más parecido que había, se retiró el 29 de enero de 2026 junto con sus API.
Vigila también los límites, porque son ellos los que deciden la primera fila, no el buen gusto. Un sitio Starter gratuito permite 50 elementos CMS, el plan Basic no incluye CMS y el Premium admite 20.000 elementos en 40 colecciones. "Pocos y seleccionados" esconde un abismo de 50 proveedores si estás creando un prototipo en el plan Starter.
Una vez que te decidas, todo lo demás vendrá dado por el esquema. Aquí tienes cómo hacerlo.
5 pasos para crear un mercado de servicios con perfiles de proveedor
La estructura es un esquema que separa a los proveedores de lo que venden, páginas de perfil público, búsqueda filtrada, edición de autoservicio y un paso de aprobación antes de que todo se publique.
La búsqueda viene después del esquema a propósito, porque lo que puedes filtrar depende totalmente de cómo almacenes las cosas.
1. Separa a los proveedores de los servicios que ofrecen
Empieza por la relación, no por los campos. Un proveedor es una persona o una empresa; un servicio es algo que hacen por un precio. Fusionar ambos en una sola tabla es una decisión de la que no podrás arrepentirte.
Declara primero el enlace, con migrations_dir apuntando a la carpeta que contiene tu migración:
{
"$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"
}
]
}
Esa propiedad es fundamental. Webflow Cloud aplica las migraciones en el directorio que indicas al desplegar, así que si la omites, te quedarás con una base de datos aprovisionada pero sin tablas, lo que provocará un error en la primera consulta en lugar de en el despliegue. El database_id puede seguir siendo un marcador de posición, ya que la plataforma lo sustituye por el real.
El esquema de abajo mantiene a los proveedores y servicios separados, y a su vez mantiene las categorías aparte:
-- 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);
Hay tres opciones que vale la pena explicar. price_cents es anulable porque "contactar para pedir presupuesto" es una respuesta válida en los mercados de servicios, y poner un cero sería mentir al respecto.
Las categorías tienen su propia tabla porque es normal que un proveedor ofrezca varios servicios, y usar una columna con valores separados por comas convertiría el filtrado por categoría en una búsqueda de cadenas de texto. Además, status se establece por defecto en draft, así que un perfil nuevo permanece invisible hasta que alguien lo publique explícitamente.
Ten en cuenta también que id es un identificador sustituto y no el sujeto del proveedor de identidad, que reside en su propia columna única. No cuesta nada ahora y permite cambiar de proveedor de autenticación en el futuro con una simple actualización de columna, en lugar de tener que reescribir todas las claves foráneas. Y el slug es único y distinto del nombre visible.
Una vez que la URL de un perfil es pública, acaba en los marcadores de alguien y en el índice de Google, así que no debería cambiar solo porque un proveedor haya renombrado su negocio. Terminas este paso con un esquema al que podrías añadir una tabla de reservas sin necesidad de reescribirlo.
2. Crea la página de perfil público
El perfil es la página a la que todo apunta, así que se renderiza en el servidor y lee directamente de la base de datos.
Aquí tienes la ruta, basada en el 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>
)
}
La condición clave es status = 'published' junto al slug. Filtrar solo por el slug significa que cualquier borrador o perfil retirado sigue siendo accesible para cualquiera que tenga la URL, lo cual es un fallo de moderación y, una vez que un proveedor abandona tu plataforma, un problema real. Devolver un error 404 en lugar de un mensaje de "no publicado" también evita confirmar qué slugs existen.
Fíjate en el params esperado, que es como llegan los parámetros de ruta dinámicos en Next.js 15. Mantén las dos consultas separadas en lugar de usar un join: un join entre servicios multiplica la fila del proveedor por cada servicio, y pierdes tiempo de renderizado eliminando duplicados.
Terminas este paso con una página de perfil que solo muestra a los proveedores aprobados.
3. Haz que los proveedores sean fáciles de encontrar con filtros reales
La búsqueda es donde el esquema demuestra si vale la pena o no. Los compradores quieren filtrar por categoría, ubicación y precio, y cada uno tiene una estructura de consulta diferente.
Construye la cláusula a partir de los filtros que estén presentes:
// 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
}
El filtro de precio es donde la gente suele equivocarse. Un comprador que filtra por precio pregunta "¿este proveedor ofrece algo dentro de mi presupuesto?", no "¿el precio medio de este proveedor es bajo?", por eso se usa EXISTS sobre sus servicios activos en lugar de un agregado. La from_price subconsulta permite que la tarjeta de listado muestre información honesta.
Cada valor está vinculado en lugar de interpolado. Concatenar un filtro en SQL es la forma en que un directorio público se convierte en un objetivo de inyección, y un endpoint de búsqueda es la superficie más expuesta. La paginación está ahí desde el principio por la misma razón: una consulta de directorio sin paginar funciona bien con cincuenta proveedores, pero provoca una denegación de servicio con cincuenta mil.
Al final de este paso, tendrás una función de búsqueda que ofrece exactamente los resultados filtrados que tus usuarios esperan, sin exponer datos no deseados.
4. Permite que los proveedores editen su propio perfil
El autoservicio es la esencia de un mercado, y también es donde un directorio se convierte en una aplicación con un modelo de permisos.
Cada escritura sigue la misma regla que la lectura no necesita: el ID del proveedor proviene de la sesión, nunca del cuerpo de la solicitud. Un manejador que acepte un ID de proveedor permitiría que cualquier proveedor registrado edite cualquier perfil, y parecería una API perfectamente razonable hasta que alguien lo intente.
Editar un perfil publicado plantea una pregunta que merece una decisión meditada: ¿el cambio se publica inmediatamente o vuelve a pending? La publicación inmediata es más cómoda, pero significa que un proveedor aprobado puede poner cualquier cosa en tu sitio.
Volver a revisar es más seguro, pero molesta a los proveedores que solo corrigieron una errata. Un punto intermedio es revisar de nuevo solo cuando cambian los campos importantes, como el titular y la biografía, dejando pasar los datos de contacto y los precios.
Elijas lo que elijas, mantén la lista de campos editables explícita en lugar de volcar un objeto enviado directamente en una actualización. Un proveedor no debería poder configurar su propio status, y una lista de permitidos evita esto por completo en lugar de simplemente hacerlo improbable.
Terminas este paso con proveedores capaces de mantener sus propios listados sin poder publicarlos ellos mismos.
5. Añade un paso de aprobación antes de publicar
La status columna del primer paso solo tiene sentido cuando algo la mueve, así que el último paso es la cola de revisión.
La mecánica es sencilla: una ruta solo para administradores que enumera a los proveedores con , y una acción que los marca como status = 'pending'published. Lo importante es que solo un administrador pueda publicar, protegido por una validación de rol en lugar de una URL oculta.
Añade un campo de motivo a la cola. Rechazar un perfil sin explicarle al proveedor el porqué genera una consulta de soporte por cada rechazo, y una frase en el rechazo es más barata que responder al mismo correo cuarenta veces.
Cuidado con la trampa del orden al probar esto. Como los filtros de búsqueda se basan en y los perfiles se filtran por publishedpublished, un proveedor que termina el registro no ve nada en el sitio público y concluye, con razón, que el registro falló.
Diles que su perfil está en revisión cuando lo envíen, en lugar de dejar que lo adivinen. Terminas este paso con un mercado donde nada aparece públicamente hasta que alguien decide que debe hacerlo.
¿Qué hace que los mercados de servicios fallen en Webflow Cloud?
Los errores en los mercados suelen girar en torno a la visibilidad: cosas que deberían ser públicas no lo son, cosas que no deberían serlo sí lo son, y el listado no coincide con el perfil.
Estos cuatro cubren la mayoría de los casos, y el primero es el que genera más tickets de soporte.
Un proveedor está activo pero no aparece en las búsquedas
Causa: El perfil cumple una regla de visibilidad pero no otra. Lo habitual es que un proveedor se publique sin servicios activos, por lo que cualquier filtro de precio lo excluye, o que un valor de categoría no coincida exactamente con la opción del filtro porque uno se escribió a mano y el otro proviene de una lista desplegable.
Solución: Consulta la fila del proveedor directamente y compara cada campo que toca la cláusula de búsqueda con lo que envían los filtros, incluyendo mayúsculas y espacios. La solución estructural es dejar de escribir categorías a mano: una lista fija, almacenada como constante y usada para completar tanto el formulario del proveedor como el control de filtro, elimina toda esta clase de errores de coincidencia.
Decide también explícitamente si quieres listar a un proveedor sin servicios, porque dejarlo implícito significa que la respuesta cambiará según los filtros que se apliquen.
Los perfiles en borrador o eliminados siguen siendo accesibles
Causa: Una consulta filtrada por el slug o el ID sin filtrar también por estado. La búsqueda los oculta porque filtra correctamente, así que los perfiles parecen haber desaparecido hasta que alguien sigue un enlace antiguo o un buscador indexa la página durante el tiempo en que estuvo pública.
Solución: Añade la condición de estado del proveedor a cada lectura que muestre un proveedor, no solo a la lista. Los servicios no tienen estado propio, solo active, por lo que un servicio es accesible exactamente cuando su proveedor está publicado y la fila está activa; por eso la comprobación debe hacerse en el lado del proveedor.
Luego comprueba qué devuelve tu aplicación para un proveedor retirado: un 404 es lo correcto, una página vacía no lo es, y un 500 significa que el código asumió una fila que no obtuvo. Si el perfil llegó a estar indexado públicamente, devolver un 404 adecuado es también lo que le indica a los buscadores que deben eliminarlo.
La tarjeta de listado y el perfil muestran precios diferentes
Causa: Las dos páginas calculan el precio de forma distinta. Una tarjeta que muestra un precio "desde" calculado en el momento de la consulta y un perfil que enumera todos los servicios no coincidirán en cuanto haya un servicio inactivo o solo bajo presupuesto, ya que cada consulta decide qué se contabiliza.
Solución: Calcula el precio mostrado en un solo lugar y haz que ambas páginas lo lean. Ten en cuenta la asimetría que hace que esto sea sutil: la subconsulta filtra por y obtiene la exclusión de nulos automáticamente, porque omite los nulos por defecto. La página de perfil no debe excluir los nulos, ya que los servicios solo bajo presupuesto son exactamente lo que debe mostrar. Por lo tanto, la condición compartida es , y el manejo de nulos difiere a propósito, no por descuido.from_priceactive = 1MIN()active = 1
Cuando un proveedor solo tenga servicios bajo presupuesto, indícalo explícitamente en lugar de mostrar un cero, que se interpreta como gratuito.
La búsqueda se ralentiza a medida que crece el directorio
Causa: Filtros que se ejecutan en columnas sin indexar o una consulta sin paginación. Ambos problemas son invisibles durante el desarrollo, porque una base de datos de prueba con veinte proveedores hace que cualquier consulta sea rápida, sin importar cómo esté escrita.
Solución: Indexa las columnas que utiliza la cláusula , lo que para el esquema anterior significa estado con ubicación, y proveedor con activo en los servicios. Luego revisa la ordenación, porque ahí es donde esta consulta realmente pierde rendimiento: no está indexado, por lo que cada búsqueda construye un árbol B temporal sobre todas las filas coincidentes antes de aplicar . Un índice en permite que la ordenación se realice directamente desde el índice.WHEREORDER BY p.display_nameLIMIT(status, display_name)
Las páginas profundas de lo agravan, ya que la base de datos sigue recorriendo las filas que está omitiendo, lo cual es el argumento a favor de la paginación por claves una vez que un directorio se vuelve grande. Confirma que la paginación ocurre en la consulta en lugar de recortar los resultados después, ya que obtener todo y descartar la mayor parte cuesta lo mismo que no paginar.OFFSET
Obtén el listado en una sola consulta en lugar de una por tarjeta, pero sé preciso sobre el porqué. El límite de seis solicitudes salientes simultáneas de Webflow Cloud cubre , KV, R2, Caché, colas y sockets sin procesar; las consultas a la base de datos no están en esa lista, y SQLite tiene su propio presupuesto de 1000 consultas por invocación.fetch
Por lo tanto, una consulta por tarjeta es un problema de ida y vuelta, no de límite de conexiones, lo que sigue haciendo que una sola consulta sea la respuesta correcta.
Qué construir a continuación en tu marketplace de Webflow Cloud
El descubrimiento es la mitad de un marketplace. La otra mitad es la transacción, y es una construcción genuinamente separada con sus propios modos de fallo.
El siguiente paso natural es aceptar pagos y dividirlos, lo que implica cuentas conectadas y un pago que dirige la mayor parte de su valor al proveedor que realizó el trabajo. Si tu catálogo es de un solo vendedor, nuestra guía sobre venta de productos digitales cubre el camino más sencillo con sesiones de pago.
Las reservas son la otra adición común para los marketplaces de servicios, ya que la disponibilidad de un proveedor es la restricción que más les importa a los compradores después del precio. Eso combina naturalmente con la tabla de servicios anterior, que ya incluye una duración.
Para una personalización más profunda más allá de lo que necesita un directorio, la documentación de Webflow Cloud cubre los marcos compatibles, las primitivas de almacenamiento (incluyendo SQLite) y cómo encajan los entornos y los despliegues.
Preguntas frecuentes
¿Deberían los perfiles de los proveedores ser elementos del CMS o filas de la base de datos?
Filas de la base de datos si los proveedores editan sus propios perfiles, que es el caso habitual. El CMS no tiene noción de una fila que una persona específica que ha iniciado sesión pueda cambiar, por lo que la edición de autoservicio significa usar una base de datos o mucha programación personalizada alrededor de la API del CMS.
¿Puedo seguir usando el CMS de Webflow para algo?
Sí, y es una práctica recomendada. Las páginas de destino de categorías, el contenido editorial y los artículos de ayuda pertenecen allí, donde un editor puede cambiarlos sin tocar tu aplicación. Mantén los registros de los proveedores en la base de datos.
¿Cómo evito que los proveedores se publiquen a sí mismos?
Excluye el estado de la lista de campos editables y haz que la publicación sea una acción exclusiva para administradores, restringida por un rol. Si un proveedor puede configurar cualquier campo que envíe, podrá establecer su propio estado, sin importar lo que le muestre la interfaz.
¿Necesito un servicio de búsqueda como Algolia?
Al principio no. Los filtros SQL gestionan categorías, ubicaciones y precios sin problemas para unos pocos miles de proveedores, y el SQLite de Webflow Cloud incluye el módulo de texto completo FTS5, que te ofrece clasificación por relevancia y coincidencias de prefijos mientras escribes. Recurre a un servicio de búsqueda especializado cuando necesites tolerancia a errores tipográficos, algo que la búsqueda de texto completo no ofrece, o cuando ajustar la relevancia se convierta en una tarea en sí misma.
¿Dónde encajan los pagos en todo esto?
Intencionalmente fuera de este desarrollo. Dividir un pago entre tu plataforma y un proveedor requiere cuentas conectadas y su propio modelo de datos; intentar integrarlo a la fuerza en un directorio suele generar un esquema que no funciona bien para ninguna de las dos cosas.





