El año pasado lanzamos un directorio global con 137,000 listados. No un prototipo. No un MVP "lo optimizaremos después". Un sistema de producción que sirve millones de vistas de página, se posiciona para miles de palabras clave de cola larga, y regenera páginas bajo demanda sin sudar. Esta es la historia de cómo lo construimos — y las decisiones arquitectónicas que lo hicieron posible.

El stack: Next.js 14 (App Router), Supabase (PostgreSQL + Edge Functions), Vercel (hosting + ISR), y una buena dosis de pragmatismo. Cometimos errores. Nos topamos con muros. Reescribimos cosas que pensábamos estaban terminadas. Pero la arquitectura final maneja 137,000+ páginas dinámicas con TTFB sub-200ms globalmente, y nuestra factura de Supabase se mantiene por debajo de $100/mes.

Si estás construyendo algo similar — un marketplace, un directorio, una plataforma de listados — este es el artículo que desearía que existiera cuando comenzamos.

Tabla de Contenidos

Construyendo un Directorio Global de 137K Listados con Next.js, Supabase & Vercel ISR

Por qué este Stack

Evaluamos muchas opciones antes de decidir por Next.js + Supabase + Vercel. Los requisitos principales eran:

  1. 137,000+ páginas únicas que los motores de búsqueda pudieran rastrear e indexar
  2. Cargas de página sub-segundo globalmente (usuarios en 40+ países)
  3. Datos dinámicos — los listados se actualizan diariamente, algunos cada hora
  4. Búsqueda de texto completo con filtrado facetado
  5. Presupuesto consciente — esto no era una apuesta moonshot financiada por VC

Consideramos Astro (excelente para sitios estáticos, pero necesitábamos más interactividad dinámica — aunque nuestro equipo de desarrollo de Astro ha enviado excelentes proyectos de directorios con él). Miramos WordPress + WPEngine. Brevemente consideramos un SPA puro con Algolia.

Next.js ganó por una característica asesina: Incremental Static Regeneration. ISR significaba que no teníamos que elegir entre rendimiento estático y contenido dinámico. Podíamos tener ambos.

Supabase ganó sobre PlanetScale y Neon por el paquete completo — autenticación, almacenamiento, funciones edge, e implementación genuinamente buena de Postgres con Row Level Security. Para un directorio, necesitas todo eso.

Vercel fue el objetivo de despliegue porque ISR funciona mejor en Vercel (sin sorpresas). La integración es nativa. La revalidación bajo demanda simplemente funciona.

¿Qué hay del Auto-Alojamiento?

Prototipamos una configuración auto-alojada de Next.js en Railway. Funcionó, pero ISR en Next.js auto-alojado tiene peculiaridades. La historia de invalidación de caché es peor. Necesitas gestionar tu propia capa CDN. Para un equipo de 3 ingenieros, el overhead operacional no valía los $200/mes que ahorraríamos.

La Capa de Datos: Supabase a Escala

Nuestra base de datos Supabase contiene 137,000 listados, cada uno con 40-60 campos. Categorías, ubicaciones, información de contacto, descripciones enriquecidas, imágenes, calificaciones, horarios de operación — todo.

Diseño del Esquema

La decisión más grande fue si usar un esquema relacional normalizado o un enfoque más orientado a documentos con columnas JSONB. Fuimos híbridos:

CREATE TABLE listings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug TEXT UNIQUE NOT NULL,
  title TEXT NOT NULL,
  description TEXT,
  category_id UUID REFERENCES categories(id),
  city_id UUID REFERENCES cities(id),
  country_code TEXT NOT NULL,
  coordinates GEOGRAPHY(POINT, 4326),
  contact JSONB DEFAULT '{}',
  attributes JSONB DEFAULT '{}',
  media JSONB DEFAULT '[]',
  rating_avg NUMERIC(3,2) DEFAULT 0,
  rating_count INTEGER DEFAULT 0,
  status TEXT DEFAULT 'active',
  published_at TIMESTAMPTZ,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  search_vector TSVECTOR
);

CREATE INDEX idx_listings_category ON listings(category_id) WHERE status = 'active';
CREATE INDEX idx_listings_city ON listings(city_id) WHERE status = 'active';
CREATE INDEX idx_listings_country ON listings(country_code) WHERE status = 'active';
CREATE INDEX idx_listings_coordinates ON listings USING GIST(coordinates);
CREATE INDEX idx_listings_search ON listings USING GIN(search_vector);
CREATE INDEX idx_listings_slug ON listings(slug);

Datos relacionales estructurados para cosas en las que filtramos (categorías, ciudades, países). JSONB para datos semi-estructurados que varían por listado (métodos de contacto, atributos personalizados, matrices de medios). Esto nos dio lo mejor de ambos mundos — consultas indexadas rápidas en las columnas relacionales y flexibilidad en el resto.

El Vector de Búsqueda

Esa columna search_vector es crítica. La poblamos con un trigger:

CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
    setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') ||
    setweight(to_tsvector('english', COALESCE(NEW.attributes->>'keywords', '')), 'C');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Esto significa que cada listado es buscable de texto completo a través de Postgres mismo. Sin servicio de búsqueda externo necesario para los primeros 100K listados. Hablaremos sobre cuándo esto se rompe después.

Connection Pooling

Supabase usa PgBouncer para connection pooling. Con ISR, obtienes ráfagas de invocaciones de funciones serverless — cada una necesita una conexión a la base de datos. Sin pooling, agotarás conexiones en minutos.

Usamos la cadena de conexión agrupada (puerto 6543) para todos los contextos serverless y la conexión directa (puerto 5432) solo para migraciones y tareas administrativas. Esto es una de esas cosas que suenan obvias pero atrapa a la gente.

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // Solo lado del servidor
  {
    db: { schema: 'public' },
    auth: { persistSession: false }
  }
)

Estrategia de Generación de Páginas: ISR, SSG, y el Problema de 137K

Aquí es donde las cosas se ponen interesantes. Y donde cometimos nuestro error más grande al principio.

El Enfoque Ingenuo (No Hagas Esto)

Nuestro primer intento: generar todas las 137,000 páginas en tiempo de compilación usando generateStaticParams. La compilación tomó 4 horas y 22 minutos. El nivel gratuito de Vercel tiene un límite de compilación de 45 minutos. Incluso el nivel Pro se limita a 6 horas. Pero el problema real no era el timeout — era el ciclo de retroalimentación. Cada despliegue tomaba medio día. Eso no es viable.

El Enfoque ISR (Lo que Realmente Funciona)

Aquí está la estrategia que lanzamos:

  1. En tiempo de compilación: Genera las 5,000 páginas principales (por tráfico) estáticamente
  2. En la primera solicitud: Genera las páginas restantes bajo demanda y cáchalas
  3. Revalidación: Basada en tiempo (cada 3600 segundos) + bajo demanda vía webhook
// app/listing/[slug]/page.tsx
import { supabase } from '@/lib/supabase'
import { notFound } from 'next/navigation'

export async function generateStaticParams() {
  // Solo pre-genera los listados principales por tráfico
  const { data } = await supabase
    .from('listings')
    .select('slug')
    .eq('status', 'active')
    .order('rating_count', { ascending: false })
    .limit(5000)

  return (data || []).map((listing) => ({
    slug: listing.slug,
  }))
}

export const revalidate = 3600 // Revalida cada hora

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const { data: listing, error } = await supabase
    .from('listings')
    .select(`
      *,
      category:categories(*),
      city:cities(*, country:countries(*))
    `)
    .eq('slug', params.slug)
    .eq('status', 'active')
    .single()

  if (!listing || error) notFound()

  return <ListingDetail listing={listing} />
}

Revalidación Bajo Demanda

Cuando un propietario de listado actualiza sus datos, no queremos esperar hasta una hora para que la página se refresque. Los webhooks de Supabase desencadenan una ruta de API de Next.js:

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidation-secret')
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { slug, type } = await request.json()

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`)
    revalidatePath(`/`) // Revalida la página de inicio también
  }

  return NextResponse.json({ revalidated: true })
}

Esto nos da lo mejor de ambos mundos: rendimiento de sitio estático con frescura de sitio dinámico. Las compilaciones se completan en menos de 8 minutos. Las páginas que no han sido pre-generadas se crean en la primera visita y se almacenan en caché en el edge.

Los Números

Métrica SSG Completo (Ingenuo) ISR (Producción)
Tiempo de compilación 4h 22m 7m 40s
Páginas en despliegue 137,000 5,000
Primera visita (sin caché) N/A ~800ms
Visitas posteriores ~120ms ~120ms
Latencia de revalidación Redeploy completo < 2 segundos
Minutos de compilación mensuales Muy por encima del límite ~230 minutos

Construyendo un Directorio Global de 137K Listados con Next.js, Supabase & Vercel ISR - arquitectura

Arquitectura de URLs y SEO a Escala

Con 137,000 páginas, la estructura de URL no es un detalle secundario — es arquitectura. Cada URL es una oportunidad de posicionamiento.

La Jerarquía de URL

/                                    → Página de inicio
/categories/[category-slug]          → Páginas de categoría (48 categorías)
/locations/[country]/[city]          → Páginas de ubicación
/listing/[listing-slug]              → Listado individual
/search?q=...&category=...&city=...  → Resultados de búsqueda (noindex)

Las páginas de intersección de categoría + ubicación son la verdadera mina de oro SEO:

/categories/restaurants/us/new-york   → "Restaurantes en Nueva York"
/categories/hotels/uk/london          → "Hoteles en Londres"

Estas páginas de intersección se generan dinámicamente con ISR. Hay aproximadamente 12,000 combinaciones válidas. Cada una se dirige a una palabra clave específica de cola larga.

Generación de Sitemap

Con 137K URLs, necesitas archivos de índice de sitemap. El límite de Google es 50,000 URLs por sitemap.

// app/sitemap/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
  const page = parseInt(params.id)
  const perPage = 45000 // Mantente por debajo del límite de 50K
  const offset = page * perPage

  const { data: listings } = await supabase
    .from('listings')
    .select('slug, updated_at')
    .eq('status', 'active')
    .order('id')
    .range(offset, offset + perPage - 1)

  const xml = generateSitemapXml(listings)
  return new Response(xml, {
    headers: { 'Content-Type': 'application/xml' },
  })
}

Dividimos en 4 sitemaps: sitemap-0.xml a través de sitemap-3.xml, referenciados por un índice de sitemap. Google Search Console indexó el 98% de las URLs enviadas dentro de 6 semanas.

Datos Estructurados

Cada página de listado incluye datos estructurados JSON-LD. Para un directorio, el esquema LocalBusiness es crítico:

const structuredData = {
  '@context': 'https://schema.org',
  '@type': 'LocalBusiness',
  name: listing.title,
  description: listing.description,
  address: {
    '@type': 'PostalAddress',
    addressLocality: listing.city.name,
    addressCountry: listing.city.country.code,
  },
  geo: {
    '@type': 'GeoCoordinates',
    latitude: listing.coordinates?.lat,
    longitude: listing.coordinates?.lng,
  },
  aggregateRating: listing.rating_count > 0 ? {
    '@type': 'AggregateRating',
    ratingValue: listing.rating_avg,
    reviewCount: listing.rating_count,
  } : undefined,
}

Búsqueda y Filtrado: La Parte Difícil

La búsqueda siempre es la parte difícil. Siempre.

Fase 1: Búsqueda de Texto Completo de Postgres

Para nuestro lanzamiento inicial, la búsqueda tsvector de Postgres manejó todo. Es lo suficientemente rápida para 137K filas con un índice GIN. Los tiempos de consulta promediaron 40-80ms.

const { data } = await supabase
  .from('listings')
  .select('id, slug, title, description, category:categories(name)')
  .textSearch('search_vector', query, { type: 'websearch' })
  .eq('status', 'active')
  .eq('country_code', countryFilter)
  .order('rating_avg', { ascending: false })
  .range(0, 19)

Fase 2: Cuando Postgres No Fue Suficiente

Alrededor de 80,000 listados, las búsquedas facetadas complejas (categoría + ubicación + texto + ordenamiento) comenzaron a alcanzar 300-500ms. Aceptable para la mayoría de aplicaciones, pero nuestros usuarios esperaban resultados instantáneos.

Agregamos Typesense como capa de búsqueda. No Algolia (demasiado caro en nuestra escala — estaríamos pagando $500+/mes). No Meilisearch (excelente, pero la búsqueda geográfica de Typesense era mejor para nuestro caso de uso).

Typesense se ejecuta en una única instancia Hetzner de $48/mes. Se sincroniza desde Supabase vía reindexación completa nocturna + actualizaciones de webhook en tiempo real. Las consultas de búsqueda ahora promedian 8-15ms.

Solución de Búsqueda Tiempo de Consulta (p50) Tiempo de Consulta (p99) Costo Mensual Búsqueda Facetada
Postgres FTS 45ms 320ms $0 (incluido) Limitada
Typesense 9ms 28ms $48 Excelente
Algolia ~5ms ~15ms $500+ Excelente
Meilisearch ~8ms ~22ms $48 (auto-alojado) Buena

Presupuestos de Rendimiento y Caché de Edge

Establecimos objetivos de rendimiento agresivos desde el principio:

  • TTFB: < 200ms (p75 global)
  • LCP: < 1.5s
  • CLS: < 0.05
  • Peso total de página: < 300KB (carga inicial)

Red Edge de Vercel

Las páginas ISR se almacenan en caché en la red edge de Vercel — 100+ PoPs globalmente. Una vez que una página se genera y se almacena en caché, se sirve desde la ubicación edge más cercana. Por eso TTFB se mantiene por debajo de 200ms incluso para usuarios en Asia Sudoriental o América del Sur.

Optimización de Imágenes

Cada listado tiene 1-8 imágenes. Eso es potencialmente más de un millón de imágenes. Usamos la optimización de imagen integrada de Vercel con next/image:

<Image
  src={listing.media[0]?.url}
  alt={listing.title}
  width={800}
  height={600}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  loading={index === 0 ? 'eager' : 'lazy'}
  quality={75}
/>

Las imágenes se almacenan en Supabase Storage y se sirven a través del CDN de imágenes de Vercel. Las imágenes originales suelen tener 2-5MB; después de la optimización, tienen 40-120KB. Esto solo ahorró aproximadamente el 80% en ancho de banda.

Monitoreo y Observabilidad en Producción

Ejecutar 137K páginas en producción sin monitoreo es como conducir con los ojos vendados. Aquí está nuestro stack:

  • Vercel Analytics: Core Web Vitals, monitoreo de usuarios reales
  • Sentry: Rastreo de errores (capturamos ~50 errores/día, principalmente de bots enviando basura)
  • Supabase Dashboard: Rendimiento de la base de datos, análisis de consultas
  • Checkly: Monitoreo sintético, intervalos de 5 minutos en rutas críticas
  • Google Search Console: Cobertura de índice, estadísticas de rastreo

El monitoreo más valioso que configuramos fue una consulta diaria de Supabase que cuenta páginas indexadas vs. listados activos totales. Si la proporción cae por debajo del 95%, recibimos una alerta. Esto atrapó una regresión de sitemap dentro de 24 horas de desplegar un cambio malo.

Desglose de Costos: Qué Cuesta Realmente

La gente siempre pregunta sobre costos. Aquí está el gasto mensual real a partir de Q1 2025:

Servicio Plan Costo Mensual
Vercel Pro $20
Vercel Ancho de Banda (excedentes) Pago por uso ~$35
Supabase Pro $25
Supabase Base de Datos (computación) Instancia pequeña $48
Typesense (Hetzner) CX31 $48
Checkly Starter $7
Sentry Team $26
Dominio + DNS (Cloudflare) Nivel gratuito $0
Total ~$209/mes

Sirviendo 137,000 páginas con millones de vistas de página mensuales por aproximadamente $200/mes. Intenta hacer eso con una configuración de servidor tradicional ejecutando WordPress.

Si estás considerando un proyecto similar y quieres entender cómo una arquitectura así se mapea a tu presupuesto, nuestra página de precios desglosa cómo típicamente alcanzamos directorios y proyectos de marketplace.

Qué Haríamos Diferente

Comienza con ISR desde el primer día. Desperdiciamos dos semanas intentando hacer que el SSG completo funcione antes de aceptar que las matemáticas no sumaban.

Usa Typesense desde el inicio. Postgres FTS fue fino al principio, pero migrar la búsqueda a mitad del proyecto fue disruptivo. Los $48/mes habrían valido la pena desde el lanzamiento.

Invierte en validación de datos antes. Con 137K listados importados de varias fuentes, la calidad de datos fue una pesadilla. Deberíamos haber construido esquemas Zod más estrictos y tuberías de validación antes de la primera importación, no después de encontrar miles de registros rotos en producción.

Prueba con volúmenes de datos realistas en staging. Nuestro entorno de staging tenía 500 listados. Las consultas que funcionaban muy bien en 500 filas se desmoronaron a 137K. Ahora sembramos staging con una muestra aleatoria del 20% de datos de producción.

Si estás planeando un directorio o compilación de marketplace y quieres evitar estas mismas trampas, comunícate con nuestro equipo. Hemos pasado por esto lo suficiente como para saber dónde están las minas.

Preguntas Frecuentes

¿Cuánto tiempo lleva construir un directorio con 100K+ listados con Next.js? Para nuestro equipo, la arquitectura inicial y características principales tomaron aproximadamente 10 semanas. La importación, limpieza y validación de datos agregaron otras 3-4 semanas. El tiempo total desde el inicio hasta el lanzamiento en producción fue de aproximadamente 14 semanas. Si estás trabajando con un equipo de desarrollo de Next.js que ha hecho esto antes, puedes recortar 2-3 semanas.

¿Puede Supabase manejar 100,000+ filas para un directorio? Absolutamente. Supabase se ejecuta en Postgres, que maneja millones de filas sin sudar. La clave es la indexación adecuada — sin índices en tus columnas más consultadas, el rendimiento se degrada rápidamente. Con los índices que describimos arriba, nuestras consultas en 137K filas consistentemente regresan en menos de 50ms para búsquedas de un solo registro.

¿Cuál es la diferencia entre ISR y SSG para sitios grandes? SSG (Static Site Generation) construye cada página en tiempo de despliegue. ISR (Incremental Static Regeneration) construye un subconjunto en tiempo de despliegue y genera el resto bajo demanda. Para sitios con más de ~10,000 páginas, ISR es prácticamente requerido — las compilaciones SSG completas se vuelven demasiado lentas para ciclos de despliegue razonables.

¿Cómo manejas SEO para 137,000 páginas generadas dinámicamente? Tres cosas importan más: generación adecuada de sitemap dividida entre múltiples archivos, datos estructurados únicos (JSON-LD) en cada página de listado, y asegurar que las páginas generadas por ISR regresen códigos de estado HTTP 200 adecuados (no 404 suave). También generamos títulos de meta y descripciones únicos por página usando los datos del listado — sin contenido meta duplicado.

¿Es confiable Vercel ISR para producción a escala? En nuestra experiencia, sí. Hemos estado ejecutando esta configuración durante más de 8 meses con 99.98% de tiempo de funcionamiento. Los únicos incidentes fueron auto-infligidos — un despliegue malo que rompió nuestro webhook de revalidación, y una ventana de mantenimiento de Supabase que causó 15 minutos de búsqueda degradada. El caché de edge de Vercel es sólido como una roca.

¿Debo usar Algolia o Typesense para un directorio grande? Depende de tu presupuesto. Algolia es el estándar de la industria con la mejor experiencia de desarrollador, pero se vuelve caro pasado 100K registros — espera $500-1000+/mes. Typesense entrega el 90% de la funcionalidad a una fracción del costo cuando se auto-aloja. Elegimos Typesense y no nos hemos arrepentido.

¿Cómo mantienes 137,000 listados actualizados? Usamos una combinación de enfoques: revalidación bajo demanda desencadenada por webhooks de Supabase cuando cambios de listados individuales, revalidación ISR basada en tiempo (cada hora) como red de seguridad, y un trabajo por lotes nocturno que verifica datos obsoletos y desencadena revalidación en lote. Los propietarios de listados también pueden solicitar manualmente una actualización de página a través de su panel.

¿Puede esta arquitectura funcionar con un CMS headless en lugar de Supabase? Sí, pero con compensaciones. Una configuración de CMS headless como Sanity o Contentful funciona bien para el lado de gestión de contenido, pero probablemente seguirás necesitando una base de datos para búsqueda y consultas complejas. Hemos construido proyectos de directorio donde el contenido editorial vive en un CMS headless y los datos del listado viven en Postgres — es un enfoque híbrido válido.