Como Construímos uma Plataforma de Diretório com 137K Listagens usando Next.js e Vercel ISR

No ano passado, lançamos uma plataforma de diretório. 137 mil listagens. Isso não foi um esforço menor. Era uma plataforma totalmente realizada onde cada listagem tinha sua própria página otimizada para SEO. As buscas precisavam ser rápidas, e sim, a hospedagem tinha que permanecer acessível. Então, como fizemos isso com Next.js, Vercel e Incremental Static Regeneration (ISR)? Aperte o cinto; aqui está a história, incluindo onde as coisas ficaram complicadas.

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR

Índice

Por que uma Plataforma de Diretório é Mais Difícil do que Parece

Plataformas de diretório podem parecer diretas. Você pode achar que uma página de listagem, uma página de detalhe, adicione alguns filtros, e pronto! Feito. Mas uma vez que você ultrapassa alguns milhares de listagens, tudo se desintegra em complexidade.

Aqui está o que está realmente acontecendo:

  • 137.000+ páginas únicas que cada uma deve ser rastreável e indexável
  • Busca facetada entre localização, categoria e mais
  • Gerenciar dados obsoletos -- listagens estão em perpétua mudança com atualizações e remoções
  • Demandas de SEO significam que você não pode apenas contar com renderização do lado do cliente
  • Hospedagem com orçamento elimina a geração de todas as páginas em tempo de build

Depois de examinar um monte de métodos, identificamos Next.js com ISR como nosso padrão. Também consideramos o Astro (usado em alguns de nossos outros trabalhos--veja nosso trabalho de desenvolvimento com Astro). No final, a capacidade dinâmica do Next.js com ISR foi a escolha óbvia.

Visão Geral da Arquitetura

Aqui está o que nossa arquitetura parece:

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

A Stack

Componente Tecnologia Por quê
Framework Next.js 14 (App Router) Suporte ISR, React Server Components, route handlers
Hospedagem Vercel Pro Edge CDN, infraestrutura ISR, analytics
Banco de Dados Neon PostgreSQL Postgres serverless, branching para previews
Busca Meilisearch Cloud Tolerância a erros de digitação, busca facetada, indexação rápida
Cache Upstash Redis Rate limiting, cache de sessão, coordenação de ISR
CMS (admin) Custom admin + Payload CMS Gerenciamento de listagens, operações em lote
CDN/Imagens Vercel Image Optimization + Cloudinary Fotos de listagens em múltiplos breakpoints

Este é um projeto de desenvolvimento Next.js em seu núcleo, e ISR foi o grande diferencial para nós.

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR - architecture

A Estratégia de ISR Que Realmente Funciona em Escala

Vamos direto ao ponto: se você tentar gerar estaticamente 137 mil páginas em tempo de build, você está pedindo problemas. Sério, não convide essa dor de cabeça. Mesmo com a geração paralela do Next.js, os builds poderiam se estender além de 45 minutos, transformando cada deployment em um pesadelo.

ISR permite que você gere páginas conforme necessário e as coloca em cache na borda. O ISR padrão é ótimo, mas para nós, ajustes foram essenciais.

A Estratégia de Página de Três Camadas

Dividimos nossas listagens em três camadas:

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

export async function generateStaticParams() {
  // Camada 1: Pré-gere as 2.000 listagens com maior tráfego
  const topListings = await db.listing.findMany({
    where: { tier: 'premium' },
    orderBy: { monthlyViews: 'desc' },
    take: 2000,
    select: { slug: true },
  });

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

// Camada 2 & 3: Geradas sob demanda via ISR
export const revalidate = 3600; // 1 hora para a maioria das listagens

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

  if (!listing) {
    notFound();
  }

  // Revalidação dinâmica baseada na camada de listagem
  // Listagens premium revalidam a cada 10 minutos
  // Listagens padrão a cada hora
  // Listagens arquivadas a cada 24 horas

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

Camada 1 (2.000 páginas): Essas listagens de alto tráfego são pré-geradas em tempo de build. Elas são responsáveis pela maioria do tráfego de busca orgânica. Sempre estão prontas.

Camada 2 (35 mil páginas): Geradas quando solicitadas pela primeira vez, em cache por uma hora. Essas listagens têm tráfego constante, então o primeiro visitante após a expiração do cache obtém uma página renderizada no servidor, mas rápida. Todos os outros obtêm a versão em cache.

Camada 3 (100 mil páginas): Geradas na primeira solicitação, em cache por 24 horas. Essas listagens mal veem ação, então não há necessidade de desperdiçar recursos.

Revalidação Sob Demanda para Atualizações em Tempo Real

A maioria dos casos é coberta por revalidações temporizadas, mas e aquela proprietária de restaurante que acabou de atualizar seu horário? Bem, lançamos a revalidação sob demanda do 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() });
}

Com nosso painel admin e webhooks se comunicando com este endpoint, qualquer mudança de listagem obtém uma página fresca na próxima solicitação. Rápido, não é?

Lidando com 137K Páginas Sem Explodir os Tempos de Build

Os tempos de build realmente nos assustavam! Aqui está o que descobrimos:

Estratégia Tempo de Build Latência de Primeira Solicitação Latência de Hit de Cache
SSG Completo (todas as 137K páginas) ~52 minutos ~40ms ~40ms
ISR (2K pré-construído) ~3,5 minutos ~180ms (cold) ~40ms
SSR Completo (sem cache) ~45 segundos ~250ms N/A
Nossa abordagem híbrida ~3,5 minutos ~150ms (cold) ~35ms

Nossa abordagem de ISR reduziu os tempos de build de uma hora agonizante para pouco menos de 4 minutos. Essa é a diferença entre temer deployments e, bem, beber café enquanto eles rodam.

A Configuração `dynamicParams`

Aqui está um detalhe crucial: mantenha dynamicParams = true para permitir que o ISR gere páginas fora de generateStaticParams. Parece óbvio, mas você ficaria chocado com a frequência com que isso é negligenciado.

export const dynamicParams = true; // Permitir geração sob demanda

Segmentos de Rota Paralela

Para páginas com categorias e localizações, exploramos segmentos de rotas paralelas para que o filtro e as grades de listagem pudessem ser carregadas independentemente:

// 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>
  );
}

Isso significa que seus filtros podem ser cacheados por si só. Mude um filtro, e apenas a grade de listagem é renderizada novamente. Rápido!

Camada de Banco de Dados e Busca

PostgreSQL no Neon

Selecionamos o Neon por seus benefícios serverless, como escala e branches de preview. O tipo de coisa que tornou nossas vidas mais fáceis.

Nossa tabela de listagens é direta, mas depende muito de indexação:

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 que o índice GiST em localização? Trata-se de consultas geoespaciais precisas. "Cafeterias perto de mim" não é apenas enfeite; é um cálculo real.

Meilisearch para Busca

Se sua lista está crescendo como a nossa, a busca de texto do PostgreSQL não vai funcionar, e é aí que o Meilisearch entra. Venceu o Algolia para nós, principalmente pelo preço ($30/mês vs $200+) e sua impressionante tolerância a erros de digitação.

// 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'],
  });
}

A cada cinco minutos, as listagens se sincronizam com um job. Fazemos uma re-indexação completa semanalmente apenas para garantir. Melhor seguro, certo?

SEO em Escala: Sitemaps, Dados Estruturados e Orçamento de Rastreamento

Para uma plataforma com 137 mil páginas, SEO não é apenas bom ter; é questão de vida ou morte. Aqui está como conseguimos:

Sitemaps Dinâmicos

Você não pode despejar 137 mil URLs em um arquivo de sitemap. O limite é de 50 mil URLs de acordo com a especificação. Então, o que fazemos? Geramos um índice de sitemap apontando para pedaços segmentados:

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Isso gera o índice de sitemap
  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;
}

Sitemaps segmentados carregam 10 mil listagens cada, completos com timestamps. O Google vasculha aproximadamente 8 mil a 12 mil páginas diariamente.

Dados Estruturados

Cada página de listagem é preenchida com marcação 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,
    },
  };
}

Esse tipo de dados estruturados turbinou nossas classificações, com o Google dando grande preferência a informações tão precisas.

Benchmarks de Desempenho

Métricas reais de nosso site ativo a partir do início de 2025:

Métrica Valor Alvo
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 (cached) / 190ms (cold ISR) < 200ms
Lighthouse Performance Score 94-98 > 90
Tempo de Build 3 min 22 seg < 5 min
Taxa de Hit de Cache 94,7% > 90%

Essa alta taxa de hit de cache? Sim, impressionantes 94,7% de nossas páginas vêm direto do CDN de borda do Vercel--nenhuma computação extra necessária. É uma vitória para velocidade e custos.

Análise de Custos no Vercel

Vamos aos dólares e centavos. Quem não adora uma boa barganha?

Serviço Custo Mensal (2025) Notas
Vercel Pro $20/assento Para recursos de nível pro e limites
Largura de banda Vercel ~$55 ~600GB/mês com ISR caching
Funções serverless Vercel ~$40 Para trabalho de ISR + coisas de API
Neon PostgreSQL $19 (plano Scale) 10GB de armazenamento, computação escalável
Meilisearch Cloud $30 500K docs, instância dedicada
Upstash Redis $10 10K comandos/dia em média
Cloudinary $25 Armazenamento de imagem e transformações
Total ~$199/mês Para 137K páginas, ~200K visitantes mensais

Menos de $200/mês para executar uma besteira com 137 mil páginas. Versus uma configuração de servidor tradicional? Você sangraria dinheiro em VMs, DBs gerenciados, CDNs e um DevOps em tempo integral para cuidar disso tudo.

Se você está jogando nessa escala e quer conversar, entre em contato conosco ou veja nossos preços.

Erros Que Cometemos e O Que Mudaríamos

Erro 1: Não Configurar Revalidação Sob Demanda Desde o Primeiro Dia

Inicialmente, confiávamos apenas em revalidação temporizada. Deixe-me dizer, movimento ruim. Proprietários de listagens atualizariam suas informações e verificariam instantaneamente. Vendo dados antigos? Não é um construtor de confiança. A revalidação precisava ser MVP.

Erro 2: Subestimar a Complexidade do Sitemap

Nossa primeira tentativa em um sitemap entupiu tudo em uma função serverless. Fila timeouts. O Vercel oferece 10 segundos (60 no Pro) antes do timeout. Aprendemos. Segmente esses desgraçados.

Erro 3: Custos de Otimização de Imagem

Originalmente, o Vercel lidava com todas as otimizações de fotos de listagem. Uma quantidade louca de imagens significava custos selvagens. Compartilhamos esse dever com o Cloudinary, reservando a magia do Vercel para as obrigações da UI.

Erro 4: Não Usar React Server Components Agressivamente o Suficiente

Algumas páginas iniciais estavam repletas de muitos comandos 'use client'. Resultado? Muito JavaScript enviado. Refocando em Server Components tornou nosso pacote de JavaScript leve como uma pena (corte de 62%!).

O Que Faríamos Diferente

Na próxima vez, definitivamente emparelharíamos Next.js com algo como um Payload CMS desde o início, em vez de hackear um painel admin do zero. Que economia de tempo teria sido!

Também consideraríamos de perto o unstable_cache do Vercel (ou apenas cache agora) para resultados de consultas além do caching ISR padrão.

FAQ

O Next.js ISR realmente pode lidar com centenas de milhares de páginas?


Absolutamente. Caminhamos sobre o que falamos. Pré-gere suas páginas de maior tráfego (geralmente 1-5%) usando generateStaticParams e deixe o ISR cuidar do resto. A borda do Vercel assume a partir daí, garantindo tempos de carregamento rápidos globalmente.

Quanto custa executar um grande site de diretório no Vercel?


Para nós, é cerca de $199/mês para 137K listagens com 200 mil visitantes mensais. Os custos variarão, é claro, mas acerte naquele doce caching, e o ISR pode economizar muito.

Qual é a diferença entre ISR e SSR para sites de diretório?


ISR gera páginas uma vez por intervalo de revalidação e as coloca em cache, enquanto SSR gera páginas do zero a cada solicitação. O ISR é mais eficiente para listagens onde os dados não mudam a cada minuto.

Como você lida com buscas em um diretório gerado estaticamente?


As interações de busca vão diretamente para Meilisearch, com chamadas de API para cobrir. Os resultados de busca são renderizados do lado do cliente, enquanto as páginas de listagem são apoiadas por ISR. É a melhor mistura de estático e dinâmico.

Qual intervalo de revalidação devo usar para ISR em um site de diretório?


Depende da frequência de mudança. Usamos uma abordagem em camadas: 10 minutos para prêmios, 1 hora para padrões e 24 horas para listagens mais silenciosas. Salpique em revalidação sob demanda para mudanças instantâneas.

Como você gera sitemaps para 137 mil páginas sem fazer timeout?


A segmentação é sua amiga. Divida em chunks de 10 mil. Roteie através de um índice de sitemap. Cada chunk deve ficar confortavelmente dentro dos limites de timeout.

O Next.js é o melhor framework para construir plataformas de diretório?


Sim, para os grandes -- especialmente com ISR. Para listas super simples, raramente mudando? O Astro pode ser uma opção leve. Criamos ambos; a escolha depende da sua carga de trabalho e necessidades.

Como você evita que dados obsoletos prejudiquem a experiência do usuário com ISR?


Misturar revalidações temporizadas e sob demanda ajuda. Combine isso com SWR do lado do cliente ou React Query para dados ultra-frescos. ISR alimenta seu shell enquanto o tempo real brilha seletivamente.