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
Top comments (0)