Construindo um Diretório Global com 137K Listagens usando Next.js, Supabase e Vercel ISR
No ano passado, lançamos um diretório global com 137.000 anúncios. Não era um protótipo. Não era um MVP "otimizaremos depois". Era um sistema de produção que serve milhões de visualizações de página, classifica milhares de palavras-chave de cauda longa e regenera páginas sob demanda sem suar. Esta é a história de como o construímos — e as decisões arquiteturais que tornaram isso possível.
O stack: Next.js 14 (App Router), Supabase (PostgreSQL + Edge Functions), Vercel (hospedagem + ISR) e uma boa dose 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 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 eu gostaria que existisse quando começamos.
Tabela de Conteúdos
- Por que Este Stack
- A Camada de Dados: Supabase em Escala
- Estratégia de Geração de Páginas: ISR, SSG e o Problema dos 137K
- Arquitetura de URL e SEO em Escala
- Busca e Filtragem: A Parte Difícil
- Orçamentos de Performance e Cache de Edge
- Monitoramento e Observabilidade em Produção
- Detalhamento de Custos: O que Isso Realmente Custa
- O que Faríamos Diferente
- FAQ

Por que Este Stack
Avaliamos muitas opções antes de chegar em Next.js + Supabase + Vercel. Os requisitos principais eram:
- 137.000+ páginas únicas que mecanismos de busca pudessem rastrear e indexar
- Carregamentos de página sub-segundo globalmente (usuários em 40+ países)
- Dados dinâmicos — anúncios atualizam diariamente, alguns a cada hora
- Busca por texto completo com filtragem facetada
- Consciência de custo — isto não era uma aposta VC-financiada
Consideramos Astro (ótimo para sites estáticos, mas precisávamos de mais interatividade dinâmica — embora nossa equipe de desenvolvimento Astro tenha entregue excelentes projetos de diretório com ele). Olhamos para WordPress + WPEngine. Brevemente consideramos um SPA puro com Algolia.
Next.js venceu por causa de uma característica assassina: Incremental Static Regeneration. ISR significava que não precisávamos 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 implantação porque ISR funciona melhor em Vercel (sem surpresas). A integração é nativa. Revalidação sob demanda funciona muito bem.
E Quanto a Auto-hospedagem?
Prototipamos uma configuração Next.js auto-hospedada em 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 uma equipe de 3 engenheiros, a sobrecarga operacional não valia os $200/mês que economizaríamos.
A Camada de Dados: Supabase em Escala
Nosso banco de dados Supabase mantém 137.000 anúncios, cada um com 40-60 campos. Categorias, locais, informações de contato, descrições ricas, imagens, classificações, horários de funcionamento — tudo.
Design de Schema
A maior decisão foi se usar um schema relacional normalizado ou uma abordagem mais orientada a documentos com colunas JSONB. Fomos 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);
Dados relacionais estruturados para coisas que filtramos (categorias, cidades, países). JSONB para dados semi-estruturados que variam por anúncio (métodos de contato, atributos customizados, arrays de mídia). Isso nos deu o melhor dos dois mundos — consultas indexadas rápidas nas colunas relacionais e flexibilidade no resto.
O Vetor de Busca
A coluna search_vector é crítica. Nós a 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;
Isso significa que cada anúncio é totalmente pesquisável através do próprio Postgres. Nenhum serviço de busca externo necessário para os primeiros 100K anúncios. Falaremos sobre quando isso quebra mais tarde.
Connection Pooling
Supabase usa PgBouncer para connection pooling. Com ISR, você recebe explosões de invocações de funções serverless — cada uma precisa de uma conexão de banco de dados. Sem pooling, você vai esgotar as conexões em minutos.
Usamos a string de conexão em pool (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 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 que 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 construção usando generateStaticParams. A construção levou 4 horas e 22 minutos. A camada gratuita do Vercel tem um limite de construção de 45 minutos. Mesmo a camada Pro limita a 6 horas. Mas o problema real não era o tempo limite — 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 foi lançada:
- No tempo de construção: Gera as 5.000 páginas principais (por tráfego) estaticamente
- Na primeira requisição: Gera páginas restantes sob demanda e as coloca em cache
- 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() {
// Pré-gera apenas os anúncios principais 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 // Revalida 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 se atualizar. Webhooks do Supabase acionam uma rota de 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(`/`) // Revalida a página inicial também
}
return NextResponse.json({ revalidated: true })
}
Isso nos dá o melhor dos dois mundos: performance de site estático com frescura de site dinâmico. As construções são concluídas em menos de 8 minutos. Páginas que não foram pré-geradas são criadas na primeira visita e armazenadas em cache na edge.
Os Números
| Métrica | SSG Completo (Ingênuo) | ISR (Produção) |
|---|---|---|
| Tempo de construção | 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 | Redeploy completo | < 2 segundos |
| Minutos de construção mensal | Muito acima do limite | ~230 minutos |

Arquitetura de URL e SEO em Escala
Com 137.000 páginas, a estrutura de URL não é um detalhe — é arquitetura. Cada URL é uma oportunidade de classificação.
A Hierarquia de URL
/ → Página inicial
/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 interseção de categoria + localização são o verdadeiro ouro SEO:
/categories/restaurants/us/new-york → "Restaurantes em Nova York"
/categories/hotels/uk/london → "Hotels em Londres"
Essas páginas de interseção são geradas dinamicamente com ISR. Existem aproximadamente 12.000 combinações válidas. Cada uma visa 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 // Mantenha-se 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 enviadas dentro de 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 por Texto Completo do Postgres
Para nosso lançamento inicial, a busca tsvector do Postgres tratou tudo. É rápido o suficiente para 137K linhas com um índice GIN. Os tempos de consulta ficaram 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 facetadas complexas (categoria + localização + texto + classificação) começaram a atingir 300-500ms. Aceitável para a maioria dos aplicativos, mas nossos usuários esperavam resultados instantâneos.
Adicionamos Typesense como uma camada de busca. Não Algolia (muito caro em nossa escala — estaríamos pagando $500+/mês). Não Meilisearch (ótimo, 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 através de um reindex completo noturno + atualizações webhook em tempo real. As consultas de busca agora ficam 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 de Edge
Estabelecemos metas de performance agressivas desde o início:
- TTFB: < 200ms (global p75)
- LCP: < 1.5s
- CLS: < 0.05
- Peso total da página: < 300KB (carga inicial)
Rede de Edge Vercel
Páginas ISR são armazenadas em cache na rede de edge do Vercel — 100+ PoPs globalmente. Uma vez que uma página é gerada e armazenada em cache, ela é servida da localização de edge mais próxima. É por isso que TTFB fica abaixo de 200ms mesmo para usuários no Sudeste Asiático ou América do Sul.
Otimização de Imagem
Cada anúncio tem 1-8 imagens. Isso é 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}
/>
As imagens são armazenadas em Supabase Storage e servidas através do CDN de imagens do Vercel. As imagens originais costumam ter 2-5MB; após otimização, elas têm 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 os olhos vendados. Aqui está nosso stack:
- Vercel Analytics: Core Web Vitals, monitoramento de usuário real
- Sentry: Rastreamento de erros (capturamos ~50 erros/dia, a maioria de bots enviando lixo)
- Painel Supabase: 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 Supabase diária que conta páginas indexadas vs. anúncios ativos totais. Se a razão cair abaixo de 95%, recebemos um alerta. Isto capturou uma regressão de sitemap dentro de 24 horas de fazer deploy de uma mudança ruim.
Detalhamento de Custos: O que Isso Realmente Custa
As pessoas sempre perguntam sobre custo. Aqui está o gasto mensal real a partir de Q1 2025:
| Serviço | Plano | Custo Mensal |
|---|---|---|
| Vercel | Pro | $20 |
| Vercel Bandwidth (excessos) | Pagamento conforme o uso | ~$35 |
| Supabase | Pro | $25 |
| Supabase Database (computação) | Instância pequena | $48 |
| Typesense (Hetzner) | CX31 | $48 |
| Checkly | Iniciante | $7 |
| Sentry | Equipe | $26 |
| Domínio + DNS (Cloudflare) | Camada gratuita | $0 |
| Total | ~$209/mês |
Servindo 137.000 páginas com milhões de visualizações de página mensais por cerca de $200/mês. Tente fazer isso com uma configuração de servidor tradicional rodando WordPress.
Se você está considerando um projeto similar e quer entender como uma arquitetura como esta se mapeia para seu orçamento, nossa página de precificação detalha como tipicamente escopo projetos de diretório e marketplace.
O que Faríamos Diferente
Comece com ISR desde o dia um. Desperdiçamos duas semanas tentando fazer SSG completo funcionar antes de aceitar que a matemática não acertava.
Use 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.
Invista em validação de dados mais cedo. Com 137K anúncios importados de várias fontes, a qualidade dos dados foi um pesadelo. Devemos ter construído esquemas Zod mais rígidos e pipelines de validação antes da primeira importação, não depois de encontrar milhares de registros quebrados em produção.
Teste com volumes de dados realistas em staging. Nosso ambiente de staging tinha 500 anúncios. Consultas que funcionavam muito bem em 500 linhas caíram na produção com 137K. Agora fazemos seed de staging com uma amostra aleatória de 20% dos dados de produção.
Se você está planejando construir um diretório ou marketplace e quer evitar essas mesmas armadilhas, entre em contato com nossa equipe. Passamos por isso o suficiente para saber onde estão as minas.
FAQ
Quanto tempo leva para construir um diretório com 100K+ anúncios usando Next.js?
Para nossa equipe, a arquitetura inicial e recursos principais levaram cerca de 10 semanas. Importação de dados, limpeza e validação adicionaram outras 3-4 semanas. O total do kickoff ao lançamento de produção foi aproximadamente 14 semanas. Se você está trabalhando com uma equipe de desenvolvimento Next.js que já fez isto antes, você pode economizar 2-3 semanas.
O 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 um suor. A chave é indexação apropriada — sem índices nas suas colunas mais consultadas, a performance se degrada rapidamente. 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 — construções SSG completas se tornam muito lentas para ciclos de deploy 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 em 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 200 apropriados (não 404 suaves). Também geramos títulos meta e descrições únicos por página usando os dados do anúncio — nenhum conteúdo meta duplicado.
O ISR Vercel é confiável para produção em escala?
Na nossa experiência, sim. Estamos rodando essa 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 Supabase que causou 15 minutos de degradação de busca. O cache de edge do Vercel é muito sólido.
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 acima de 100K registros — espere $500-1000+/mês. Typesense entrega 90% da funcionalidade a 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 acionada por webhooks Supabase quando anúncios individuais mudam, revalidação ISR baseada em tempo (a cada hora) como rede de segurança, e um trabalho em lote noturno que verifica dados obsoletos e aciona revalidação em massa. Os proprietários de anúncios também podem solicitar manualmente uma atualização de página através de seu painel.
Esta arquitetura pode funcionar com um CMS headless em vez de Supabase?
Sim, mas com trade-offs. Uma configuração de CMS headless como Sanity ou Contentful funciona bem no lado de gerenciamento de conteúdo, mas você provavelmente ainda vai 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 CMS headless e os dados de anúncio vivem em Postgres — é uma abordagem híbrida válida.