Pular para o conteúdo
~/.primo-academy.sh
☰ Aulas · Next.js e Meta-frameworks · 0/10
Recomendado: essencial

App Router: Layouts, Pages, Route Groups

5 min de leitura

fonte

No Pages Router (Next legado), uma rota era um arquivo. Você criava pages/posts/[id].tsx e pronto. No App Router (Next 13+, canônico em 2026), a rota é uma pasta, e dentro dela mora um conjunto de arquivos especiais que definem o comportamento. É mais arquivo, mais convenção, mas em troca você ganha layouts aninhados, loading/error por rota, e o modelo RSC funcionando de verdade.

Antes de escrever código, primeiro você precisa ver onde mora cada coisa. Este nó é o mapa da estrutura.

O essencial 🟢

Convenção: pasta app/, cada pasta = uma rota. Em vez de arquivos espalhados, o App Router mora inteiro dentro de app/. Cada pasta dentro de app/ vira uma rota. O nome da pasta vira o segmento da URL.

app/
├── page.tsx              →  /
├── about/
│   └── page.tsx          →  /about
├── posts/
│   ├── page.tsx          →  /posts
│   └── [slug]/
│       └── page.tsx      →  /posts/<qualquer-coisa>
├── layout.tsx            →  layout raiz (envolve TUDO)
└── globals.css           →  estilos globais

page.tsx é a página em si. É o único arquivo obrigatório por rota. Sem page.tsx, a pasta existe mas não vira rota acessível.

// app/about/page.tsx
export default function AboutPage() {
  return <h1>Sobre nós</h1>;
}

Acessar /about no browser renderiza esse componente. Fim.

layout.tsx envolve as rotas filhas (e persiste entre navegações). É onde mora o chrome da app: header, sidebar, footer. Layouts aninham - o layout raiz envolve o da pasta, que envolve o da subpasta, e assim por diante. Quando você navega, o Next não re-renderiza o layout - só o conteúdo que mudou dentro dele.

// app/layout.tsx (layout RAIZ - obrigatório)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="pt-BR">
      <body>
        <Header />
        <main>{children}</main>
        <Footer />
      </body>
    </html>
  );
}

// app/dashboard/layout.tsx (layout da seção /dashboard/*)
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard">
      <Sidebar />
      <div>{children}</div>
    </div>
  );
}

Visitar /dashboard/posts renderiza: RootLayout > DashboardLayout > Page. Navegar de /dashboard/posts pra /dashboard/users mantém os dois layouts e troca só a page.

loading.tsx é o fallback automático pra Suspense. Quando o page.tsx da rota está esperando dados (fetch lento, suspense boundary), o Next mostra esse componente automaticamente. Não é obrigatório - sem ele, o usuário vê tela em branco até a página resolver.

// app/posts/loading.tsx
export default function Loading() {
  return <div className="post-skeleton">Carregando posts...</div>;
}

Na prática, é onde mora o skeleton da rota. Você estiliza com a mesma forma que a página, pra evitar layout shift.

error.tsx é o error boundary da rota. Se um page.tsx (ou qualquer descendente) lançar erro durante renderização, o Next mostra esse componente no lugar da página. O resto da árvore (layout pai) sobrevive.

// app/posts/error.tsx
"use client"; // TEM que ser Client Component

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>Algo deu errado ao carregar os posts.</h2>
      <button onClick={() => reset()}>Tentar de novo</button>
    </div>
  );
}

Por que 'use client'? Porque você precisa de onClick (evento de browser) e reset é uma função. Server Component não tem eventos. Veremos isso a fundo em rsc-fundamentos.

not-found.tsx é o 404 customizado. Aciona quando o page.tsx chama notFound() (helper do Next) ou quando o caminho da URL não bate com nenhuma rota.

// app/not-found.tsx
import Link from "next/link";
export default function NotFound() {
  return (
    <div>
      <h2>Página não encontrada</h2>
      <Link href="/">Voltar pra home</Link>
    </div>
  );
}

Route groups: (nome) organiza sem virar rota. Pastas entre parênteses são "pastas lógicas" - elas agrupam rotas sem adicionar segmento à URL. O uso mais comum: layouts compartilhados por um subconjunto de rotas.

app/
├── (marketing)/
│   ├── layout.tsx         →  layout só pras rotas públicas
│   ├── about/page.tsx     →  /about  (NÃO /marketing/about)
│   └── pricing/page.tsx   →  /pricing
├── (app)/
│   ├── layout.tsx         →  layout só pras rotas autenticadas
│   └── dashboard/page.tsx →  /dashboard
└── layout.tsx             →  layout raiz

Resultado: /about e /dashboard existem como rotas, mas compartilham layouts diferentes (marketing vs app). Sem route group, você teria app/marketing/about/page.tsx e a URL seria /marketing/about - o que normalmente não é o que você quer.

Rotas dinâmicas: [slug], [...catchAll], [[...opcional]].

// app/posts/[slug]/page.tsx
// params é { slug: string }
export default function PostPage({ params }: { params: { slug: string } }) {
  return <h1>Post: {params.slug}</h1>;
}

// app/docs/[...path]/page.tsx
// Captura TUDO: /docs/a/b/c → params = { path: ["a", "b", "c"] }
export default function DocsPage({ params }: { params: { path: string[] } }) {
  return <h1>Docs: {params.path.join("/")}</h1>;
}

// app/shop/[[...slug]]/page.tsx
// Opcional: /shop, /shop/a, /shop/a/b → todos válidos

No Next 15+, params é uma Promise (assíncrono) - você dá await antes de usar. Veremos isso na prática em data-fetching-server.

Aprofundamento 🟡

Layouts aninhados (cada rota pode ter seu layout por cima). Você pode ter layouts em qualquer nível da árvore. Eles compõem - o pai envolve o filho.

app/
├── layout.tsx            → <html>, <body>, Header global
├── (app)/
│   └── layout.tsx        → Sidebar, auth check
├── (app)/dashboard/
│   └── layout.tsx        → breadcrumb, tabs do dashboard
└── (app)/dashboard/
    └── settings/
        └── layout.tsx    → sub-tabs de settings

Cada layout.tsx recebe children e renderiza o que quiser em volta do children. O React hidrata todos na ordem do mais externo pro mais interno.

Metadata API: export const metadata ou generateMetadata. Pra definir <title>, <meta>, OpenGraph, etc. de cada rota, sem mexer em <head>.

// app/posts/[slug]/page.tsx
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await fetchPost(slug);
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: { title: post.title, images: [post.cover] },
  };
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await fetchPost(slug);
  return <article>{post.content}</article>;
}

Sem generateMetadata, o Next usa o que estiver no layout pai (ou nada). Server Component pode exportar metadata - não precisa de 'use client'.

template.tsx re-renderiza entre navegações. Diferente de layout.tsx (que persiste), template.tsx remonta a cada navegação. Útil pra resetar state de animação, ou pra forçar re-fetch. Uso raro - em 95% dos casos, layout.tsx é o que você quer.

Quando o loading.tsx NÃO funciona (e usar <Suspense> manual). O loading.tsx envolve o page.tsx inteiro. Se só uma parte da página é lenta (uma sidebar, um widget), o usuário vê a página inteira com a parte lenta em branco - não vê o loading.tsx porque a página já está pendurada na parte lenta.

Solução: <Suspense> cirúrgico em volta do pedaço lento.

// app/posts/page.tsx
import { Suspense } from "react";

export default function PostsPage() {
  return (
    <div>
      <h1>Posts</h1>
      <Suspense fallback={<PostsSkeleton />}>
        {/* Async Server Component: carrega devagar */}
        <PostsList />
      </Suspense>
      <Footer /> {/* renderiza instantâneo */}
    </div>
  );
}

Veremos isso a fundo em streaming-ssr. Por ora,记住: loading.tsx é o fallback da rota inteira; <Suspense> é pra isolar uma parte.

route.ts (route handlers) - APIs no App Router. Pra criar um endpoint HTTP, você exporta funções nomeadas (GET, POST, etc.) de um arquivo route.ts. Substitui o diretório pages/api/ do Pages Router.

// app/api/posts/route.ts
export async function GET() {
  const posts = await fetchPosts();
  return Response.json(posts);
}

Você não precisa disso pra Server Actions (que é o caminho moderno pra mutations). Veremos no nó server-actions quando usar cada.

Pra quem quer ir além 🔴

Parallel routes: @slot em paralelo. Sintaxe avançada pra renderizar múltiplas "páginas" independentes na mesma URL. Ex: @team e @analytics em /dashboard renderizam lado a lado, cada uma com seu próprio loading.tsx e error.tsx. Útil pra dashboard complexo, raro em app média.

// app/dashboard/
// ├── @team/page.tsx
// ├── @analytics/page.tsx
// ├── layout.tsx  (recebe { children, team, analytics })
// └── page.tsx

Intercepting routes: (.)photo/[id]. Mostra uma rota "por cima" da atual (modal que tem URL compartilhável), e cair pra rota real se você der refresh. Complexo, mas poderoso pra modais deep-linkable. Mencione só pra saber que existe.

generateStaticParams pra pré-renderizar rotas dinâmicas. Em vez de app/posts/[slug]/page.tsx ser SSR por demanda, você pré-renderiza as slugs conhecidas em build time (SSG/ISR).

// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetchAllPosts();
  return posts.map((p) => ({ slug: p.slug }));
}

Combina com o revalidate do data-fetching (próximo nó da trilha).

Leitura recomendada:

Dica: o App Router é cheio de convenção. A primeira vez parece exagero ("preciso de 4 arquivos pra uma rota?"). Depois de 2-3 rotas, vira automático. O ganho real é o layout aninhado - o Pages Router nunca teve isso bem, e é o que destrava apps com chrome complexo (sidebar por seção, auth por área, modais que lembram do estado).

No próximo nó, vamos ver a fronteira 'use client' - o que muda quando um componente vira client, e como combinar com os server components que você acabou de aprender a estruturar.

// Quiz

Qual é a função do arquivo `app/dashboard/loading.tsx`?

Escolha uma alternativa

// recursos

// avaliação da trilha

—
ainda sem avaliações