Next.js 16 App Router: Production Patterns and Pitfalls

Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.

E
ECOSIRE Research and Development Team
|19 de março de 202612 min de leitura2.6k Palavras|

Next.js 16 App Router: padrões de produção e armadilhas

O Next.js App Router mudou fundamentalmente a forma como construímos aplicativos React e, no Next.js 16, os padrões amadureceram e se tornaram algo genuinamente pronto para produção. Mas a mudança conceitual do Pages Router é acentuada: componentes de servidor, pré-renderização parcial, layouts aninhados e um modelo de busca de dados completamente redesenhado exigem uma religação do modelo mental que a documentação por si só não consegue transmitir totalmente.

Este guia aborda os padrões que realmente importam na produção: aqueles que evitam o envenenamento do cache, eliminam pacotes de clientes desnecessários e mantêm seus Core Web Vitals verdes em escala. Tiraremos proveito da experiência de construção de um aplicativo Next.js 16 de 249 páginas com i18n de 11 localidades, imagens OG dinâmicas e renderização do lado do servidor para 5.577 arquivos de conteúdo MDX.

Principais conclusões

  • Nunca use export const metadata em páginas que precisam de SEO com reconhecimento de localidade – sempre use generateMetadata()
  • Os componentes do servidor são o padrão; adicione 'use client' apenas quando você realmente precisar de interatividade
  • loading.tsx e error.tsx são colocados em seu segmento de rota, não na raiz
  • O arquivo proxy.ts é o middleware do Next.js 16 - não crie middleware.ts E proxy.ts
  • fetch() em componentes do servidor desduplica automaticamente solicitações idênticas na mesma renderização
  • unstable_cache com tags permite a invalidação cirúrgica do cache sem revalidar tudo
  • generateStaticParams() é necessário para rotas dinâmicas que você deseja gerar estaticamente em tempo de construção
  • Os dados estruturados JSON-LD devem limpar quebras de script usando codificação Unicode

O modelo mental do componente servidor

O maior erro que os desenvolvedores cometem com o App Router é chegar ao 'use client' muito cedo. No Next.js 16, cada componente no diretório app/ é um componente de servidor por padrão. Este é o padrão correto – os componentes do servidor são renderizados no servidor, não têm impacto no pacote JavaScript e podem acessar diretamente bancos de dados e APIs.

A regra: empurre 'use client' para as folhas da sua árvore de componentes. Uma página pode ser um componente de servidor que busca dados e os passa para um shell de componente cliente fino que lida com interações.

// app/[locale]/blog/[slug]/page.tsx — Server Component (no directive needed)
import { getBlogPost } from '@/lib/blog';
import { BlogContent } from '@/components/blog/blog-content'; // Client Component

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string; locale: string }>;
}) {
  const { slug, locale } = await params;
  const post = await getBlogPost(slug, locale);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      {/* Server Component — no JS sent to browser */}
      <PostMeta author={post.author} date={post.date} />
      {/* Client Component — only this file sent as JS */}
      <BlogContent content={post.content} />
    </article>
  );
}
// components/blog/blog-content.tsx — Client Component
'use client';

import { useState } from 'react';

export function BlogContent({ content }: { content: string }) {
  const [expanded, setExpanded] = useState(false);

  return (
    <div>
      <div>{content}</div>
      {/* Client-side interactivity lives here */}
    </div>
  );
}

O principal insight: BlogContent envia JavaScript para o navegador. PostMeta não. Cada diretiva 'use client' desnecessária infla seu pacote.


generateMetadata sobre metadados estáticos

O export const metadata estático é tentador por sua simplicidade, mas não pode reconhecer a localidade, não pode acessar parâmetros de rota e não pode buscar dados. Para qualquer aplicação real, generateMetadata() é a única escolha correta:

// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string; locale: string }>;
}): Promise<Metadata> {
  const { slug, locale } = await params;
  const post = await getBlogPost(slug, locale);

  if (!post) return {};

  const locales = ['en', 'zh', 'es', 'ar', 'pt', 'fr', 'de', 'ja', 'tr', 'hi', 'ur'];

  return {
    title: post.title,
    description: post.description,
    alternates: {
      canonical: `https://ecosire.com/${locale === 'en' ? '' : locale + '/'}blog/${slug}`,
      languages: Object.fromEntries(
        locales.map((loc) => [
          loc,
          `https://ecosire.com/${loc === 'en' ? '' : loc + '/'}blog/${slug}`,
        ])
      ),
    },
    openGraph: {
      title: post.title,
      description: post.description,
      images: [`/api/og/blog/${slug}?locale=${locale}`],
      type: 'article',
      publishedTime: post.date,
    },
  };
}

O campo alternates.languages gera tags hreflang para cada localidade – essencial para SEO multilíngue. Os mecanismos de pesquisa os utilizam para fornecer a versão no idioma correto para cada usuário.


Layouts aninhados e estado compartilhado

O sistema de layout aninhado do App Router é poderoso, mas tem uma restrição não óbvia: os layouts não podem passar dados para seus filhos por meio de props. A solução alternativa é React Context (somente componentes do cliente) ou buscar dados independentemente em cada nível.

app/
  [locale]/
    layout.tsx          <- Locale layout: fonts, i18n provider, theme
    page.tsx            <- Homepage
    blog/
      layout.tsx        <- Blog layout: sidebar, breadcrumbs
      page.tsx          <- Blog list
      [slug]/
        layout.tsx      <- Post layout: reading progress, TOC
        page.tsx        <- Post content
        loading.tsx     <- Skeleton while post loads
        error.tsx       <- Error boundary for this segment

Cada layout.tsx envolve seus filhos de forma independente. Os dados buscados em um layout pai não estão automaticamente disponíveis para os filhos — cada componente que precisa de dados os busca de forma independente. Next.js 16 desduplica chamadas fetch() idênticas dentro de um ciclo de renderização, portanto, isso não é tão caro quanto parece.

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const messages = await getMessages();

  return (
    <html lang={locale} dir={['ar', 'ur'].includes(locale) ? 'rtl' : 'ltr'}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Estratégia de cache: as quatro camadas

Next.js 16 possui quatro camadas de cache distintas. É necessário compreender todos os quatro para raciocinar por que uma página está ou não sendo atualizada:

1. Solicitar Memoização — O mesmo URL fetch() dentro de um ciclo de renderização é chamado uma vez. Automático, sem configuração.

2. Cache de dados — Persistente entre solicitações. As respostas fetch() são armazenadas em cache indefinidamente por padrão.

// Force revalidation every hour
const data = await fetch('/api/posts', { next: { revalidate: 3600 } });

// Never cache (equivalent to SSR)
const data = await fetch('/api/posts', { cache: 'no-store' });

3. Full Route Cache — Carga útil HTML e RSC estática armazenada em cache no momento da construção para rotas estáticas.

4. Cache do roteador — Cache do lado do cliente de rotas visitadas. Persiste na sessão do navegador.

Para consultas ao banco de dados (sem busca), use unstable_cache:

import { unstable_cache } from 'next/cache';

const getCachedBlogPosts = unstable_cache(
  async (locale: string) => {
    return db.select().from(posts).where(eq(posts.locale, locale));
  },
  ['blog-posts'], // Cache key
  {
    revalidate: 3600, // Revalidate hourly
    tags: ['blog'], // Tag for manual invalidation
  }
);

// Invalidate from a Server Action or Route Handler:
import { revalidateTag } from 'next/cache';
revalidateTag('blog'); // All blog-tagged caches cleared

Limites de erro: dois níveis necessários

Os aplicativos de produção precisam de dois limites de erro: um para páginas com reconhecimento de localidade e um substituto global para erros no nível raiz.

// app/[locale]/error.tsx — Locale-level error boundary
'use client';

import { useTranslations } from 'next-intl';
import { useEffect } from 'react';

export default function LocaleError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const t = useTranslations('errors');

  useEffect(() => {
    // Log to error tracking service
    console.error(error);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h2>{t('title')}</h2>
      <p>{t('description')}</p>
      <button onClick={reset}>{t('retry')}</button>
    </div>
  );
}
// app/global-error.tsx — Root fallback (no i18n available here)
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // Cannot use useTranslations here — no NextIntlClientProvider above
  // Use inline styles — no Tailwind in root error boundaries
  return (
    <html>
      <body>
        <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '2rem' }}>
          <h2 style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>Something went wrong</h2>
          <button
            onClick={reset}
            style={{ marginTop: '1rem', padding: '0.5rem 1rem', cursor: 'pointer' }}
          >
            Try again
          </button>
        </div>
      </body>
    </html>
  );
}

O global-error.tsx substitui o layout.tsx raiz quando é acionado, portanto deve incluir as tags <html> e <body>. Ele não pode acessar provedores de contexto que residem no layout - sem i18n, sem tema, sem contexto de autenticação.


Middleware: proxy.ts vs middleware.ts

Next.js usa middleware.ts (ou middleware.js) para middleware de ponta. Em um projeto usando next-intl v4, a configuração do middleware geralmente é renomeada para proxy.ts e reexportada, criando uma armadilha crítica: não crie middleware.ts e proxy.ts. Ter ambos causa um erro de construção.

O padrão correto para combinar i18n com proteção de autenticação:

// src/proxy.ts — This IS the Next.js middleware
import createMiddleware from 'next-intl/middleware';
import { NextRequest, NextResponse } from 'next/server';
import { routing } from './i18n/routing';

const intlMiddleware = createMiddleware(routing);

const protectedPaths = ['/dashboard', '/portal'];

export default function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Check auth for protected paths
  const isProtected = protectedPaths.some((path) =>
    pathname.includes(path)
  );

  if (isProtected) {
    const token = request.cookies.get('ecosire_auth');
    if (!token) {
      const url = request.nextUrl.clone();
      url.pathname = '/auth/login';
      // Prevent open redirect — never allow // prefix
      const redirect = pathname.startsWith('//') ? '/' : pathname;
      url.searchParams.set('redirect', redirect);
      return NextResponse.redirect(url);
    }
  }

  return intlMiddleware(request);
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};

Este único arquivo lida com a detecção de localidade i18n e a proteção de autenticação. O config.matcher exclui arquivos estáticos, rotas de API e componentes internos do Next.js.


Segurança de dados estruturados JSON-LD

Os scripts JSON-LD são um vetor XSS comum. Se seus dados contiverem uma tag de script de fechamento, eles poderão sair do bloco de script. A correção é obrigatória – codifique o caractere menor que:

// components/seo/JsonLd.tsx
interface JsonLdProps {
  data: Record<string, unknown>;
}

export function JsonLd({ data }: JsonLdProps) {
  // Sanitize script tag breakout attempts by encoding '<' as unicode
  const json = JSON.stringify(data).replace(/</g, '\\u003c');

  return (
    <script
      type="application/ld+json"
      // Safe: content is JSON-serialized and sanitized above
      suppressHydrationWarning
      ref={(el) => { if (el) el.textContent = json; }}
    />
  );
}

Uso em uma página:

<JsonLd
  data={{
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.description,
    inLanguage: locale,
    author: {
      '@type': 'Organization',
      name: 'ECOSIRE',
    },
  }}
/>

O campo inLanguage é essencial para SEO multilíngue – ele informa aos rastreadores de IA e aos mecanismos de pesquisa em qual idioma o conteúdo está, melhorando a precisão da classificação para consultas em idiomas diferentes do inglês.


Imagens dinâmicas de OG

A API ImageResponse do Next.js 16 gera imagens OG por página na borda. A configuração para postagens do blog:

// app/api/og/blog/[slug]/route.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export async function GET(
  request: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params;
  const post = await getPostMeta(slug);

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'flex-end',
          width: '100%',
          height: '100%',
          background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)',
          padding: '60px',
        }}
      >
        <div style={{ fontSize: 48, fontWeight: 700, color: 'white', lineHeight: 1.2 }}>
          {post.title}
        </div>
        <div style={{ fontSize: 24, color: '#94a3b8', marginTop: 20 }}>
          {post.description}
        </div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

Armadilhas e soluções comuns

Armadilha 1: buscar dados em componentes de cliente quando os componentes de servidor funcionariam

Se um componente exibe apenas dados e não precisa do estado React ou de APIs do navegador, ele deve ser um componente de servidor. Adicionar 'use client' força tudo o que importa para o pacote do cliente.

Armadilha 2: Limites de Suspense ausentes em torno dos componentes de servidor assíncronos

// Without Suspense — entire page blocks until data loads
export default async function Page() {
  const data = await slowFetch(); // 2 seconds
  return <div>{data}</div>;
}

// With Suspense — page streams, slow part shows skeleton
export default function Page() {
  return (
    <Suspense fallback={<DataSkeleton />}>
      <SlowDataComponent />
    </Suspense>
  );
}

Erro 3: usar cookies() ou headers() em funções em cache

cookies() e headers() são APIs dinâmicas que optam pela rota para renderização dinâmica. Usá-los dentro de um retorno de chamada unstable_cache quebra silenciosamente o cache.

** Armadilha 4: Esquecer de aguardar parâmetros em Next.js 16 **

No Next.js 16, params agora é uma promessa. Esquecer await causa erros de tempo de execução:

// Next.js 15 — params was synchronous
export default function Page({ params }: { params: { slug: string } }) {
  const { slug } = params;
}

// Next.js 16 — params is async
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params; // Required
}

Perguntas frequentes

Quando devo usar generateStaticParams() versus renderização dinâmica?

Use generateStaticParams() para conteúdo que muda com pouca frequência (postagens de blog, páginas de produtos, documentação). Isso gera HTML estático em tempo de construção com custo zero de tempo de execução. Use a renderização dinâmica para conteúdo personalizado, dados em tempo real ou páginas com milhares de variantes onde o tempo de construção seria proibitivo. Combine ambos com ISR (revalidate) para conteúdo que muda ocasionalmente.

Como lidar com a autenticação no App Router?

Armazene tokens de autenticação em cookies HttpOnly – nunca localStorage ou sessionStorage. Use middleware (proxy.ts) para verificar a presença de cookies em rotas protegidas. Em Componentes do Servidor, leia cookies com cookies() de next/headers. Passe os dados do usuário do servidor para o cliente por meio de props ou React Context, nunca relendo o cookie nos componentes do cliente.

Qual é a diferença entre loading.tsx e Suspense?

loading.tsx é um arquivo especial que Next.js envolve automaticamente em um limite Suspense para todo o segmento de rota. Ele é exibido imediatamente enquanto o conteúdo assíncrono do segmento é carregado. Os limites manuais Suspense oferecem um controle mais preciso - você pode mostrar diferentes esqueletos para diferentes partes de uma página. Ambos usam o mesmo mecanismo React Suspense subjacente.

Como compartilho o estado entre os componentes servidor e cliente?

Você não pode — os componentes do servidor são renderizados uma vez e não têm estado de tempo de execução. Passe dados do servidor para o cliente por meio de adereços. Para o estado do lado do cliente que precisa persistir nas navegações, use parâmetros de pesquisa de URL (para estado compartilhável), Zustand ou Redux (para estado efêmero) ou cookies (para preferências do usuário). Nunca tente importar o estado de um componente cliente para um componente servidor.

Como configuro o Turbopack para desenvolvimento?

Adicione --turbo ao seu script de desenvolvimento: "dev": "next dev --turbo". Turbopack é o empacotador baseado em Rust do Next.js 16 com partidas a frio e HMR significativamente mais rápidas. Está pronto para produção para a maioria dos projetos, mas possui algumas incompatibilidades de plugins do webpack. Verifique suas dependências específicas na lista de compatibilidade do Turbopack antes de mudar.


Próximas etapas

Construir um aplicativo Next.js 16 de produção que lide com 11 localidades, milhares de páginas e requisitos de desempenho de nível empresarial é um empreendimento de engenharia significativo. A equipe de front-end da ECOSIRE forneceu exatamente isso - uma plataforma Next.js 16 de 249 páginas com SEO multilíngue, imagens OG dinâmicas e tempos de resposta de menos de um segundo.

Se você estiver dimensionando seu aplicativo Next.js ou precisar de suporte de engenharia de front-end empresarial, explore nossos serviços de desenvolvimento para ver como podemos ajudar.

E

Escrito por

ECOSIRE Research and Development Team

Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.

Converse no WhatsApp