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.
O problema: 55M páginas únicas
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.
Arquitetura: Server Component puro
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
-
generateMetadataroda 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 (
<main className="flex-1 w-full px-3 sm:px-6 py-3 sm:py-4">
<JsonLd data={organizationSchema} />
<CompanyDetail empresa={empresa} />
</main>
);
}
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();
});
OpenGraph Image dinâmica
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(
<div style={{ display: "flex", flexDirection: "column", /* ... */ }}>
<h1>{empresa.razao_social}</h1>
<p>CNPJ {formatCnpj(empresa.cnpj)}</p>
<p>Situação: {empresa.situacao_cadastral}</p>
</div>,
{ 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.
Code Splitting agressivo
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.
Canonical URLs via Middleware
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.
JSON-LD para Rich Results
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 <head>:
function JsonLd({ data }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
API Proxy via Rewrites
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
}
Resultados de performance
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.
Conclusão
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
United States
NORTH AMERICA
Related News
How Braze’s CTO is rethinking engineering for the agentic area
10h ago
Amazon Employees Are 'Tokenmaxxing' Due To Pressure To Use AI Tools
21h ago

Implementing Multicloud Data Sharding with Hexagonal Storage Adapters
15h ago

DeepMind’s CEO Says AGI May Be ~4 Years Away. The Last Three Missing Pieces Are Not What Most People Think.
15h ago

CCSnapshot - A Claude Code Configs Transfer Tool
21h ago