Sua deploy começa às 23h. Você observa o log de build da Vercel passar por 10.000 caminhos estáticos, depois 50.000, depois travar em algum lugar próximo a 89.000. Seis horas depois, a build expira. Seu diretório com 137.000 anúncios não será lançado porque você tentou pré-renderizar tudo no tempo de build — um erro que nos custou 11 dias e uma ligação com cliente muito desconfortável. Eventualmente, lançamos um sistema em produção que serve milhões de page views, classifica para milhares de palavras-chave de cauda longa, e regenera páginas sob demanda por $209/mês. A arquitetura que tornou isto possível exigiu eliminar nosso instinto de pré-renderizar tudo, repensar como as consultas do Supabase escalam sob ISR, e uma mudança de configuração do Vercel que reduziu os tempos de resposta em 340ms. Aqui está o que realmente funcionou.

A stack: Next.js 14 (App Router), Supabase (PostgreSQL + Edge Functions), Vercel (hosting + ISR), e uma dose saudável de pragmatismo. Cometemos erros. Batemos em paredes. Reescrevemos coisas que pensávamos estar terminadas. Mas a arquitetura final lida com 137.000+ páginas dinâmicas com TTFB sub-200ms globalmente, e nossa conta do Supabase fica abaixo de $100/mês.

Se você está construindo algo similar — um marketplace, um diretório, uma plataforma de anúncios — este é o artigo que desejávamos que existisse quando começamos.

Índice

Construindo um Diretório Global com 137K Anúncios usando Next.js, Supabase & Vercel ISR

Por Que Esta Stack

Avaliamos muitas opções antes de chegar em Next.js + Supabase + Vercel. Os requisitos principais eram:

  1. 137.000+ páginas únicas que motores de busca pudessem rastrear e indexar
  2. Carregamentos sub-segundo globalmente (usuários em 40+ países)
  3. Dados dinâmicos — anúncios atualizam diariamente, alguns a cada hora
  4. Busca full-text com filtragem facetada
  5. Consciente de orçamento — não era um moonshot financiado por VC

Consideramos Astro (ótimo para sites estáticos, mas precisávamos de mais interatividade dinâmica — embora nosso time de desenvolvimento Astro tenha lançado excelentes projetos de diretórios com ele). Analisamos WordPress + WPEngine. Brevemente consideramos um SPA puro com Algolia.

Next.js venceu por uma característica matadora: Incremental Static Regeneration. ISR significava que não tínhamos que escolher entre performance estática e conteúdo dinâmico. Poderíamos ter ambos.

Supabase venceu sobre PlanetScale e Neon por causa do pacote completo — autenticação, armazenamento, edge functions, e uma implementação genuinamente boa de Postgres com Row Level Security. Para um diretório, você precisa de tudo isso.

Vercel foi o alvo de deployment porque ISR funciona melhor na Vercel (sem surpresa). A integração é nativa. Revalidação sob demanda funciona perfeitamente.

E Auto-Hospedagem?

Prototipamos uma configuração Next.js auto-hospedada no Railway. Funcionou, mas ISR em Next.js auto-hospedado tem peculiaridades. A história de invalidação de cache é pior. Você precisa gerenciar sua própria camada de CDN. Para um time de 3 engenheiros, o overhead operacional não valia os $200/mês que economizaríamos.

A Camada de Dados: Supabase em Escala

Nosso banco de dados Supabase contém 137.000 anúncios, cada um com 40-60 campos. Categorias, localizações, informações de contato, descrições ricas, imagens, avaliações, horários de funcionamento — tudo.

Design do Schema

A maior decisão foi escolher entre um schema relacional normalizado ou uma abordagem mais orientada a documentos com colunas JSONB. Optamos por hibrido:

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

Dados relacionais estruturados para coisas que filtramos (categorias, cidades, países). JSONB para coisas semi-estruturadas que variam por anúncio (métodos de contato, atributos personalizados, arrays de mídia). Isto nos deu o melhor dos dois mundos — consultas rápidas e indexadas nas colunas relacionais e flexibilidade no resto.

O Vetor de Busca

Essa coluna search_vector é crítica. Preenchemos com um 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;

Isto significa que cada anúncio é buscável por full-text através do próprio Postgres. Nenhum serviço de busca externo necessário para os primeiros 100K anúncios. Vamos discutir quando isto falha depois.

Connection Pooling

Supabase usa PgBouncer para connection pooling. Com ISR, você consegue rajadas de invocações de funções serverless — cada uma precisa de uma conexão de banco de dados. Sem pooling, você esgotará conexões em minutos.

Usamos a string de conexão agrupada (porta 6543) para todos os contextos serverless e a conexão direta (porta 5432) apenas para migrações e tarefas administrativas. Esta é uma daquelas coisas que soa óbvia mas pega as pessoas.

// 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!, // Apenas servidor
  {
    db: { schema: 'public' },
    auth: { persistSession: false }
  }
)

Estratégia de Geração de Páginas: ISR, SSG, e o Problema dos 137K

Aqui é onde as coisas ficam interessantes. E onde cometemos nosso maior erro inicial.

A Abordagem Ingênua (Não Faça Isto)

Nossa primeira tentativa: gerar todas as 137.000 páginas no tempo de build usando generateStaticParams. A build levou 4 horas e 22 minutos. A camada gratuita do Vercel tem um limite de build de 45 minutos. Até o tier Pro limita em 6 horas. Mas o problema real não era o timeout — era o loop de feedback. Cada deploy levava meio dia. Isto é impraticável.

A Abordagem ISR (O Que Realmente Funciona)

Aqui está a estratégia que lançamos:

  1. No tempo de build: Gerar as top 5.000 páginas (por tráfego) estaticamente
  2. Na primeira requisição: Gerar páginas restantes sob demanda e cacheá-las
  3. Revalidação: Baseada em tempo (a cada 3600 segundos) + sob demanda via webhook
// app/listing/[slug]/page.tsx
import { supabase } from '@/lib/supabase'
import { notFound } from 'next/navigation'

export async function generateStaticParams() {
  // Apenas pré-gerar anúncios top por tráfego
  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 a 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} />
}

Revalidação Sob Demanda

Quando um proprietário de anúncio atualiza seus dados, não queremos esperar até uma hora para a página atualizar. Webhooks do Supabase disparam uma rota da API do 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 também
  }

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

Isto nos dá o melhor dos dois mundos: performance de site estático com frescor de site dinâmico. Builds completam em menos de 8 minutos. Páginas que não foram pré-geradas são criadas na primeira visita e cacheadas na edge.

Os Números

Métrica SSG Completo (Ingênuo) ISR (Produção)
Tempo de build 4h 22m 7m 40s
Páginas no deploy 137.000 5.000
Primeira visita (sem cache) N/A ~800ms
Visitas subsequentes ~120ms ~120ms
Latência de revalidação Full redeploy < 2 segundos
Minutos de build mensais Muito acima do limite ~230 minutos

Construindo um Diretório Global com 137K Anúncios usando Next.js, Supabase & Vercel ISR - arquitetura

Arquitetura de URL e SEO em Escala

Com 137.000 páginas, a estrutura de URL não é uma reflexão — é arquitetura. Cada URL é uma oportunidade de ranking.

A Hierarquia de URL

/                                    → Homepage
/categories/[category-slug]          → Páginas de categoria (48 categorias)
/locations/[country]/[city]          → Páginas de localização
/listing/[listing-slug]              → Anúncio individual
/search?q=...&category=...&city=...  → Resultados de busca (noindex)

As páginas de intersecção categoria + localização são o verdadeiro ouro de SEO:

/categories/restaurants/us/new-york   → "Restaurantes em Nova York"
/categories/hotels/uk/london          → "Hotéis em Londres"

Essas páginas de intersecção são geradas dinamicamente com ISR. Existem aproximadamente 12.000 combinações válidas. Cada uma alvo para uma palavra-chave específica de cauda longa.

Geração de Sitemap

Com 137K URLs, você precisa de arquivos de índice de sitemap. O limite do Google é 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 // Ficar abaixo do limite 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 em 4 sitemaps: sitemap-0.xml até sitemap-3.xml, referenciados por um índice de sitemap. Google Search Console indexou 98% das URLs submetidas em 6 semanas.

Dados Estruturados

Cada página de anúncio inclui dados estruturados JSON-LD. Para um diretório, o schema LocalBusiness é 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,
}

Busca e Filtragem: A Parte Difícil

Busca é sempre a parte difícil. Sempre.

Fase 1: Busca Full-Text do Postgres

Para nosso lançamento inicial, a busca tsvector do Postgres lidou com tudo. É rápida o suficiente para 137K linhas com um índice GIN. Os tempos de consulta em média 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: Quando Postgres Não Era Suficiente

Em torno de 80.000 anúncios, buscas complexas facetadas (categoria + localização + texto + sort) começaram a atingir 300-500ms. Aceitável para a maioria dos apps, mas nossos usuários esperavam resultados instantâneos.

Adicionamos Typesense como uma camada de busca. Não Algolia (muito cara em nossa escala — estaríamos pagando $500+/mês). Não Meilisearch (ótima, mas a busca geo do Typesense era melhor para nosso caso de uso).

Typesense roda em uma única instância Hetzner de $48/mês. Sincroniza do Supabase via reindexação completa noturna + atualizações de webhook em tempo real. As consultas de busca agora em média 8-15ms.

Solução de Busca Tempo de Consulta (p50) Tempo de Consulta (p99) Custo Mensal Busca Facetada
Postgres FTS 45ms 320ms $0 (incluído) Limitada
Typesense 9ms 28ms $48 Excelente
Algolia ~5ms ~15ms $500+ Excelente
Meilisearch ~8ms ~22ms $48 (auto-hospedado) Boa

Orçamentos de Performance e Cache na Edge

Definimos metas de performance agressivas desde o início:

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

Rede Edge do Vercel

Páginas ISR são cacheadas na rede edge do Vercel — 100+ PoPs globalmente. Uma vez que uma página é gerada e cacheada, é servida do local edge mais próximo. Por isto TTFB fica sob 200ms até mesmo para usuários no Sudeste Asiático ou América do Sul.

Otimização de Imagens

Cada anúncio possui 1-8 imagens. Isto é potencialmente mais de um milhão de imagens. Usamos otimização de imagem integrada do Vercel com 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}
/>

Imagens são armazenadas em Supabase Storage e servidas através do CDN de imagens do Vercel. As imagens originais frequentemente são 2-5MB; após otimização, 40-120KB. Isto sozinho economizou aproximadamente 80% em largura de banda.

Monitoramento e Observabilidade em Produção

Executar 137K páginas em produção sem monitoramento é como dirigir vendado. Aqui está nossa stack:

  • Vercel Analytics: Core Web Vitals, monitoramento de usuário real
  • Sentry: Rastreamento de erros (capturamos ~50 erros/dia, principalmente de bots enviando lixo)
  • Supabase Dashboard: Performance de banco de dados, análise de consultas
  • Checkly: Monitoramento sintético, intervalos de 5 minutos em caminhos críticos
  • Google Search Console: Cobertura de índice, estatísticas de rastreamento

O monitoramento mais valioso que configuramos foi uma consulta diária ao Supabase que conta páginas indexadas vs. anúncios ativos totais. Se a proporção cair abaixo de 95%, recebemos um alerta. Isto capturou uma regressão de sitemap em 24 horas de deployar uma mudança ruim.

Detalhamento de Custos: O Que Isto Realmente Custa

As pessoas sempre perguntam sobre custo. Aqui está o gasto mensal real a partir de Q1 2026:

Serviço Plano Custo Mensal
Vercel Pro $20
Vercel Bandwidth (excedentes) Pay-as-you-go ~$35
Supabase Pro $25
Supabase Database (computação) Instância Small $48
Typesense (Hetzner) CX31 $48
Checkly Starter $7
Sentry Team $26
Domínio + DNS (Cloudflare) Camada Gratuita $0
Total ~$209/mês

Servindo 137.000 páginas com milhões de page views mensais por cerca de $200/mês. Tente fazer isto com uma configuração tradicional de servidor rodando WordPress.

Se você está considerando um projeto similar e quer entender como uma arquitetura assim mapearia para seu orçamento, nossa página de preços detalha como tipicamente scopeamos projetos de diretório e marketplace.

O Que Faríamos Diferente

Começar com ISR desde o primeiro dia. Desperdiçamos duas semanas tentando fazer SSG completo funcionar antes de aceitar que a matemática não funcionava.

Usar Typesense desde o início. Postgres FTS foi bom no início, mas migrar busca no meio do projeto foi disruptivo. Os $48/mês teriam valido a pena desde o lançamento.

Investir em validação de dados mais cedo. Com 137K anúncios importados de várias fontes, qualidade de dados foi um pesadelo. Deveríamos ter construído esquemas Zod mais rigorosos e pipelines de validação antes da primeira importação, não depois que encontramos milhares de registros quebrados em produção.

Testar com volumes de dados realistas em staging. Nosso ambiente de staging tinha 500 anúncios. Consultas que funcionavam ótimas em 500 linhas caíram em 137K. Agora seedamos staging com uma amostra aleatória de 20% dos dados de produção.

Se você está planejando um build de diretório ou marketplace e quer evitar essas mesmas armadilhas, entre em contato com nosso time. Passamos por isto tantas vezes que sabemos onde estão as minas.

FAQ

Quanto tempo leva para construir um diretório com 100K+ anúncios com Next.js?

Para nosso time, a arquitetura inicial e features principais levaram cerca de 10 semanas. Import de dados, limpeza e validação adicionaram outras 3-4 semanas. Total do kickoff até lançamento em produção foi aproximadamente 14 semanas. Se você estiver trabalhando com um time de desenvolvimento Next.js que já fez isto antes, você pode reduzir 2-3 semanas disso.

Supabase pode lidar com 100.000+ linhas para um diretório?

Absolutamente. Supabase roda em Postgres, que lida com milhões de linhas sem quebrar nada. A chave é indexação apropriada — sem índices nas suas colunas mais consultadas, performance degrada rápido. Com os índices que descrevemos acima, nossas consultas em 137K linhas consistentemente retornam em menos de 50ms para buscas de registro único.

Qual é a diferença entre ISR e SSG para sites grandes?

SSG (Static Site Generation) constrói cada página no tempo de deploy. ISR (Incremental Static Regeneration) constrói um subconjunto no tempo de deploy e gera o resto sob demanda. Para sites com mais de ~10.000 páginas, ISR é praticamente necessário — builds SSG completas ficam muito lentas para ciclos de deployment razoáveis.

Como você lida com SEO para 137.000 páginas geradas dinamicamente?

Três coisas importam mais: geração apropriada de sitemap dividida por múltiplos arquivos, dados estruturados únicos (JSON-LD) em cada página de anúncio, e garantir que páginas geradas por ISR retornem códigos de status HTTP apropriados 200 (não soft 404s). Também geramos títulos de meta e descrições únicas por página usando os dados do anúncio — sem conteúdo de meta duplicado.

ISR do Vercel é confiável para produção em escala?

Em nossa experiência, sim. Estamos rodando esta configuração por mais de 8 meses com 99.98% de uptime. Os únicos incidentes foram auto-infligidos — um deploy ruim que quebrou nosso webhook de revalidação, e uma janela de manutenção do Supabase que causou 15 minutos de busca degradada. O cache edge do Vercel é sólido como rocha.

Devo usar Algolia ou Typesense para um diretório grande?

Depende do seu orçamento. Algolia é o padrão da indústria com a melhor experiência de desenvolvedor, mas fica cara além de 100K registros — espere $500-1000+/mês. Typesense entrega 90% da funcionalidade por uma fração do custo quando auto-hospedado. Escolhemos Typesense e não nos arrependemos.

Como você mantém 137.000 anúncios atualizados?

Usamos uma combinação de abordagens: revalidação sob demanda disparada por webhooks do Supabase quando anúncios individuais mudam, revalidação ISR baseada em tempo (a cada hora) como rede de segurança, e um job em lote noturno que verifica dados obsoletos e dispara revalidação em massa. Proprietários de anúncios também podem solicitar manualmente uma atualização de página através do seu dashboard.

Esta arquitetura pode funcionar com um headless CMS em vez de Supabase?

Sim, mas com trade-offs. Uma configuração headless CMS como Sanity ou Contentful funciona bem do lado de gerenciamento de conteúdo, mas você provavelmente ainda precisará de um banco de dados para busca e consultas complexas. Construímos projetos de diretório onde o conteúdo editorial vive em um headless CMS e os dados de anúncio vivem em Postgres — é uma abordagem híbrida válida.