DEV Community

Pedro Parker
Pedro Parker

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

A page.tsx é um Server Component, sem "use client". Isso significa:

  1. Zero JavaScript enviado ao browser para a renderização inicial
  2. generateMetadata roda no servidor — Google recebe os meta tags corretos
  3. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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 }
  );
}
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

Componente reutilizável para injetar no <head>:

function JsonLd({ data }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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*`,
  }];
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Server Components = SEO perfeito sem hydration cost
  2. generateMetadata = meta tags dinâmicos sem SSG
  3. cache() = deduplica data fetching entre metadata e componente
  4. next/dynamic + lazy() = code splitting granular
  5. Middleware = canonicalização de URLs sem lógica no componente
  6. Rewrites = proxy transparente sem CORS

Top comments (0)