Guia Pratico do Next.js App Router

Intermediario | 70 min leitura | 2024.12.17

O que voce vai aprender neste tutorial

✓ Estrutura basica do App Router
✓ Server Components e Client Components
✓ Busca de dados
✓ Roteamento e layouts
✓ Loading e tratamento de erros
✓ Server Actions

Pre-requisitos

  • Conhecimento basico de React
  • Conhecimento basico de TypeScript
  • Node.js 18 ou superior instalado

Configuracao do projeto

# Criar projeto
npx create-next-app@latest my-app --typescript --tailwind --eslint --app

cd my-app
npm run dev

Estrutura de diretorios

my-app/
├── app/
│   ├── layout.tsx      # Layout raiz
│   ├── page.tsx        # Pagina inicial
│   ├── globals.css     # Estilos globais
│   └── ...
├── components/         # Componentes compartilhados
├── lib/               # Funcoes utilitarias
└── public/            # Arquivos estaticos

Step 1: Fundamentos do App Router

Roteamento baseado em arquivos

app/
├── page.tsx                    # / (inicio)
├── about/
│   └── page.tsx               # /about
├── blog/
│   ├── page.tsx               # /blog
│   └── [slug]/
│       └── page.tsx           # /blog/:slug
├── products/
│   ├── page.tsx               # /products
│   └── [...categories]/
│       └── page.tsx           # /products/* (catch-all)
└── (marketing)/
    ├── pricing/
    │   └── page.tsx           # /pricing
    └── contact/
        └── page.tsx           # /contact

Componente de pagina basico

// app/page.tsx
export default function HomePage() {
  return (
    <main className="container mx-auto p-4">
      <h1 className="text-4xl font-bold">Inicio</h1>
      <p className="mt-4">Bem-vindo ao Next.js App Router!</p>
    </main>
  );
}

Roteamento dinamico

// app/blog/[slug]/page.tsx
interface Props {
  params: Promise<{ slug: string }>;
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;

  return (
    <article>
      <h1>Artigo do blog: {slug}</h1>
    </article>
  );
}

// Geracao de parametros estaticos
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(res => res.json());

  return posts.map((post: { slug: string }) => ({
    slug: post.slug,
  }));
}

Step 2: Layouts e templates

Layout raiz

// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: {
    default: 'My App',
    template: '%s | My App',
  },
  description: 'Next.js App Router Demo',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="pt-BR">
      <body className={inter.className}>
        <header className="bg-gray-800 text-white p-4">
          <nav className="container mx-auto flex gap-4">
            <a href="/">Inicio</a>
            <a href="/blog">Blog</a>
            <a href="/about">Sobre</a>
          </nav>
        </header>
        {children}
        <footer className="bg-gray-100 p-4 mt-8">
          <p className="text-center">© 2024 My App</p>
        </footer>
      </body>
    </html>
  );
}

Layouts aninhados

// app/blog/layout.tsx
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="container mx-auto flex gap-8 p-4">
      <aside className="w-64">
        <h2 className="font-bold mb-4">Categorias</h2>
        <ul className="space-y-2">
          <li><a href="/blog?category=tech">Tecnologia</a></li>
          <li><a href="/blog?category=design">Design</a></li>
          <li><a href="/blog?category=business">Negocios</a></li>
        </ul>
      </aside>
      <main className="flex-1">{children}</main>
    </div>
  );
}

Grupos de rotas

// app/(auth)/layout.tsx
// (auth) nao afeta a URL
export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-md w-96">
        {children}
      </div>
    </div>
  );
}

// app/(auth)/login/page.tsx → /login
// app/(auth)/register/page.tsx → /register

Step 3: Server Components e Client Components

Server Components (padrao)

// app/products/page.tsx
// Este e um Server Component (padrao)
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'force-cache', // Geracao estatica
  });
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map((product: { id: number; name: string; price: number }) => (
        <div key={product.id} className="border p-4 rounded">
          <h2>{product.name}</h2>
          <p>R${product.price.toLocaleString()}</p>
        </div>
      ))}
    </div>
  );
}

Client Components

// components/Counter.tsx
'use client'; // Especificar como Client Component

import { useState } from 'react';

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

  return (
    <div className="flex items-center gap-4">
      <button
        onClick={() => setCount(count - 1)}
        className="px-4 py-2 bg-gray-200 rounded"
      >
        -
      </button>
      <span className="text-2xl">{count}</span>
      <button
        onClick={() => setCount(count + 1)}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        +
      </button>
    </div>
  );
}

Padroes de uso

// app/dashboard/page.tsx (Server Component)
import Counter from '@/components/Counter';
import UserProfile from '@/components/UserProfile';

async function getUserData() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

export default async function DashboardPage() {
  // Buscar dados no servidor
  const user = await getUserData();

  return (
    <div>
      {/* Passar dados com Server Component */}
      <UserProfile user={user} />

      {/* Funcionalidade interativa com Client Component */}
      <Counter />
    </div>
  );
}
// components/UserProfile.tsx (Server Component)
interface User {
  name: string;
  email: string;
  avatar: string;
}

export default function UserProfile({ user }: { user: User }) {
  return (
    <div className="flex items-center gap-4">
      <img src={user.avatar} alt={user.name} className="w-12 h-12 rounded-full" />
      <div>
        <p className="font-bold">{user.name}</p>
        <p className="text-gray-600">{user.email}</p>
      </div>
    </div>
  );
}

Step 4: Busca de dados

Busca de dados em paralelo

// app/dashboard/page.tsx
async function getUser() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

async function getNotifications() {
  const res = await fetch('https://api.example.com/notifications');
  return res.json();
}

async function getStats() {
  const res = await fetch('https://api.example.com/stats');
  return res.json();
}

export default async function DashboardPage() {
  // Buscar dados em paralelo
  const [user, notifications, stats] = await Promise.all([
    getUser(),
    getNotifications(),
    getStats(),
  ]);

  return (
    <div>
      <h1>Bem-vindo, {user.name}</h1>
      <p>Notificacoes: {notifications.length}</p>
      <p>Vendas do mes: R${stats.revenue.toLocaleString()}</p>
    </div>
  );
}

Controle de cache

// Dados estaticos (buscados no build)
const staticData = await fetch('https://api.example.com/data', {
  cache: 'force-cache',
});

// Dados dinamicos (buscados a cada requisicao)
const dynamicData = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

// Revalidacao baseada em tempo
const revalidatedData = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }, // 1 hora
});

// Revalidacao baseada em tags
const taggedData = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] },
});

// revalidateTag('products') para revalidar

Renderizacao progressiva com Suspense

// app/page.tsx
import { Suspense } from 'react';
import RecommendedProducts from '@/components/RecommendedProducts';
import LatestNews from '@/components/LatestNews';

export default function HomePage() {
  return (
    <div>
      <h1>Inicio</h1>

      <Suspense fallback={<div>Carregando produtos recomendados...</div>}>
        <RecommendedProducts />
      </Suspense>

      <Suspense fallback={<div>Carregando noticias...</div>}>
        <LatestNews />
      </Suspense>
    </div>
  );
}

Step 5: Loading e tratamento de erros

loading.tsx

// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4">
      {[...Array(3)].map((_, i) => (
        <div key={i} className="animate-pulse">
          <div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
          <div className="h-4 bg-gray-200 rounded w-1/2"></div>
        </div>
      ))}
    </div>
  );
}

error.tsx

// app/blog/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="text-center py-8">
      <h2 className="text-2xl font-bold text-red-600 mb-4">
        Ocorreu um erro
      </h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={reset}
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        Tentar novamente
      </button>
    </div>
  );
}

not-found.tsx

// app/blog/[slug]/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="text-center py-8">
      <h2 className="text-2xl font-bold mb-4">Artigo nao encontrado</h2>
      <p className="text-gray-600 mb-4">
        O artigo que voce procura nao existe ou foi removido.
      </p>
      <Link
        href="/blog"
        className="text-blue-500 hover:underline"
      >
        Voltar para a lista de artigos
      </Link>
    </div>
  );
}
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound(); // Exibir not-found.tsx
  }

  return <article>{/* ... */}</article>;
}

Step 6: Server Actions

Server Action basico

// app/contact/page.tsx
async function submitContact(formData: FormData) {
  'use server';

  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  // Salvar no banco de dados
  await db.contact.create({
    data: { name, email, message },
  });

  // Enviar email, etc.
}

export default function ContactPage() {
  return (
    <form action={submitContact} className="space-y-4">
      <div>
        <label htmlFor="name">Nome</label>
        <input
          id="name"
          name="name"
          required
          className="w-full border p-2 rounded"
        />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="w-full border p-2 rounded"
        />
      </div>
      <div>
        <label htmlFor="message">Mensagem</label>
        <textarea
          id="message"
          name="message"
          required
          className="w-full border p-2 rounded"
        />
      </div>
      <button
        type="submit"
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        Enviar
      </button>
    </form>
  );
}

useFormState e useFormStatus

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  if (!title || title.length < 3) {
    return { error: 'O titulo deve ter pelo menos 3 caracteres' };
  }

  await db.post.create({
    data: { title, content },
  });

  revalidatePath('/posts');
  return { success: true };
}
// app/posts/new/page.tsx
'use client';

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
    >
      {pending ? 'Enviando...' : 'Publicar'}
    </button>
  );
}

export default function NewPostPage() {
  const [state, formAction] = useActionState(createPost, null);

  return (
    <form action={formAction} className="space-y-4">
      {state?.error && (
        <p className="text-red-500">{state.error}</p>
      )}

      <div>
        <label htmlFor="title">Titulo</label>
        <input
          id="title"
          name="title"
          className="w-full border p-2 rounded"
        />
      </div>

      <div>
        <label htmlFor="content">Conteudo</label>
        <textarea
          id="content"
          name="content"
          className="w-full border p-2 rounded"
        />
      </div>

      <SubmitButton />
    </form>
  );
}

Exercicio pratico: Aplicativo de blog

// lib/db.ts
export interface Post {
  id: string;
  title: string;
  content: string;
  createdAt: Date;
}

// BD simples em memoria
let posts: Post[] = [];

export const db = {
  posts: {
    findMany: () => posts,
    findUnique: (id: string) => posts.find(p => p.id === id),
    create: (data: Omit<Post, 'id' | 'createdAt'>) => {
      const post = {
        ...data,
        id: crypto.randomUUID(),
        createdAt: new Date(),
      };
      posts.push(post);
      return post;
    },
    delete: (id: string) => {
      posts = posts.filter(p => p.id !== id);
    },
  },
};
// app/posts/page.tsx
import Link from 'next/link';
import { db } from '@/lib/db';

export default function PostsPage() {
  const posts = db.posts.findMany();

  return (
    <div className="container mx-auto p-4">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold">Blog</h1>
        <Link
          href="/posts/new"
          className="px-4 py-2 bg-blue-500 text-white rounded"
        >
          Novo artigo
        </Link>
      </div>

      <div className="space-y-4">
        {posts.map(post => (
          <article key={post.id} className="border p-4 rounded">
            <h2 className="text-xl font-bold">
              <Link href={`/posts/${post.id}`} className="hover:underline">
                {post.title}
              </Link>
            </h2>
            <p className="text-gray-600 mt-2">
              {post.content.substring(0, 100)}...
            </p>
          </article>
        ))}
      </div>
    </div>
  );
}

Boas praticas

1. Uso de componentes
   - Busca de dados → Server Component
   - Funcionalidade interativa → Client Component
   - 'use client' apenas onde necessario

2. Busca de dados
   - Buscar em paralelo sempre que possivel
   - Escolher estrategia de cache apropriada
   - Renderizacao progressiva com Suspense

3. Performance
   - Divisao de codigo com importacao dinamica
   - Usar next/image para imagens
   - Usar next/font para fontes

4. Tratamento de erros
   - Tratar adequadamente com error.tsx
   - Pagina 404 com not-found.tsx
   - Estado de carregamento com loading.tsx

Resumo

O Next.js App Router e uma nova arquitetura que combina Server Components e Client Components. Usando-os adequadamente, voce pode melhorar tanto a performance quanto a experiencia de desenvolvimento.

← Voltar para a lista