Cómo Servimos 137K Listados en Next.js ISR sin Explotar el Presupuesto de Vercel
Tu deploy comienza a las 11pm. Ves el registro de compilación de Vercel pasar 10,000 rutas estáticas, luego 50,000, luego se detiene alrededor de 89,000. Seis horas después, la compilación agota el tiempo límite. Tu directorio de 137,000 listados no se entrega porque intentaste pre-renderizar todo en tiempo de compilación — un error que nos costó 11 días y una llamada de cliente muy incómoda. Eventualmente entregamos 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 por $209/mes. La arquitectura que lo hizo posible requirió eliminar nuestro instinto de pre-renderizar todo, repensar cómo escalan las consultas de Supabase bajo ISR, y un cambio de configuración de Vercel que redujo los tiempos de respuesta en 340ms. Aquí está lo que realmente funcionó.
El stack: Next.js 14 (App Router), Supabase (PostgreSQL + Edge Functions), Vercel (hosting + ISR), y una dosis saludable de pragmatismo. Cometimos errores. Chocamos contra obstáculos. Reescribimos cosas que creíamos estaban terminadas. Pero la arquitectura final maneja 137,000+ páginas dinámicas con TTFB inferior a 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
- Por Qué Este Stack
- La Capa de Datos: Supabase a Escala
- Estrategia de Generación de Páginas: ISR, SSG, y el Problema de 137K
- Arquitectura de URL y SEO a Escala
- Búsqueda y Filtrado: La Parte Difícil
- Presupuestos de Rendimiento y Edge Caching
- Monitoreo y Observabilidad en Producción
- Desglose de Costos: Qué Realmente Cuesta
- Qué Haríamos Diferente
- Preguntas Frecuentes

Por Qué Este Stack
Evaluamos muchas opciones antes de decidir por Next.js + Supabase + Vercel. Los requisitos principales eran:
- 137,000+ páginas únicas que los motores de búsqueda pudieran rastrear e indexar
- Cargas de página sub-segundo globalmente (usuarios en 40+ países)
- Datos dinámicos — los listados se actualizan diariamente, algunos cada hora
- Búsqueda de texto completo con filtrado facetado
- Presupuesto consciente — esto no era un moonshot financiado 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 entregado excelentes proyectos de directorio 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 — auth, storage, edge functions, e implementación de Postgres genuinamente buena 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 Sobre Auto-Hosting?
Prototipamos una configuración de Next.js auto-hospedada en Railway. Funcionó, pero ISR en Next.js auto-hospedado tiene particularidades. La historia de invalidación de caché es peor. Necesitas gestionar tu propia capa de CDN. Para un equipo de 3 ingenieros, la sobrecarga 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 ricas, imágenes, calificaciones, horarios operativos — todo.
Diseño de Schema
La decisión más grande fue si usar un esquema relacional normalizado u un enfoque más orientado a documentos con columnas JSONB. Optamos por un enfoque híbrido:
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, arrays de media). 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
Ese campo search_vector es crítico. Lo 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 completamente buscable por texto 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 más adelante.
Connection Pooling
Supabase usa PgBouncer para connection pooling. Con ISR, obtienes ráfagas de invocaciones de funciones serverless — cada una necesita una conexión de 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 suena obvio pero atrap a 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 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 mayor error inicial.
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 tier gratuito de Vercel tiene un límite de compilación de 45 minutos. Incluso el tier Pro tiene un límite de 6 horas. Pero el problema real no era el timeout — era el loop de retroalimentación. Cada deploy tomaba media día. Eso es inmanejable.
El Enfoque ISR (Lo Que Realmente Funciona)
Aquí está la estrategia que entregamos:
- En tiempo de compilación: Genera las top 5,000 páginas (por tráfico) estáticamente
- En la primera solicitud: Genera páginas restantes bajo demanda y cachéalas
- 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 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 // Revalidar 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 actualice. Los webhooks de Supabase disparan 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(`/`) // Revalidar homepage 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 cachean 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 deploy | 137,000 | 5,000 |
| Primera visita (no cacheada) | N/A | ~800ms |
| Visitas subsecuentes | ~120ms | ~120ms |
| Latencia de revalidación | Full redeploy | < 2 segundos |
| Minutos de compilación mensuales | Muy por encima del límite | ~230 minutos |

Arquitectura de URL y SEO a Escala
Con 137,000 páginas, la estructura de URL no es un afterthought — es arquitectura. Cada URL es una oportunidad de clasificación.
La Jerarquía de URL
/ → Homepage
/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 el verdadero oro de 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 bajo el 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 en 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ápido 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 Era 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.
Añadimos Typesense como capa de búsqueda. No Algolia (demasiado caro a nuestra escala — estaríamos pagando $500+/mes). No Meilisearch (excelente, pero la geo-búsqueda de Typesense era mejor para nuestro caso de uso).
Typesense se ejecuta en una única instancia de 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-hospedado) | Buena |
Presupuestos de Rendimiento y Edge Caching
Establecimos objetivos de rendimiento agresivos desde el día uno:
- TTFB: < 200ms (global p75)
- LCP: < 1.5s
- CLS: < 0.05
- Peso total de página: < 300KB (carga inicial)
Red Edge de Vercel
Las páginas ISR se cachean en la red edge de Vercel — 100+ PoPs globalmente. Una vez que una página se genera y se cachea, 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 imágenes 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 a menudo son 2-5MB; después de la optimización, son 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 usuario real
- Sentry: Rastreo de errores (capturamos ~50 errores/día, principalmente de bots enviando basura)
- Supabase Dashboard: Rendimiento de 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 relació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é Realmente Cuesta
La gente siempre pregunta sobre costos. Aquí está el gasto mensual real a partir de Q1 2026:
| Servicio | Plan | Costo Mensual |
|---|---|---|
| Vercel | Pro | $20 |
| Vercel Bandwidth (overages) | Pay-as-you-go | ~$35 |
| Supabase | Pro | $25 |
| Supabase Database (compute) | Small instance | $48 |
| Typesense (Hetzner) | CX31 | $48 |
| Checkly | Starter | $7 |
| Sentry | Team | $26 |
| Domain + DNS (Cloudflare) | Free tier | $0 |
| Total | ~$209/mes |
Sirviendo 137,000 páginas con millones de vistas de página mensuales por alrededor de $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 como esta se asigna a tu presupuesto, nuestra página de precios desglosa cómo típicamente determinamos alcance para proyectos de directorio y marketplace.
Qué Haríamos Diferente
Comienza con ISR desde el día uno. Desperdiciamos dos semanas intentando hacer que el SSG completo funcionara antes de aceptar que las matemáticas no se alineaban.
Usa Typesense desde el inicio. Postgres FTS estuvo bien temprano, pero migrar 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 más temprano. Con 137K listados importados de varias fuentes, la calidad de datos fue una pesadilla. Deberíamos haber construido esquemas Zod más estrictos y pipelines 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 ambiente de staging tenía 500 listados. Las consultas que funcionaban bien en 500 filas se rompían en 137K. Ahora sembramos staging con una muestra aleatoria del 20% de datos de producción.
Si estás planeando una compilación de directorio o marketplace y quieres evitar estas mismas trampas, ponte en contacto con nuestro equipo. Hemos pasado por esto suficientemente para saber dónde están las minas terrestres.
Preguntas Frecuentes
¿Cuánto tiempo toma construir un directorio de 100K+ listados con Next.js? Para nuestro equipo, la arquitectura inicial y características principales tomaron aproximadamente 10 semanas. Importación de datos, limpieza y validación agregaron otras 3-4 semanas. El tiempo total desde el inicio hasta el lanzamiento de producción fue aproximadamente 14 semanas. Si estás trabajando con un equipo de desarrollo de Next.js que ha hecho esto antes, puedes reducir 2-3 semanas de eso.
¿Puede Supabase manejar 100,000+ filas para un directorio? Absolutamente. Supabase se ejecuta en Postgres, que maneja millones de filas sin problemas. 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 devuelven en menos de 50ms para búsquedas de un registro.
¿Cuál es la diferencia entre ISR y SSG para sitios grandes? SSG (Static Site Generation) construye cada página en tiempo de deploy. ISR (Incremental Static Regeneration) construye un subconjunto en tiempo de deploy y genera el resto bajo demanda. Para sitios con más de ~10,000 páginas, ISR es prácticamente requerido — compilaciones SSG completas se vuelven demasiado lentas para ciclos de deployment razonables.
¿Cómo manejas SEO para 137,000 páginas generadas dinámicamente? Tres cosas importan más: generación adecuada de sitemap dividida en múltiples archivos, datos estructurados únicos (JSON-LD) en cada página de listado, y asegurar que las páginas generadas por ISR devuelvan códigos de estado HTTP 200 apropiados (no 404 suaves). También generamos títulos de meta y descripciones únicas por página usando los datos del listado — sin contenido meta duplicado.
¿Es confiable ISR de Vercel 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 uptime. Los únicos incidentes fueron auto-infligidos — un deploy 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é 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 desarrollo, pero se vuelve caro después de 100K registros — espera $500-1000+/mes. Typesense entrega el 90% de la funcionalidad a una fracción del costo cuando está auto-hospedado. Elegimos Typesense y no nos hemos arrepentido.
¿Cómo mantienes 137,000 listados actualizados? Usamos una combinación de enfoques: revalidación bajo demanda disparada por webhooks de Supabase cuando cambian listados individuales, revalidación ISR basada en tiempo (cada hora) como una red de seguridad, y un trabajo batch nocturno que verifica datos obsoletos y dispara revalidación masiva. 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 sin cabeza en lugar de Supabase? Sí, pero con compromisos. Una configuración de CMS sin cabeza como Sanity o Contentful funciona bien para el lado de gestión de contenido, pero probablemente aún necesites una base de datos para búsqueda y consultas complejas. Hemos construido proyectos de directorio donde el contenido editorial vive en un CMS sin cabeza y los datos de listado viven en Postgres — es un enfoque híbrido válido.