AI News Hub Logo

AI News Hub

Next.js 15 com 55 milhões de páginas dinâmicas: SSR, SEO e performance

DEV Community
Pedro Parker

O CNPJ Aberto tem uma página dedicada para cada empresa brasileira. São 55 milhões de páginas — cada uma com título, description, OpenGraph image e JSON-LD únicos. Gerar tudo em build time (SSG) levaria dias e ocuparia terabytes. Usar client-side rendering mataria o SEO. A solução? Server Components com SSR on-demand no Next.js 15. Neste post, vou mostrar as decisões de arquitetura que fazem isso funcionar. Cada página /cnpj/[cnpj] precisa de: ✅ Title dinâmico — "EMPRESA XPTO LTDA — CNPJ 12.345.678/0001-00" ✅ Meta description — com situação, local, CNAE, capital social ✅ OpenGraph image — gerada dinamicamente com dados da empresa ✅ JSON-LD — schema.org Organization para rich results ✅ Canonical URL — para evitar duplicatas (CNPJ formatado vs não formatado) ✅ Conteúdo completo — renderizado no servidor para crawlers getStaticPaths com 55M paths? Impossível. getStaticProps com ISR? O cold start para 55M paths seria brutal. Server Components com generateMetadata é a resposta. frontend/src/app/cnpj/[cnpj]/page.tsx → Server Component (SSR) frontend/src/components/CompanyDetail.tsx → Dynamic imports para client components A page.tsx é um Server Component, sem "use client". Isso significa: Zero JavaScript enviado ao browser para a renderização inicial generateMetadata roda no servidor — Google recebe os meta tags corretos Data fetching direto no componente, sem useEffect/useState // page.tsx — Server Component export async function generateMetadata({ params }) { const { cnpj } = await params; const empresa = await getEmpresa(cnpj); if (!empresa) { return { title: "CNPJ não encontrado", robots: { index: false } }; } const matriz = empresa.estabelecimentos.find( e => e.identificador_matriz_filial === "Matriz" ); const cnpjFormatted = formatCnpj(matriz?.cnpj || cnpj); return { title: `${empresa.razao_social} — CNPJ ${cnpjFormatted}`, description: [ `CNPJ ${cnpjFormatted}`, `Situação: ${matriz?.situacao_cadastral}`, `${matriz?.municipio}/${matriz?.uf}`, `Capital Social: R$ ${empresa.capital_social?.toLocaleString("pt-BR")}`, `${empresa.socios.length} sócio(s)`, ].filter(Boolean).join(" · "), alternates: { canonical: `/cnpj/${cnpj.replace(/\D/g, "")}` }, openGraph: { title: empresa.razao_social, type: "website" }, twitter: { card: "summary_large_image" }, }; } export default async function CnpjPage({ params }) { const { cnpj } = await params; const empresa = await getEmpresa(cnpj); if (!empresa) notFound(); return ( ); } Detalhe: getEmpresa é wrapped com cache do React, se generateMetadata e o componente chamam a mesma função com o mesmo argumento, a query só roda uma vez. import { cache } from "react"; export const getEmpresa = cache(async function getEmpresa(cnpj: string) { const res = await apiFetch(`${getApiBase()}/api/cnpj/${cleaned}`); if (res.status === 404) return null; return res.json(); }); Cada empresa tem uma OG image única, gerada sob demanda pelo Next.js: // cnpj/[cnpj]/opengraph-image.tsx import { ImageResponse } from "next/og"; export default async function OGImage({ params }) { const empresa = await getEmpresa(params.cnpj); return new ImageResponse( {empresa.razao_social} CNPJ {formatCnpj(empresa.cnpj)} Situação: {empresa.situacao_cadastral} , { width: 1200, height: 630 } ); } Quando alguém compartilha um link de empresa no Twitter/LinkedIn, a imagem mostra dados reais da empresa. O Next.js cacheia a imagem depois da primeira geração. A página de empresa tem muitos componentes interativos: rede societária (grafo), mapa, red flags, score de saúde, notas do usuário... Carregar tudo de uma vez seria ~200KB de JavaScript. Solução: next/dynamic para tudo que não é above-the-fold: // CompanyDetail.tsx import dynamic from "next/dynamic"; const CardLayoutManager = dynamic(() => import("./CardLayoutManager")); const AddressCompanies = dynamic(() => import("./AddressCompanies")); const MonitorButton = dynamic(() => import("./MonitorButton")); const ProActionBar = dynamic(() => import("./ProActionBar")); E dentro do CardLayoutManager, mais lazy loading: const PartnerNetwork = lazy(() => import("./PartnerNetwork")); const CorporateGroup = lazy(() => import("./CorporateGroup")); const RedFlags = lazy(() => import("./RedFlags")); const HealthScore = lazy(() => import("./HealthScore")); const CompanyMap = lazy(() => import("./CompanyMap")); Cada componente pesado é um chunk separado que só carrega quando o card entra no viewport. Resultado: o JavaScript inicial da página caiu de ~200KB para ~45KB. Um CNPJ pode ser digitado de várias formas: /cnpj/12345678000100 (limpo) /cnpj/12.345.678/0001-00 (formatado) /cnpj/12.345.678%2F0001-00 (URL-encoded) Todas devem apontar para a mesma página. O middleware do Next.js faz redirect 301 automático: // middleware.ts export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; if (pathname.startsWith("/cnpj/")) { const raw = pathname.slice("/cnpj/".length); const digits = raw.replace(/\D/g, ""); // Se tem 14 dígitos mas a URL não é limpa → redirect 301 if (digits.length === 14 && raw !== digits) { const url = request.nextUrl.clone(); url.pathname = `/cnpj/${digits}`; return NextResponse.redirect(url, 301); } } return NextResponse.next(); } Isso garante que o Google indexe apenas a versão canônica de cada URL. Structured data ajuda o Google a entender a página e exibir rich snippets: const jsonLd = { "@context": "https://schema.org", "@type": "Organization", name: empresa.razao_social, alternateName: matriz?.nome_fantasia, taxID: formatCnpj(cnpj), address: { "@type": "PostalAddress", streetAddress: `${matriz.logradouro} ${matriz.numero}`, addressLocality: matriz.municipio, addressRegion: matriz.uf, postalCode: matriz.cep, addressCountry: "BR", }, telephone: matriz?.telefone, email: matriz?.email, }; Componente reutilizável para injetar no : function JsonLd({ data }) { return ( ); } O frontend faz requests para /api/* que o Next.js proxeia para o backend FastAPI: // next.config.js async rewrites() { return [{ source: "/api/:path*", destination: `${process.env.NEXT_PUBLIC_API_URL}/api/:path*`, }]; } Vantagem: O browser nunca fala direto com o backend. Sem CORS, sem exposição de IP interno, cookies funcionam transparentemente. Para SSR, o servidor Next.js fala direto com o backend via URL interna (API_URL), evitando um hop extra: function getApiBase() { if (typeof window === "undefined") { return process.env.API_URL || "http://localhost:8000"; } return ""; // Client → usa rewrites } Testado com PageSpeed Insights: Métrica Valor LCP (Largest Contentful Paint) ~1.2s FID (First Input Delay) ~50ms CLS (Cumulative Layout Shift) 0.02 Time to First Byte ~200ms JavaScript total (initial) ~45KB gzipped O segredo é simples: Server Components renderizam o HTML no servidor, dynamic imports carregam JavaScript sob demanda, e o cache do React evita queries duplicadas. Para sites com milhões de páginas dinâmicas, Next.js 15 com App Router é uma combinação poderosa: Server Components = SEO perfeito sem hydration cost generateMetadata = meta tags dinâmicos sem SSG cache() = deduplica data fetching entre metadata e componente next/dynamic + lazy() = code splitting granular Middleware = canonicalização de URLs sem lógica no componente Rewrites = proxy transparente sem CORS