Ir para o conteúdo
Nextjs

App Router do Next.js 15: Layouts, Loading States e Error Boundaries na Prática

Marcos Soares
Atualizado em 
11 minutos de leitura
Ilustracao 3D de paineis de vidro fosco aninhados com brilho ciano representando layouts e error boundaries no Next.js 15
Ouça este artigo
0:00App Router do Next.js 15: Layouts, Loading States e Error Boundaries na Prática--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O problema que o App Router resolve (e o que ele cria)

O Pages Router do Next.js tratava cada rota como uma ilha. Compartilhar estado de layout entre páginas exigia _app.tsx, _document.tsx e uma coreografia frágil de getLayout patterns. O App Router inverte essa lógica: a árvore de arquivos define a árvore de componentes. Layouts persistem entre navegações, loading states operam por segmento, e error boundaries capturam falhas sem derrubar a aplicação inteira.

Mas essa inversão traz armadilhas. Layouts que re-renderizam quando não deveriam. Loading states que cobrem a tela inteira em vez de um trecho. Error boundaries que engolem erros silenciosamente. Este post mostra como montar cada peça com precisão e onde a maioria dos projetos erra.

Layouts: persistência que funciona a seu favor

Um layout.tsx no App Router é um React Server Component por padrão. Ele recebe children e não re-renderiza quando o usuário navega entre rotas filhas. Isso significa que um sidebar, um header ou um provider de tema sobrevive à navegação sem perder estado.

TSX
// app/dashboard/layout.tsx
import { ReactNode } from "react";
import { Sidebar } from "@/components/sidebar";
import { getSession } from "@/lib/auth";
import { redirect } from "next/navigation";
 
// Server Component: roda apenas no servidor, sem bundle pro client
export default async function DashboardLayout({
  children,
}: {
  children: ReactNode;
}) {
  const session = await getSession();
 
  // Redireciona antes de renderizar qualquer coisa,
  // evitando flash de conteúdo protegido
  if (!session) {
    redirect("/login");
  }
 
  return (
    <div className="flex min-h-screen">
      <Sidebar user={session.user} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}

O getSession() roda no servidor a cada request, mas o layout em si não re-monta no client durante navegação client-side. O React preserva a árvore do layout e troca apenas o children. Isso é diferente de um _app.tsx no Pages Router, que re-executava a cada transição.

Layouts aninhados com escopo preciso

A composição real aparece quando você aninha layouts. Cada segmento de rota pode ter seu próprio layout, e eles se empilham:

TSX
// app/dashboard/projects/layout.tsx
import { ReactNode } from "react";
import { ProjectNav } from "@/components/project-nav";
 
export default function ProjectsLayout({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <div>
      <ProjectNav />
      {/* Apenas essa região troca quando o usuário navega
          entre /dashboard/projects/[id] */}
      <section className="mt-4">{children}</section>
    </div>
  );
}

A estrutura de pastas app/dashboard/layout.tsx + app/dashboard/projects/layout.tsx produz dois layouts encaixados. Quando o usuário navega de /dashboard/projects/1 para /dashboard/projects/2, o DashboardLayout e o ProjectsLayout persistem. Apenas o page.tsx dentro de projects/[id] re-renderiza.

Se você precisa de proteção de rotas nesse fluxo, o layout do dashboard já cuida disso. O layout de projects não precisa repetir a verificação. Para estratégias mais avançadas de autenticação com middleware, o post sobre autenticação com NextAuth.js e Middleware cobre o fluxo completo.

Loading states: granularidade por segmento

O arquivo loading.tsx cria um Suspense boundary automático ao redor do page.tsx do mesmo segmento. Quando o componente da página é um Server Component assíncrono, o Next.js mostra o loading.tsx enquanto aguarda a resolução.

TSX
// app/dashboard/projects/loading.tsx
export default function ProjectsLoading() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {Array.from({ length: 6 }).map((_, i) => (
        <div
          key={i}
          className="h-32 animate-pulse rounded-lg bg-zinc-800"
        />
      ))}
    </div>
  );
}
TSX
// app/dashboard/projects/page.tsx
import { ProjectCard } from "@/components/project-card";
import { db } from "@/lib/database";
 
export default async function ProjectsPage() {
  // Essa query suspende o componente até resolver.
  // O loading.tsx do mesmo segmento aparece automaticamente.
  const projects = await db.project.findMany({
    orderBy: { updatedAt: "desc" },
    take: 20,
  });
 
  return (
    <div className="grid grid-cols-3 gap-4">
      {projects.map((project) => (
        <ProjectCard key={project.id} project={project} />
      ))}
    </div>
  );
}

Quando loading.tsx não é granular o suficiente

O loading.tsx cobre a página inteira do segmento. Se a página tem três seções independentes e apenas uma é lenta, o skeleton cobre tudo. A solução é usar Suspense manual com boundaries separados:

TSX
// app/dashboard/page.tsx
import { Suspense } from "react";
import { RecentProjects } from "@/components/recent-projects";
import { ActivityFeed } from "@/components/activity-feed";
import { UsageStats } from "@/components/usage-stats";
 
export default function DashboardPage() {
  return (
    <div className="grid grid-cols-12 gap-6">
      <div className="col-span-8">
        {/* Cada Suspense boundary opera independentemente:
            se ActivityFeed é lento, RecentProjects já aparece */}
        <Suspense fallback={<ProjectsSkeleton />}>
          <RecentProjects />
        </Suspense>
      </div>
 
      <div className="col-span-4 space-y-6">
        <Suspense fallback={<StatsSkeleton />}>
          <UsageStats />
        </Suspense>
 
        <Suspense fallback={<FeedSkeleton />}>
          <ActivityFeed />
        </Suspense>
      </div>
    </div>
  );
}
 
function ProjectsSkeleton() {
  return <div className="h-64 animate-pulse rounded-lg bg-zinc-800" />;
}
 
function StatsSkeleton() {
  return <div className="h-32 animate-pulse rounded-lg bg-zinc-800" />;
}
 
function FeedSkeleton() {
  return <div className="h-48 animate-pulse rounded-lg bg-zinc-800" />;
}

Essa abordagem permite streaming parcial: o servidor envia o HTML do que resolveu primeiro e injeta o restante via chunks. O resultado é uma percepção de velocidade muito superior a um spinner único. A diferença entre SSR tradicional e esse modelo de streaming é discutida em profundidade no post sobre SSR vs CSR e quando cada abordagem faz sentido.

AbordagemGranularidadeStreamingComplexidade
loading.tsx no segmentoPágina inteiraSim, mas bloco únicoBaixa: um arquivo resolve
Suspense manual por seçãoComponente individualSim, chunks independentesMédia: precisa criar skeletons por seção
loading.tsx + Suspense combinadosMistaSim, hierárquicoMédia-alta: exige clareza sobre qual boundary captura o quê

Error boundaries: captura sem silêncio

O error.tsx funciona como um React Error Boundary automático para o segmento. Ele precisa ser um Client Component (o Error Boundary do React exige componentDidCatch, que roda no client).

TSX
// app/dashboard/projects/error.tsx
"use client";
 
import { useEffect } from "react";
 
interface ErrorBoundaryProps {
  error: Error & { digest?: string };
  reset: () => void;
}
 
export default function ProjectsError({ error, reset }: ErrorBoundaryProps) {
  useEffect(() => {
    // Envia para serviço de monitoramento.
    // O digest é um hash estável gerado pelo Next.js para erros de servidor,
    // seguro para expor ao client sem vazar stack traces.
    console.error("[ProjectsError]", error.digest ?? error.message);
  }, [error]);
 
  return (
    <div className="rounded-lg border border-red-500/20 bg-red-950/10 p-6">
      <h2 className="text-lg font-semibold text-red-400">
        Erro ao carregar projetos
      </h2>
      <p className="mt-2 text-sm text-zinc-400">
        Algo falhou ao buscar seus projetos. Tente novamente.
      </p>
      <button
        onClick={reset}
        className="mt-4 rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-500"
      >
        Tentar novamente
      </button>
    </div>
  );
}

O reset() re-renderiza o segmento sem recarregar a página. Ele tenta montar o Server Component de novo. Se o erro era transiente (timeout de banco, falha de rede), a segunda tentativa pode funcionar. Se o erro é determinístico (bug no código), o reset() vai falhar de novo e o error boundary reaparece.

Hierarquia de error boundaries

Error boundaries capturam erros dos filhos, não dos irmãos. Um error.tsx em app/dashboard/projects/ captura erros do page.tsx de projects e de qualquer rota filha, mas não captura erros do layout.tsx do mesmo segmento.

Para capturar erros do layout raiz, existe o global-error.tsx:

TSX
// app/global-error.tsx
"use client";
 
export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // global-error substitui o layout raiz inteiro quando dispara,
  // então precisa incluir <html> e <body>
  return (
    <html lang="pt-BR">
      <body className="flex min-h-screen items-center justify-center bg-zinc-950 text-white">
        <div className="text-center">
          <h1 className="text-2xl font-bold">Algo deu errado</h1>
          <button
            onClick={reset}
            className="mt-4 rounded bg-zinc-700 px-4 py-2"
          >
            Recarregar
          </button>
        </div>
      </body>
    </html>
  );
}

Esse componente só dispara em produção. Em desenvolvimento, o overlay de erro do Next.js tem prioridade.

O que NÃO fazer

Anti-pattern 1: transformar layout em Client Component sem necessidade

TSX
// ERRADO: "use client" no layout força todo o subtree a perder
// Server Component por padrão. Providers de estado viram
// obrigatórios e o bundle cresce.
"use client";
 
import { useState, ReactNode } from "react";
 
export default function DashboardLayout({ children }: { children: ReactNode }) {
  const [sidebarOpen, setSidebarOpen] = useState(true);
 
  return (
    <div className="flex">
      <aside className={sidebarOpen ? "w-64" : "w-16"}>
        <button onClick={() => setSidebarOpen(!sidebarOpen)}>Toggle</button>
      </aside>
      <main>{children}</main>
    </div>
  );
}

O problema: o layout inteiro vira Client Component. Todo children abaixo perde a capacidade de ser Server Component por padrão. O estado do sidebar força re-renders desnecessários.

A correção: isole a interatividade num componente filho:

TSX
// CORRETO: layout permanece Server Component.
// Apenas o SidebarToggle é Client Component.
// app/dashboard/layout.tsx
import { ReactNode } from "react";
import { SidebarToggle } from "@/components/sidebar-toggle";
 
export default function DashboardLayout({ children }: { children: ReactNode }) {
  return (
    <div className="flex">
      <SidebarToggle />
      <main className="flex-1">{children}</main>
    </div>
  );
}
TSX
// components/sidebar-toggle.tsx
"use client";
 
import { useState } from "react";
 
export function SidebarToggle() {
  const [open, setOpen] = useState(true);
 
  return (
    <aside className={open ? "w-64" : "w-16"}>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {/* conteúdo do sidebar */}
    </aside>
  );
}

Esse padrão de empurrar "use client" para a folha da árvore é a regra de ouro do App Router. Se você está construindo um design system para esse tipo de composição, o post sobre Design System com Radix UI e Tailwind CSS detalha como manter primitivos client-side isolados.

Anti-pattern 2: error boundary que engole erros

TSX
// ERRADO: sem logging, sem contexto, sem ação do usuário
"use client";
 
export default function ProjectsError() {
  return <p>Erro</p>;
}

Sem useEffect para reportar o erro, sem reset para permitir retry, sem informação útil. Em produção, esse componente transforma debugging em arqueologia. Sempre receba error e reset das props, logue o digest, e ofereça uma ação concreta ao usuário.

Anti-pattern 3: loading.tsx no segmento raiz cobrindo tudo

Colocar um loading.tsx em app/ cria um Suspense boundary que envolve toda a aplicação. Qualquer navegação que envolva um Server Component assíncrono mostra esse loading, mesmo que a rota filha tenha seu próprio. O resultado é um flash de loading global seguido pelo loading específico. Coloque loading.tsx nos segmentos mais internos possíveis.

Quando usar cada arquivo de convenção

A árvore de decisão é direta:

Layout: o conteúdo persiste entre navegações do mesmo segmento? Use layout. Se precisa re-montar a cada navegação (um formulário que deve resetar), use template.tsx em vez de layout.tsx.

Loading: a página inteira depende de uma única fonte de dados assíncrona? loading.tsx resolve. Se há múltiplas fontes independentes, use Suspense manual dentro da página.

Error: cada segmento que faz fetch ou operação falível deve ter seu error.tsx. Sem isso, o erro borbulha até o global-error.tsx ou, se ele não existir, derruba a aplicação.

Para rotas que combinam autenticação e error handling, o middleware de autenticação na Edge pode interceptar requests antes mesmo de o layout renderizar, evitando erros desnecessários em componentes protegidos.

Se sua aplicação usa Supabase como backend, os error boundaries capturam falhas de RLS e queries mal formadas sem derrubar o dashboard inteiro.

Em projetos com monorepo via Turborepo, esses arquivos de convenção vivem no pacote da aplicação Next.js, não em pacotes compartilhados. Layouts e error boundaries são específicos da aplicação.

FAQ

layout.tsx re-renderiza quando mudo de rota?

Não no sentido de re-montar. O React preserva a instância do layout e troca apenas o children. Se o layout é um Server Component assíncrono, o fetch dentro dele executa uma vez no servidor e o resultado é cacheado para navegações client-side subsequentes dentro do mesmo segmento. Se você precisa que o layout re-execute a cada navegação, use template.tsx.

Posso usar loading.tsx e Suspense manual no mesmo segmento?

Sim. O loading.tsx cria um Suspense boundary ao redor do page.tsx. Se dentro do page.tsx você adiciona Suspense boundaries manuais, eles operam dentro do boundary do loading.tsx. O loading.tsx só aparece se o page.tsx como um todo suspender. Se o page.tsx retorna JSX síncrono com Suspense internos, o loading.tsx nunca dispara.

O error.tsx captura erros em Client Components filhos?

Sim. O Error Boundary do React captura erros de renderização e erros em useEffect de qualquer componente descendente, seja Server ou Client Component. Ele não captura erros em event handlers (como onClick). Para esses, use try/catch no próprio handler.

Qual a diferença entre error.tsx e global-error.tsx?

O error.tsx captura erros do page.tsx e dos filhos do segmento onde está. Ele não captura erros do layout.tsx do mesmo nível. O global-error.tsx captura erros do root layout (app/layout.tsx) e substitui a página inteira, incluindo <html> e <body>.

Preciso de loading.tsx em todo segmento?

Não. Crie loading.tsx apenas em segmentos onde o page.tsx faz operações assíncronas lentas o suficiente para o usuário perceber. Para páginas que resolvem em menos de 100ms, o loading state é imperceptível e adiciona complexidade sem benefício. Se a latência depende de fatores externos (banco remoto, API de terceiros), coloque o loading.tsx como rede de segurança.

A posição que defendo

O App Router força uma decisão arquitetural que o Pages Router deixava implícita: onde termina a responsabilidade de cada camada da interface. Layouts definem estrutura persistente. Loading states definem contratos de espera. Error boundaries definem limites de falha. Tratar esses três como arquivos obrigatórios em todo segmento é burocracia. Tratar como opcionais em todo lugar é negligência. A regra que funciona: todo segmento que faz fetch assíncrono precisa de loading.tsx e error.tsx. Segmentos puramente composicionais (que só passam children adiante) precisam apenas de layout.tsx. Essa distinção mantém a base de código previsível sem inflar a árvore de arquivos.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Guias de integração relacionados

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.