Cómo construimos una plataforma de directorio con 137K listados usando Next.js y Vercel ISR

El año pasado, lanzamos una plataforma de directorio. 137.000 listados. Esto no fue un esfuerzo menor. Fue una plataforma completamente desarrollada donde cada listado tenía su propia página optimizada para SEO. Las búsquedas necesitaban ser rápidas, y sí, el hosting tenía que seguir siendo asequible. Entonces, ¿cómo lo hicimos con Next.js, Vercel e Incremental Static Regeneration (ISR)? Abróchate el cinturón; aquí está la historia, incluyendo dónde las cosas se pusieron difíciles.

Cómo construimos una plataforma de directorio con 137K listados usando Next.js y Vercel ISR

Tabla de contenidos

Por qué una plataforma de directorio es más difícil de lo que parece

Los sitios de directorio pueden parecer simples. Podrías pensar en una página de lista, una página de detalle, espolvorear algunos filtros, ¡y listo! Hecho. Pero una vez que pasas de unos pocos miles de listados, todo se vuelve complejo.

Aquí está lo que realmente está sucediendo:

  • 137.000+ páginas únicas que cada una debe ser rastreada e indexable
  • Búsqueda facetada a través de ubicación, categoría y más
  • Gestionar datos obsoletos -- los listados están en flujo perpetuo con actualizaciones y eliminaciones
  • Las demandas de SEO significan que no puedes confiar solo en la renderización del lado del cliente
  • Hosting con presupuesto elimina la generación de todas las páginas en tiempo de compilación

Después de examinar un montón de métodos, identificamos Next.js con ISR como nuestro estándar. También consideramos Astro (usado en algunos de nuestros otros trabajos--ver nuestro trabajo de desarrollo con Astro). Al final, la capacidad dinámica de Next.js con ISR fue la elección obvia.

Descripción general de la arquitectura

Así es como se ve nuestra arquitectura:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Vercel      │────▶│  Next.js App │────▶│  PostgreSQL  │
│   Edge CDN    │     │  (ISR)       │     │  (Neon)      │
└──────────────┘     └──────────────┘     └──────────────┘
                            │                     │
                            ▼                     ▼
                     ┌──────────────┐     ┌──────────────┐
                     │  Redis       │     │  Meilisearch │
                     │  (Upstash)   │     │  (Cloud)     │
                     └──────────────┘     └──────────────┘

El stack

Componente Tecnología Por qué
Framework Next.js 14 (App Router) Soporte ISR, React Server Components, route handlers
Hosting Vercel Pro Edge CDN, infraestructura ISR, analíticas
Base de datos Neon PostgreSQL Postgres serverless, ramificación para previsualizaciones
Búsqueda Meilisearch Cloud Tolerancia a errores tipográficos, búsqueda facetada, indexación rápida
Cache Upstash Redis Limitación de velocidad, cache de sesión, coordinación ISR
CMS (admin) Admin personalizado + Payload CMS Gestión de listados, operaciones masivas
CDN/Imágenes Optimización de imágenes Vercel + Cloudinary Fotos de listados en múltiples puntos de corte

Este es un proyecto de desarrollo con Next.js en su esencia, e ISR fue el gran vendedor para nosotros.

Cómo construimos una plataforma de directorio con 137K listados usando Next.js y Vercel ISR - arquitectura

La estrategia ISR que realmente funciona a escala

Vayamos al grano: si intentas generar estáticamente 137.000 páginas en tiempo de compilación, te estás pidiendo problemas. En serio, no invites ese dolor de cabeza. Incluso con la generación paralela de Next.js, las compilaciones podrían extenderse más de 45 minutos, convirtiendo cada despliegue en una pesadilla.

ISR te permite generar páginas según sea necesario y almacenarlas en caché en el edge. ISR predeterminado es excelente, pero para nosotros, los ajustes fueron esenciales.

Estrategia de página de tres niveles

Dividimos nuestros listados en tres niveles:

// app/listing/[slug]/page.tsx

export async function generateStaticParams() {
  // Nivel 1: Pre-generar los 2.000 listados con más tráfico
  const topListings = await db.listing.findMany({
    where: { tier: 'premium' },
    orderBy: { monthlyViews: 'desc' },
    take: 2000,
    select: { slug: true },
  });

  return topListings.map((listing) => ({
    slug: listing.slug,
  }));
}

// Nivel 2 y 3: Generados bajo demanda mediante ISR
export const revalidate = 3600; // 1 hora para la mayoría de listados

export default async function ListingPage({ params }: { params: { slug: string } }) {
  const listing = await getListingBySlug(params.slug);

  if (!listing) {
    notFound();
  }

  // Revalidación dinámica basada en el nivel del listado
  // Los listados premium se revalidan cada 10 minutos
  // Los listados estándar cada hora
  // Los listados archivados cada 24 horas

  return <ListingDetail listing={listing} />;
}

Nivel 1 (2.000 páginas): Estos listados de alto tráfico se pre-generan en tiempo de compilación. Son responsables de la mayoría del tráfico de búsqueda orgánica. Siempre están listos.

Nivel 2 (35.000 páginas): Generados cuando se solicitan por primera vez, almacenados en caché durante una hora. Estos listados tienen tráfico constante, por lo que el primer visitante después del vencimiento de la caché obtiene una página renderizada en servidor pero rápida. Todos los demás obtienen la versión en caché.

Nivel 3 (100.000 páginas): Generados en la primera solicitud, almacenados en caché durante 24 horas. Estos listados casi no ven acción, por lo que no hay necesidad de desperdiciar recursos.

Revalidación bajo demanda para actualizaciones en tiempo real

La mayoría de los casos están cubiertos por revalidaciones temporizadas, pero ¿qué pasa con esa dueña de restaurante que acaba de actualizar sus horarios? Bueno, implementamos la revalidación bajo demanda de Next.js usando route handlers:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } 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: 'Invalid secret' }, { status: 401 });
  }

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

  if (type === 'listing') {
    revalidatePath(`/listing/${slug}`);
    revalidateTag(`listing-${slug}`);
  } else if (type === 'category') {
    revalidateTag(`category-${slug}`);
  }

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

Con nuestro panel de administración y webhooks comunicándose con este endpoint, cualquier cambio de listado obtiene una página nueva en la siguiente solicitud. ¿Rápido, eh?

Manejar 137K páginas sin arruinar los tiempos de compilación

¡Los tiempos de compilación realmente nos asustaban! Aquí está lo que encontramos:

Estrategia Tiempo de compilación Latencia de primera solicitud Latencia de caché alcanzado
SSG completo (todas las 137K páginas) ~52 minutos ~40ms ~40ms
ISR (2K pre-construido) ~3.5 minutos ~180ms (frío) ~40ms
SSR completo (sin caché) ~45 segundos ~250ms N/A
Nuestro enfoque híbrido ~3.5 minutos ~150ms (frío) ~35ms

Nuestro enfoque ISR redujo los tiempos de compilación de una hora angustiosa a poco menos de 4 minutos. Esa es la diferencia entre temer los despliegues y, bueno, tomar café mientras se ejecutan.

La configuración `dynamicParams`

Aquí hay un detalle crucial: mantén dynamicParams = true para permitir que ISR genere páginas fuera de generateStaticParams. Parece obvio, pero te sorprendería con qué frecuencia se pasa por alto.

export const dynamicParams = true; // Permitir generación bajo demanda

Segmentos de rutas paralelas

Para páginas con categorías y ubicaciones, aprovechamos los segmentos de rutas paralelas para que el filtro y las cuadrículas de listados pudieran cargarse independientemente:

// app/directory/[category]/layout.tsx
export default function CategoryLayout({
  children,
  filters,
}: {
  children: React.ReactNode;
  filters: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-[280px_1fr] gap-6">
      <aside>{filters}</aside>
      <main>{children}</main>
    </div>
  );
}

Esto significa que tus filtros pueden ser almacenados en caché por sí solos. Cambia un filtro, y solo la cuadrícula de listados se re-renderiza. ¡Rápido!

Capa de base de datos y búsqueda

PostgreSQL en Neon

Elegimos Neon por sus ventajas serverless como escalado y ramas de vista previa. El tipo de cosas que hicieron nuestras vidas más fáciles.

Nuestra tabla de listados es sencilla pero se basa mucho en la indexación:

CREATE INDEX idx_listings_category ON listings(category_id);
CREATE INDEX idx_listings_location ON listings USING GIST(location);
CREATE INDEX idx_listings_rating ON listings(avg_rating DESC);
CREATE INDEX idx_listings_slug ON listings(slug);
CREATE INDEX idx_listings_status_tier ON listings(status, tier);

¿Por qué el índice GiST en ubicación? Todo se trata de esas consultas geoespaciales precisas. "Cafeterías cerca de mí" no es solo palabrería; es un cálculo real.

Meilisearch para búsqueda

Si tu lista está creciendo como la nuestra, la búsqueda de texto de PostgreSQL no será suficiente, y ahí es donde entra Meilisearch. Nos ganó a Algolia, principalmente por precio ($30/mes vs $200+) y su impresionante tolerancia a errores tipográficos.

// lib/search.ts
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_API_KEY!,
});

export async function searchListings(query: string, filters: FilterParams) {
  const index = client.index('listings');

  return index.search(query, {
    filter: buildFilterString(filters),
    facets: ['category', 'city', 'priceRange', 'rating'],
    limit: 24,
    offset: filters.page * 24,
    attributesToHighlight: ['name', 'description'],
  });
}

Cada cinco minutos, los listados se sincronizan con un trabajo. Hacemos un re-índice completo semanalmente solo por si acaso. Más vale prevenir, ¿verdad?

SEO a escala: mapas del sitio, datos estructurados y presupuesto de rastreo

Para una plataforma con 137.000 páginas, SEO no es solo algo agradable de tener; es cuestión de vida o muerte. Así es como lo logramos:

Mapas del sitio dinámicos

No puedes volcar 137.000 URLs en un archivo de mapa del sitio. El límite es 50.000 URLs según la especificación. Entonces, ¿qué hacemos? Generamos un índice de mapa del sitio que apunta a fragmentos segmentados:

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Esto genera el índice del mapa del sitio
  const totalListings = await db.listing.count({ where: { status: 'active' } });
  const sitemapCount = Math.ceil(totalListings / 10000);

  const sitemaps = [];

  for (let i = 0; i < sitemapCount; i++) {
    sitemaps.push({
      url: `${process.env.NEXT_PUBLIC_URL}/sitemap/${i}.xml`,
      lastModified: new Date(),
    });
  }

  return sitemaps;
}

Los mapas del sitio segmentados contienen 10.000 listados cada uno, completos con marcas de tiempo. Google se sumerge a alrededor de 8.000-12.000 páginas diarias.

Datos estructurados

Cada página de listado contiene marcado de esquema LocalBusiness:

function generateStructuredData(listing: Listing) {
  return {
    '@context': 'https://schema.org',
    '@type': 'LocalBusiness',
    name: listing.name,
    description: listing.description,
    address: {
      '@type': 'PostalAddress',
      streetAddress: listing.address,
      addressLocality: listing.city,
      addressRegion: listing.state,
      postalCode: listing.zip,
    },
    aggregateRating: listing.reviewCount > 0 ? {
      '@type': 'AggregateRating',
      ratingValue: listing.avgRating,
      reviewCount: listing.reviewCount,
    } : undefined,
    geo: {
      '@type': 'GeoCoordinates',
      latitude: listing.lat,
      longitude: listing.lng,
    },
  };
}

Este tipo de datos estructurados turbocargó nuestros rankings, con Google dando gran preferencia a información tan precisa.

Puntos de referencia de rendimiento

Métricas reales de nuestro sitio en directo a partir de principios de 2025:

Métrica Valor Objetivo
Largest Contentful Paint (LCP) 1.1s (p75) < 2.5s
First Input Delay (FID) 12ms (p75) < 100ms
Cumulative Layout Shift (CLS) 0.02 (p75) < 0.1
Time to First Byte (TTFB) 85ms (en caché) / 190ms (ISR frío) < 200ms
Puntuación de rendimiento Lighthouse 94-98 > 90
Tiempo de compilación 3 min 22 seg < 5 min
Tasa de aciertos de caché 94.7% > 90%

¿Esa alta tasa de aciertos de caché? Sí, un asombroso 94.7% de nuestras páginas provienen directamente de la CDN de edge de Vercel--sin computación adicional necesaria. Es una victoria en ambos sentidos para velocidad y costos.

Desglose de costos en Vercel

Vamos a los dólares y centavos. ¿A quién no le encanta una buena ganga?

Servicio Costo mensual (2025) Notas
Vercel Pro $20/seat Para características y límites de nivel profesional
Ancho de banda Vercel ~$55 ~600GB/mes con caché ISR
Funciones serverless Vercel ~$40 Para trabajo ISR + otras cosas
Neon PostgreSQL $19 (plan Scale) 10GB almacenamiento, compute escalable
Meilisearch Cloud $30 500K documentos, instancia dedicada
Upstash Redis $10 10K comandos/día promedio
Cloudinary $25 Almacenamiento de imágenes y transformaciones
Total ~$199/mes Para 137K páginas, ~200K visitantes mensuales

Menos de $200/mes para ejecutar una bestia con 137.000 páginas. ¿Versus una configuración de servidor tradicional? Estarías sangrando dinero en VMs, DBs administradas, CDNs, y un DevOps a tiempo completo para cuidarlo todo.

Si estás jugando en esta escala y quieres charlar, contáctanos o echa un vistazo a nuestros precios.

Errores que cometimos y qué cambiaríamos

Error 1: No configurar revalidación bajo demanda desde el primer día

Inicialmente confiábamos solo en revalidación temporizadas. Déjame decirte, mala decisión. Los propietarios de listados modificarían su información y revisarían instantáneamente. ¿Ver datos antiguos? No es un refuerzo de confianza. La revalidación necesitaba ser MVP.

Error 2: Subestimar la complejidad del mapa del sitio

Nuestro primer intento en un mapa del sitio metió todo en una función serverless. Cue timeouts. Vercel te da 10 segundos (60 en Pro) antes del timeout. Aprendimos. Segmenta esos suckers.

Error 3: Costos de optimización de imágenes

Originalmente, Vercel manejaba todas las optimizaciones de fotos de listados. Una cantidad loca de imágenes significaba costos salvajes. Compartimos ese deber con Cloudinary, reservando la magia de Vercel para las cosas imprescindibles de UI.

Error 4: No usar React Server Components lo suficientemente agresivamente

Algunas páginas iniciales estaban empacadas con demasiados comandos 'use client'. ¿Resultado? Demasiado JavaScript enviado. Reenfocarse en Server Components hizo nuestro paquete de JavaScript ligero como una pluma (¡corte del 62%!).

Lo que haríamos diferente

La próxima vez, definitivamente emparearíamos Next.js con algo como Payload CMS desde el principio en lugar de hackear un panel de administración desde cero. ¡Qué ahorro de tiempo hubiera sido eso!

También consideraríamos de cerca unstable_cache (o simplemente cache ahora) más reciente de Vercel para resultados de consultas más allá del caché ISR estándar.

Preguntas frecuentes

¿Puede Next.js ISR realmente manejar cientos de miles de páginas?
Absolutamente. Hemos caminado el camino. Pre-genera tus páginas de mayor tráfico (generalmente 1-5%) usando generateStaticParams y deja que ISR se encargue del resto. Vercel's edge se hace cargo desde allí, asegurando tiempos de carga rápidos globalmente.

¿Cuánto cuesta ejecutar un sitio de directorio grande en Vercel?
Para nosotros, es aproximadamente $199/mes para 137K listados con 200.000 visitantes mensuales. Los costos variarán, por supuesto, pero golpea ese dulce punto de caché, e ISR puede ahorrarte mucho dinero.

¿Cuál es la diferencia entre ISR y SSR para sitios de directorio?
ISR genera páginas una vez por intervalo de revalidación y las almacena en caché, mientras que SSR genera páginas desde cero en cada solicitud. ISR es más eficiente para listados donde los datos no cambian cada minuto.

¿Cómo manejas la búsqueda en un directorio generado estáticamente?
Las interacciones de búsqueda van directamente a Meilisearch, con llamadas API para cubrir. Los resultados de búsqueda se renderizan del lado del cliente, mientras que las páginas de listados están respaldadas por ISR. Es la mejor mezcla de estático y dinámico.

¿Qué intervalo de revalidación debo usar para ISR en un sitio de directorio?
Depende de la frecuencia de cambios. Usamos un enfoque escalonado: 10 min para prémiums, 1 hora para estándares, y 24 horas para listados más tranquilos. Espolvorea revalidación bajo demanda para cambios instantáneos.

¿Cómo generas mapas del sitio para 137.000 páginas sin timeout?
La segmentación es tu amiga. Córtalos en fragmentos de 10.000. Enrútalos a través de un índice de mapa del sitio. Cada fragmento debe mantenerse cómodamente dentro de los límites de timeout.

¿Es Next.js el mejor framework para construir plataformas de directorio?
Sí, para los grandes--especialmente con ISR. ¿Para listas ultra simples, raramente cambiantes? Astro puede ser una opción ligera. Hemos elaborado ambas; la elección depende de tu carga de trabajo y necesidades.

¿Cómo previene datos obsoletos que hieran la experiencia del usuario con ISR?
Mezclar revalidaciones basadas en tiempo y bajo demanda ayuda. Empareja eso con SWR del lado del cliente o React Query para datos ultra frescos. ISR alimenta tu shell mientras lo real-time brilla selectivamente.