Alternar tema

Armadilhas comuns do Next.js App Router e soluções: 8 experiências práticas para evitar retrabalho

Easton editorial illustration: route-map drafting table

Quando comecei a usar o Next.js App Router, apanhei bastante.

No fim do ano passado, um projeto da empresa precisava subir para o Next.js 15. Já que íamos atualizar, fazia sentido aproveitar e migrar do Pages Router para o App Router. Afinal, a documentação oficial falava em “melhor desempenho”, “melhor experiência de desenvolvimento” e “a arquitetura revolucionária dos Server Components”. O resultado? No primeiro dia de uso, caí em uma sequência de problemas estranhos.

Dados que não atualizavam, páginas carregando sem parar, cache que parecia não funcionar mesmo depois de configurado, confusão entre Server Component e Client Component… O caso mais absurdo foi um bug que passei 3 horas depurando, para no fim descobrir que eu tinha esquecido de colocar 'use client' no arquivo error.tsx. Foi daqueles momentos em que dá vontade de rir e chorar ao mesmo tempo.

Depois fizemos uma pequena estatística interna no time e percebemos que 80% dos problemas se repetiam. Então organizei aqui as armadilhas que já encontramos, na esperança de poupar um pouco do seu caminho.

Este texto não entra em teoria. É experiência prática. Em cada problema, vou mostrar: por que a armadilha aparece, como ela foi descoberta e como resolver. No fim, você vai saber como evitar boa parte dos “buracos” do App Router.

Armadilhas na busca de dados

Armadilha 1: buscar os mesmos dados no cliente sem necessidade

Cenário real:

Uma vez precisei mostrar as informações do usuário em uma página e, por hábito, escrevi algo assim:

// app/profile/page.tsx
'use client'
import { useEffect, useState } from 'react'

export default function ProfilePage() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => setUser(data))
  }, [])

  if (!user) return <div>Carregando...</div>
  return <div>Olá, {user.name}</div>
}

Parece normal, certo? Na prática, é um antipadrão clássico. Os dados saem do banco de dados, passam por uma API Route e só então chegam ao cliente. Você adiciona uma ida e volta de rede sem necessidade.

Por que essa armadilha acontece:

Na época do Pages Router, nos acostumamos a usar useEffect para buscar dados no cliente. Mas, no App Router, Server Components podem buscar dados diretamente no servidor. Muitas vezes não existe motivo para criar essa camada extra de API.

A forma correta:

// app/profile/page.tsx (por padrão, é um Server Component)
import { db } from '@/lib/db'

export default async function ProfilePage() {
  // Consulta o banco diretamente no servidor
  const user = await db.user.findFirst()

  return <div>Olá, {user.name}</div>
}

O ganho de desempenho aparece rápido:

  • uma requisição de API a menos;
  • a latência entre servidor e banco costuma ficar abaixo de 10ms, enquanto cliente-servidor pode passar de 100ms;
  • menos JavaScript enviado no bundle do cliente.

Ponto principal:

Se os dados podem ser obtidos em um Server Component, não jogue esse trabalho para um fetch no cliente. Só considere buscar no cliente quando houver interação do usuário, como busca, filtros ou atualização em tempo real.

Armadilha 2: o cache padrão de Route Handlers

Cenário real:

Eu criei uma API que retornava a hora atual. O problema: por mais que eu atualizasse a página, a hora não mudava.

// app/api/time/route.ts
export async function GET() {
  return Response.json({ time: new Date().toISOString() })
}

Atualizei 10 vezes e o horário retornado era exatamente o mesmo. Na hora, cheguei a desconfiar que o código nem estava sendo executado.

Por que essa armadilha acontece:

O Next.js pode armazenar em cache Route Handlers de requisições GET. Para dados estáticos, como configurações, isso é ótimo. Para dados dinâmicos, vira dor de cabeça.

Solução 1: marcar explicitamente como dinâmico

// app/api/time/route.ts
export const dynamic = 'force-dynamic' // Força renderização dinâmica

export async function GET() {
  return Response.json({ time: new Date().toISOString() })
}

Solução 2: usar o novo comportamento padrão do Next.js 15

A boa notícia é que, no Next.js 15, o comportamento padrão dos GET Route Handlers mudou para não armazenar em cache. Se você ainda usa Next.js 14, pode escrever assim:

// app/api/time/route.ts
export async function GET() {
  return Response.json(
    { time: new Date().toISOString() },
    { headers: { 'Cache-Control': 'no-store' } }
  )
}

Minha prática hoje:

  • Dados estáticos (configurações, constantes): marco explicitamente export const revalidate = 3600.
  • Dados dinâmicos (informações do usuário, dados em tempo real): marco export const dynamic = 'force-dynamic'.

Não dependa do comportamento padrão. Deixe sua intenção explícita; o código fica mais claro.

Armadilha 3: esquecer de revalidar depois de mudar dados

Cenário real:

Em um app simples de Todo, a lista não atualizava depois de adicionar uma nova tarefa:

// app/todos/page.tsx
export default async function TodosPage() {
  const todos = await db.todo.findMany()
  return <TodoList todos={todos} />
}

// app/actions.ts
'use server'
export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  // Esqueci de revalidar!
}

Depois de enviar o formulário, a página ainda mostrava os dados antigos. Só dava para ver a nova tarefa depois de atualizar manualmente.

Por que essa armadilha acontece:

O cache do App Router é agressivo. Mesmo que os dados mudem, a página não se atualiza sozinha. Você precisa dizer explicitamente: “os dados deste caminho mudaram, pode atualizar”.

A forma correta:

// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'

export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  revalidatePath('/todos') // Revalida o caminho /todos
}

Técnica um pouco mais avançada:

Se várias páginas exibem a lista de Todo, como a home e uma página de arquivo, revalidateTag é mais flexível:

// app/todos/page.tsx
export default async function TodosPage() {
  const todos = await fetch('http://localhost:3000/api/todos', {
    next: { tags: ['todos'] } // Marca os dados com uma tag
  })
  return <TodoList todos={todos} />
}

// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function addTodo(text: string) {
  await db.todo.create({ data: { text } })
  revalidateTag('todos') // Revalida todos os dados marcados com 'todos'
}

Ponto principal:

O trio depois de mudar dados: gravar no banco → revalidatePath / revalidateTag → redirecionar, se fizer sentido.

Comportamentos confusos de Server Components e Client Components

Armadilha 4: usar Context dentro de Server Component

Cenário real:

Eu queria oferecer alternância global de tema e criei um ThemeProvider:

// app/providers.tsx
import { createContext } from 'react'

export const ThemeContext = createContext('light')

export function Providers({ children }) {
  return (
    <ThemeContext.Provider value="dark">
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

O erro apareceu: You're importing a component that needs createContext. This only works in a Client Component.

Por que essa armadilha acontece:

Server Components não dão suporte a React Context, porque eles renderizam no servidor e não têm o mecanismo de estado do cliente.

A forma correta:

O Provider precisa ser um Client Component e deve ficar separado:

// app/providers.tsx
'use client' // Essencial: marca como Client Component

import { createContext, useState } from 'react'

export const ThemeContext = createContext('light')

export function Providers({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light')

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

// app/layout.tsx (continua sendo Server Component)
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

O erro que eu cometi:

No começo, coloquei 'use client' direto no layout.tsx. Resultado: o app inteiro virou Client Component e eu perdi as vantagens dos Server Components. Lembre-se: marque só o Provider como Client Component; mantenha o Layout como Server Component.

Armadilha 5: entender errado o SSR de Client Components

Cenário real:

Usei localStorage dentro de um Client Component. No desenvolvimento local parecia funcionar, mas depois do deploy veio o erro: localStorage is not defined.

// app/components/user-info.tsx
'use client'

export default function UserInfo() {
  const user = JSON.parse(localStorage.getItem('user') || '{}')
  return <div>{user.name}</div>
}

Por que essa armadilha acontece:

Muita gente acha que 'use client' significa “roda apenas no cliente”. Na verdade, Client Components também são pré-renderizados no servidor (SSR). Como localStorage só existe no navegador, acessá-lo no servidor vai quebrar.

Solução 1: verificar o ambiente

'use client'
import { useEffect, useState } from 'react'

export default function UserInfo() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    // useEffect roda apenas no cliente
    const userData = JSON.parse(localStorage.getItem('user') || '{}')
    setUser(userData)
  }, [])

  if (!user) return null
  return <div>{user.name}</div>
}

Solução 2: usar uma condição

'use client'

export default function UserInfo() {
  const user = typeof window !== 'undefined'
    ? JSON.parse(localStorage.getItem('user') || '{}')
    : null

  if (!user) return null
  return <div>{user.name}</div>
}

Ponto principal:

Client Component = componente que pode interagir no cliente, mas ainda assim ele é pré-renderizado no servidor. Código que depende de APIs do navegador, como localStorage, window e document, precisa ficar dentro de useEffect ou passar por uma verificação de ambiente.

Armadilha 6: usar 'use client' em excesso

Cenário real:

Quando comecei com App Router, eu tinha o hábito de adicionar 'use client' sempre que via um erro. No fim, quase todo o projeto tinha virado Client Component, e as vantagens dos Server Components desapareceram.

Por que essa armadilha acontece:

Alguns problemas parecem exigir Client Component, mas muitas vezes são só problemas de organização do código.

Contraexemplo:

// app/dashboard/page.tsx
'use client' // Não deveria estar aqui!

import { useState } from 'react'

export default function Dashboard() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <Header /> {/* Estático */}
      <Stats /> {/* Precisa de dados do servidor */}
      <Counter count={count} setCount={setCount} /> {/* Precisa de interação */}
    </div>
  )
}

Desse jeito, a página inteira vira Client Component, e os dados de Stats também precisam passar por fetch no cliente.

Forma correta:

// app/dashboard/page.tsx (Server Component)
import { db } from '@/lib/db'
import { Counter } from './counter'

export default async function Dashboard() {
  const stats = await db.stats.findFirst() // Busca dados no servidor

  return (
    <div>
      <Header /> {/* Server Component */}
      <Stats data={stats} /> {/* Server Component */}
      <Counter /> {/* Client Component */}
    </div>
  )
}

// app/dashboard/counter.tsx
'use client' // Só este componente é Client Component

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

Minha regra prática:

Uso três critérios para decidir se um componente precisa de 'use client':

  1. usa React Hooks, como useState, useEffect ou useContext;
  2. precisa ouvir eventos do navegador, como onClick ou onChange;
  3. usa APIs do navegador, como localStorage ou window.

Se não cair em nenhum desses casos, mantenha como Server Component.

Armadilhas do cache

Armadilha 7: a confusão com o Client Router Cache

Cenário real:

O usuário editava um post na página /posts/1, salvava e era redirecionado para a lista /posts. Só que o título do post na lista continuava antigo. A atualização só aparecia depois de recarregar a página.

Por que essa armadilha acontece:

O App Router tem um Client Router Cache, que armazena páginas já visitadas. Mesmo depois de os dados mudarem, a navegação pode mostrar a versão antiga guardada no cache.

Solução 1: revalidar ao navegar

// app/posts/[id]/edit/page.tsx
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function updatePost(id: string, title: string) {
  await db.post.update({ where: { id }, data: { title } })

  revalidatePath('/posts') // Revalida a lista
  revalidatePath(`/posts/${id}`) // Revalida a página de detalhe

  redirect('/posts') // Volta para a lista
}

Solução 2: usar router.refresh()

'use client'
import { useRouter } from 'next/navigation'

export function EditForm() {
  const router = useRouter()

  async function handleSubmit() {
    await updatePost(...)
    router.refresh() // Atualiza os dados da rota atual
    router.push('/posts')
  }
}

Boa notícia no Next.js 15:

O Next.js 15 mudou o comportamento padrão do Client Router Cache para não armazenar em cache. Esse problema praticamente deixa de ser preocupação. Mas, se você usa Next.js 14, trate isso manualmente.

Armadilha 8: revalidate não faz efeito

Cenário real:

Configurei revalidate = 60 em um Server Component, esperando que os dados fossem atualizados automaticamente a cada 60 segundos. Na prática, nada mudava.

// app/news/page.tsx
export const revalidate = 60 // Espera regenerar depois de 60 segundos

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news')
  return <NewsList news={news} />
}

Depois do deploy, a lista de notícias ficou um dia inteiro sem atualizar.

Por que essa armadilha acontece:

revalidate só funciona em produção. No ambiente de desenvolvimento (npm run dev), o cache não se comporta da mesma forma. Além disso, ele só vale para páginas geradas estaticamente. Se a página for reconhecida como renderização dinâmica, revalidate deixa de funcionar.

Passos para investigar:

  1. Confirme que está em produção:
npm run build
npm run start
  1. Verifique se a página é estática:

Na saída do build, procure marcações como ○ Static ou ● SSG. Se aparecer λ Dynamic, a página foi reconhecida como dinâmica.

  1. Encontre o que está forçando renderização dinâmica:

Causas comuns:

  • uso de cookies() ou headers();
  • uso de searchParams (parâmetros de rota dinâmicos);
  • Route Handler sem revalidate definido explicitamente.

Solução:

// app/news/page.tsx
export const revalidate = 60

export default async function NewsPage() {
  const news = await fetch('https://api.example.com/news', {
    next: { revalidate: 60 } // revalidate no nível do fetch
  })

  return <NewsList news={news} />
}

Minha prática hoje:

  • Conteúdo puramente estático: uso generateStaticParams + revalidate.
  • Parâmetros dinâmicos: uso ISR (Incremental Static Regeneration).
  • Dados em tempo real: marco direto dynamic = 'force-dynamic' e não tento usar revalidate.

Armadilhas no tratamento de erros

Armadilha 9: esquecer 'use client' em error.tsx

Cenário real:

Criei um error.tsx para lidar com erros da página e recebi: ReactServerComponentsError: Client Component must be used in a Client Component boundary.

// app/error.tsx (forma errada)
export default function Error({ error, reset }) {
  return (
    <div>
      <h2>Algo deu errado!</h2>
      <button onClick={reset}>Tentar novamente</button>
    </div>
  )
}

Por que essa armadilha acontece:

error.tsx precisa ser um Client Component, porque depende do mecanismo de Error Boundary do React, e Error Boundary só funciona no cliente.

Forma correta:

// app/error.tsx
'use client' // Isso é obrigatório

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Algo deu errado!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Tentar novamente</button>
    </div>
  )
}

Ponto principal:

Entre arquivos especiais como error.tsx, loading.tsx e not-found.tsx, apenas error.tsx precisa obrigatoriamente ser Client Component. Os outros podem ser Server Components.

Armadilha 10: colocar redirect no lugar errado dentro de try/catch

Cenário real:

Em uma Server Action, eu validava um formulário e queria redirecionar para a página de erro quando a validação falhasse. Só que redirect era capturado pelo catch, e a navegação não acontecia.

// app/actions.ts (forma errada)
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    redirect(`/users/${user.id}`) // Isso será capturado pelo catch!
  } catch (error) {
    console.error(error)
    return { error: 'Falha ao criar usuário' }
  }
}

Por que essa armadilha acontece:

redirect() funciona lançando um erro especial. O Next.js captura esse erro e executa a navegação. Se você chama redirect dentro de um try/catch, o seu próprio catch pode capturá-lo e impedir o redirecionamento.

Forma correta:

// app/actions.ts
'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
    // Não faça redirect aqui
    return { success: true, userId: user.id }
  } catch (error) {
    console.error(error)
    return { error: 'Falha ao criar usuário' }
  }
}

// Faça redirect no ponto de chamada
export async function handleSubmit(data: FormData) {
  const result = await createUser(data)
  if (result.success) {
    redirect(`/users/${result.userId}`) // Fora do try/catch
  }
}

Ou assim:

'use server'
import { redirect } from 'next/navigation'

export async function createUser(data: FormData) {
  try {
    const user = await db.user.create({ data })
  } catch (error) {
    console.error(error)
    return { error: 'Falha ao criar usuário' }
  }

  redirect(`/users/${user.id}`) // Depois do try/catch
}

Armadilhas durante a migração

Armadilha 11: 404.js e 500.js não funcionam mais

Cenário real:

Durante a migração do Pages Router, mantive pages/404.js e pages/500.js. Depois percebi que essas duas páginas simplesmente não eram usadas.

Por que essa armadilha acontece:

O mecanismo de tratamento de erros do App Router mudou completamente:

  • 404.js → not-found.tsx;
  • 500.js → error.tsx;
  • erro global → global-error.tsx.

Forma correta:

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>404 - Página não encontrada</h2>
      <Link href="/">Voltar para a página inicial</Link>
    </div>
  )
}

// app/error.tsx
'use client'

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>500 - Erro no servidor</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Tentar novamente</button>
    </div>
  )
}

// app/global-error.tsx (captura erros no layout raiz)
'use client'

export default function GlobalError({ error, reset }) {
  return (
    <html>
      <body>
        <h2>Erro global</h2>
        <p>{error.message}</p>
        <button onClick={reset}>Tentar novamente</button>
      </body>
    </html>
  )
}

Armadilha 12: next-seo deixou de servir

Cenário real:

Meu projeto usava bastante next-seo para gerenciar metadados de SEO. Depois da migração para o App Router, isso parou de funcionar como antes.

// pages/blog/[slug].tsx (época do Pages Router)
import { NextSeo } from 'next-seo'

export default function BlogPost({ post }) {
  return (
    <>
      <NextSeo
        title={post.title}
        description={post.excerpt}
        openGraph={{
          title: post.title,
          description: post.excerpt,
          images: [{ url: post.coverImage }],
        }}
      />
      <article>{post.content}</article>
    </>
  )
}

Por que essa armadilha acontece:

O App Router trouxe a API nativa generateMetadata, então next-seo já não é a escolha recomendada.

Plano de migração:

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)
  return <article>{post.content}</article>
}

Vantagens:

  1. segurança de tipos, com suporte do TypeScript;
  2. suporte a async/await, permitindo consultar o banco diretamente;
  3. melhor desempenho, com renderização no servidor.

Recomendações de otimização de desempenho

Evite Client Components desnecessários

Problema: a página inteira vira Client Component e perde as vantagens dos Server Components.

Solução:

Use a estratégia de “Client Components nas folhas”:

// ❌ Abordagem ruim
// app/dashboard/page.tsx
'use client'
export default function Dashboard() {
  return (
    <div>
      <Header />
      <Sidebar />
      <MainContent />
      <Footer />
    </div>
  )
}

// ✅ Abordagem boa
// app/dashboard/page.tsx (Server Component)
import { Header } from './header'
import { Sidebar } from './sidebar'
import { MainContent } from './main-content'
import { Footer } from './footer'

export default function Dashboard() {
  return (
    <div>
      <Header /> {/* Server Component */}
      <Sidebar /> {/* Client Component (interação) */}
      <MainContent /> {/* Server Component */}
      <Footer /> {/* Server Component */}
    </div>
  )
}

// app/dashboard/sidebar.tsx
'use client' // Só este é Client Component
export function Sidebar() {
  const [collapsed, setCollapsed] = useState(false)
  return <aside>...</aside>
}

Otimize boundaries de Suspense

Problema: a página inteira espera dados lentos, aumentando o tempo de tela em branco no primeiro carregamento.

Solução:

Use <Suspense> para separar carregamentos:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { FastComponent } from './fast'
import { SlowComponent } from './slow'

export default function Dashboard() {
  return (
    <div>
      {/* Dados rápidos aparecem imediatamente */}
      <FastComponent />

      {/* Dados lentos mostram um skeleton */}
      <Suspense fallback={<div>Carregando...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}

Use busca de dados em paralelo quando fizer sentido

Problema: busca serial de dados, em que o tempo total é a soma de todas as requisições.

Solução:

// ❌ Busca serial (lenta)
export default async function Page() {
  const user = await getUser() // 100ms
  const posts = await getPosts() // 200ms
  const comments = await getComments() // 150ms
  // Tempo total: 450ms
}

// ✅ Busca paralela (rápida)
export default async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ])
  // Tempo total: 200ms (o tempo do mais lento)
}

Armadilhas do ambiente de desenvolvimento

Armadilha 13: vazamento de conexões causado por hot reload

Cenário real:

Depois de algum tempo rodando em desenvolvimento, o banco começou a retornar: too many connections.

Por que essa armadilha acontece:

Hot Reload reexecuta código de módulos. Se você cria uma conexão de banco no escopo global, cada hot reload pode criar uma nova conexão, enquanto a anterior continua aberta.

Solução:

// lib/db.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query'],
  })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

Assim, em desenvolvimento, o mesmo Prisma é reutilizado durante hot reload.

Armadilha 14: servidor de desenvolvimento cada vez mais lento

Cenário real:

Depois de meia hora rodando npm run dev, o hot reload ficava muito lento. Às vezes, travava de vez.

Por que essa armadilha acontece:

O servidor de desenvolvimento do App Router pode consumir muita memória ao lidar com muitas páginas, especialmente quando existem várias rotas dinâmicas.

Soluções temporárias:

  1. Reiniciar o servidor de desenvolvimento, o que trata o sintoma, mas não a causa.
  2. Reduzir watchers desnecessários:
// next.config.js
module.exports = {
  webpack: (config) => {
    config.watchOptions = {
      poll: 1000, // Verifica mudanças de arquivo uma vez por segundo (frequência menor)
      aggregateTimeout: 300,
      ignored: /node_modules/,
    }
    return config
  },
}

Solução de longo prazo:

Atualize para Next.js 15 e use Turbopack:

npm run dev --turbo

O hot reload do Turbopack pode ser mais de 10 vezes mais rápido e não costuma travar em projetos grandes.

Resumo: checklist para evitar armadilhas

Depois de tantos problemas, organizei uma checklist rápida. Vale passar por ela antes de começar um novo projeto; dá para evitar 90% das dores.

Busca de dados

  • ☑ Se dá para buscar dados com Server Component, não use Client Component + useEffect.
  • ☑ Marque Route Handlers explicitamente com dynamic = 'force-dynamic' ou revalidate.
  • ☑ Depois de mudar dados, lembre de revalidatePath / revalidateTag.

Server/Client Components

  • ☑ Provider precisa ser Client Component, mas Layout deve continuar Server Component.
  • ☑ APIs do navegador, como localStorage e window, precisam ficar em useEffect ou passar por verificação de ambiente.
  • ☑ Adicione 'use client' só em componentes que realmente precisam de interação; não coloque na página inteira por conveniência.

Tratamento de erros

  • ☑ error.tsx precisa de 'use client'.
  • ☑ Não coloque redirect dentro de try/catch.
  • ☑ Erros no layout raiz usam global-error.tsx, não error.tsx.

Mecanismo de cache

  • ☑ Atualize para Next.js 15 para aproveitar padrões de cache mais razoáveis.
  • ☑ revalidate só funciona em produção; não dependa dele em desenvolvimento.
  • ☑ Em páginas dinâmicas, não use revalidate: marque direto dynamic = 'force-dynamic'.

Migração

  • ☑ 404.js → not-found.tsx; 500.js → error.tsx.
  • ☑ next-seo → generateMetadata.
  • ☑ getServerSideProps → Server Component com fetch direto.
  • ☑ useRouter muda de next/router para next/navigation.

Otimização de desempenho

  • ☑ Use Suspense para separar componentes rápidos e lentos.
  • ☑ Busque dados em paralelo com Promise.all.
  • ☑ Use singleton para conexão de banco em desenvolvimento.
  • ☑ Use Turbopack (npm run dev --turbo).

Para fechar

Sendo bem honesto, o App Router tem curva de aprendizado. No começo, cair nessas armadilhas é normal. Mas, depois que você entende os padrões, a produtividade melhora bastante.

Hoje sigo alguns hábitos:

  1. Antes de uma nova feature, penso no fluxo de dados: esses dados precisam de renderização no servidor ou interação no cliente?
  2. Quando algo dá errado, olho primeiro a saída do build: a página é Static ou Dynamic? Por quê?
  3. Uso bem o DevTools: o painel Network mostra o número de requisições, e o Console mostra a pilha de erros.
  4. Não dependo de comportamento padrão: deixo explícita a intenção de cache, modo de renderização e revalidação dos dados.

O mais importante: não se assuste com essas armadilhas. Teste, erre uma vez e você dificilmente esquece. A documentação oficial do Next.js App Router é bem detalhada; quando surgir um problema, vale voltar a ela. A resposta quase sempre está lá.

Se este texto te ajudou, compartilhe com alguém que também está sofrendo com o App Router. E, se você encontrou uma armadilha nova, deixe nos comentários. Vou continuar atualizando esta lista.

Boa sorte no caminho com App Router: menos retrabalho, mais código elegante.

FAQ

Como diferenciar Server Component de Client Component?
Server Component (padrão):
• Roda no servidor e não é enviado ao cliente
• Não pode usar hooks como useState e useEffect
• Não pode usar APIs do navegador

Client Component (precisa ser marcado):
• Usa a diretiva 'use client'
• Pode usar todos os hooks do React
• Pode usar APIs do navegador

Critério prático: se precisa de interação ou API do navegador, use Client Component
Por que os dados não atualizam?
O fetch do Next.js tem cache por padrão.

Soluções:
• Defina cache: 'no-store' (busca dados novos a cada requisição)
• Use next: { revalidate: 60 } (revalida depois de 60 segundos)
• Em um Client Component, use router.refresh() para forçar atualização

Como conferir: veja a saída do build e confirme se a página é Dynamic ou Static
O que fazer quando a página fica carregando sem parar?
Possíveis causas:
• async Server Component sem estado de loading tratado corretamente
• Boundary de Suspense configurado errado
• Falha na busca de dados sem tratamento de erro

Como resolver:
• Adicione um arquivo loading.tsx
• Envolva componentes assíncronos com Suspense
• Adicione error.tsx para tratar erros
Por que error.tsx não funciona?
error.tsx precisa ser um Client Component.

É obrigatório adicionar a diretiva 'use client':
'use client'

export default function Error({ error, reset }) {
return <div>Erro: {error.message}</div>
}

Atenção: error.tsx só captura erros de componentes filhos, não os seus próprios erros
Como migrar do Pages Router para o App Router?
Principais mudanças:
• getServerSideProps → async Server Component
• getStaticProps → geração estática (padrão)
• next/router → next/navigation
• _app.js → layout.tsx
• _document.js → não é mais necessário (layout.tsx assume esse papel)

Sugestão: escolha primeiro 1 ou 2 páginas piloto, valide o fluxo e só depois avance para a migração completa
Como entender o mecanismo de cache?
Camadas de cache do Next.js:
• Request Memoization: o mesmo fetch dentro da mesma requisição roda uma vez só
• Data Cache: a resposta do fetch pode ser armazenada em cache
• Full Route Cache: a página inteira pode ser armazenada em cache (geração estática)
• Router Cache: cache de rotas no cliente

Como controlar: use opções como cache: 'no-store' e next: { revalidate }
Como depurar problemas no App Router?
Métodos de depuração:
• Veja a saída do build (npm run build) para confirmar o tipo da página
• Use o painel Network do DevTools para conferir requisições
• Confira mensagens de erro no Console
• Veja a saída do terminal do Next.js

Problemas comuns:
• A página está Static, mas deveria ser Dynamic → confira a configuração de cache do fetch
• Os dados não atualizam → confira cache e revalidate
• A página fica carregando → confira loading.tsx e Suspense

19 min de leitura · Publicado em: 25 dez 2025 · Atualizado em: 14 jul 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog