Introdução ao Next.js com TypeScript e App Router
O Next.js evoluiu significativamente na versão 13+ com a introdução do App Router, um paradigma completamente novo para estruturar aplicações React modernas. Se você já trabalhou com o Pages Router (estrutura anterior), perceberá que estamos falando de uma mudança fundamental na forma como organizamos rotas, componentes e lógica de servidor. O App Router traz consigo a capacidade nativa de trabalhar com Server Components, permitindo renderizar componentes no servidor e enviar apenas HTML para o cliente, reduzindo JavaScript no navegador.
TypeScript complementa perfeitamente essa arquitetura, fornecendo segurança de tipos em tempo de desenvolvimento e evitando erros comuns em produção. Neste artigo, você compreenderá como estruturar um projeto Next.js moderno, aproveitando plenamente o App Router, Server Components e tipagem TypeScript rigorosa.
Estrutura do App Router e Sistema de Arquivo
Como funciona o roteamento baseado em arquivo
O App Router do Next.js utiliza uma convenção de diretórios dentro da pasta app/. Diferentemente do Pages Router onde pages/usuarios.tsx gerava a rota /usuarios, aqui criamos uma pasta app/usuarios/ contendo um arquivo page.tsx. Essa abordagem oferece melhor organização de código relacionado (layout, componentes internos, estilos) no mesmo diretório.
A estrutura básica é intuitiva: qualquer pasta dentro de app/ com um arquivo page.tsx ou page.js se torna uma rota. Pastas que começam com colchetes [param] são segmentos dinâmicos. Você pode também criar layouts compartilhados com layout.tsx, componentes com loading.tsx para estados de carregamento, error.tsx para tratamento de erros, e not-found.tsx para páginas não encontradas.
// app/layout.tsx - Layout raiz que envolve toda aplicação
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Minha Aplicação',
description: 'Aplicação Next.js com TypeScript',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="pt-BR">
<body>{children}</body>
</html>
);
}
// app/page.tsx - Página inicial (rota /)
export default function Home() {
return <h1>Bem-vindo ao Next.js 13+</h1>;
}
// app/usuarios/page.tsx - Página da rota /usuarios
export default function UsuariosPage() {
return <h1>Lista de Usuários</h1>;
}
// app/usuarios/[id]/page.tsx - Página dinâmica /usuarios/:id
interface UsuarioPageProps {
params: {
id: string;
};
}
export default function UsuarioPage({ params }: UsuarioPageProps) {
return <h1>Usuário ID: {params.id}</h1>;
}
Segmentos especiais e suas funções
Além de page.tsx, o App Router reconhece outros arquivos especiais que modificam o comportamento da rota. O layout.tsx define uma UI que persiste entre navegações no mesmo segmento, ideal para sidebars e navegações. O loading.tsx exibe um esqueleto ou spinner enquanto o conteúdo carrega (funciona com Suspense). O error.tsx captura erros do segmento e do seu subtree, exibindo um UI alternativa. Por fim, not-found.tsx é renderizado quando você chama notFound() ou para rotas inexistentes.
// app/usuarios/layout.tsx - Layout específico para usuários
import type { ReactNode } from 'react';
export default function UsuariosLayout({ children }: { children: ReactNode }) {
return (
<div className="usuarios-container">
<aside className="usuarios-sidebar">
<nav>
<a href="/usuarios">Ver Todos</a>
</nav>
</aside>
<main>{children}</main>
</div>
);
}
// app/usuarios/loading.tsx - Mostra durante o carregamento
export default function UsuariosLoading() {
return <div className="spinner">Carregando usuários...</div>;
}
// app/usuarios/error.tsx - Tratador de erros
'use client';
import type { ReactNode } from 'react';
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function UsuariosError({ error, reset }: ErrorProps) {
return (
<div>
<h2>Erro ao carregar usuários</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Tentar novamente</button>
</div>
);
}
Server Components vs Client Components
O paradigma de Server Components
Server Components são a principal inovação do App Router. Eles são renderizados exclusivamente no servidor, nunca no navegador. Isso significa que você pode acessar bancos de dados, APIs internas, variáveis de ambiente secretas e bibliotecas pesadas sem expô-las ao cliente. O servidor envia apenas HTML para o navegador, reduzindo drasticamente o bundle de JavaScript.
Por padrão, todos os componentes no App Router são Server Components. Você não precisa fazer nada especial — apenas crie um arquivo .tsx e exporte um componente. A magia acontece naturalmente. Isso é oposto ao comportamento do Pages Router, onde tudo era Client Component.
// app/usuarios/usuarios-lista.tsx - Server Component por padrão
import type { Usuario } from '@/types/usuario';
// Pode ser async!
async function buscarUsuarios(): Promise<Usuario[]> {
const response = await fetch('https://api.exemplo.com/usuarios', {
// Revalidar a cada 60 segundos
next: { revalidate: 60 },
});
if (!response.ok) throw new Error('Falha ao buscar usuários');
return response.json();
}
export default async function UsuariosLista() {
const usuarios = await buscarUsuarios();
return (
<ul>
{usuarios.map((usuario) => (
<li key={usuario.id}>{usuario.nome}</li>
))}
</ul>
);
}
Quando usar Client Components
Client Components são necessários para interatividade: estado com useState, efeitos com useEffect, context providers, listeners de eventos e hooks customizados. Para marcar um arquivo como Client Component, adicione 'use client' no topo. Componentes Client podem ser filhos de Server Components (mas não o inverso em arquivos separados).
Uma prática essencial é manter Client Components pequenos e focados. Coloque a lógica interativa em um Client Component minúsculo e envolva-o com um Server Component que faz o trabalho pesado. Isso maximiza os benefícios de performance.
// app/usuarios/filtro-usuarios.tsx - Client Component
'use client';
import { useState } from 'react';
interface FiltroUsuariosProps {
onFiltrar: (termo: string) => void;
}
export default function FiltroUsuarios({ onFiltrar }: FiltroUsuariosProps) {
const [termo, setTermo] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const novoTermo = e.target.value;
setTermo(novoTermo);
onFiltrar(novoTermo);
};
return (
<input
type="text"
placeholder="Filtrar usuários..."
value={termo}
onChange={handleChange}
className="filtro-input"
/>
);
}
// app/usuarios/page.tsx - Server Component que incorpora Client Component
import FiltroUsuarios from './filtro-usuarios';
async function buscarUsuarios(filtro?: string) {
const query = filtro ? `?q=${filtro}` : '';
const response = await fetch(`https://api.exemplo.com/usuarios${query}`, {
next: { revalidate: 60 },
});
return response.json();
}
export default async function UsuariosPage() {
const usuarios = await buscarUsuarios();
// Função para passar ao Client Component
const handleFiltrar = async (termo: string) => {
// Esta função executará no cliente, mas pode chamar uma Server Action
'use server';
return buscarUsuarios(termo);
};
return (
<div>
<FiltroUsuarios onFiltrar={handleFiltrar} />
<ul>
{usuarios.map((u) => (
<li key={u.id}>{u.nome}</li>
))}
</ul>
</div>
);
}
Tipos de Rota e Casos de Uso Específicos
Rotas estáticas e dinâmicas
Rotas estáticas são pré-renderizadas em build time e servidas de um cache. Se você tem uma página de blog com 1000 posts, o Next.js pode gerar HTML para cada post durante o build. Para rotas dinâmicas com parâmetros, use generateStaticParams para indicar quais parâmetros devem ser pré-renderizados. Qualquer rota não coberta será renderizada sob demanda (ISR - Incremental Static Regeneration).
// app/blog/[slug]/page.tsx
interface BlogPostPageProps {
params: {
slug: string;
};
}
// Gera estas rotas em build time
export async function generateStaticParams() {
const posts = await fetch('https://api.exemplo.com/posts').then(r => r.json());
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const post = await fetch(`https://api.exemplo.com/posts/${params.slug}`).then(r => r.json());
return (
<article>
<h1>{post.titulo}</h1>
<p>{post.conteudo}</p>
</article>
);
}
Rotas de API (API Routes)
Rotas de API permitem criar endpoints HTTP sem servidor separado. Coloque arquivos route.ts em pastas dentro de app/api/. Eles funcionam como handlers HTTP puros, recebendo NextRequest e retornando NextResponse.
// app/api/usuarios/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
// Buscar usuários de um banco de dados
const usuarios = await fetch('https://seu-banco.com/usuarios').then(r => r.json());
return NextResponse.json({ usuarios }, { status: 200 });
}
export async function POST(request: NextRequest) {
const body = await request.json();
// Validar dados com TypeScript
if (!body.nome || !body.email) {
return NextResponse.json(
{ erro: 'Nome e email são obrigatórios' },
{ status: 400 }
);
}
// Salvar no banco
const novoUsuario = await fetch('https://seu-banco.com/usuarios', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(r => r.json());
return NextResponse.json(novoUsuario, { status: 201 });
}
// app/api/usuarios/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface RouteParams {
params: {
id: string;
};
}
export async function GET(request: NextRequest, { params }: RouteParams) {
const usuario = await fetch(`https://seu-banco.com/usuarios/${params.id}`).then(r => r.json());
if (!usuario) {
return NextResponse.json({ erro: 'Usuário não encontrado' }, { status: 404 });
}
return NextResponse.json(usuario);
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const body = await request.json();
const usuarioAtualizado = await fetch(`https://seu-banco.com/usuarios/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(r => r.json());
return NextResponse.json(usuarioAtualizado);
}
Server Actions
Server Actions são funções assíncronas que executam no servidor, sendo chamadas diretamente do cliente. Marque uma função com 'use server' e ela pode ser invocada em Client Components. Isso elimina a necessidade de criar endpoints de API para operações simples.
// app/usuarios/acoes.ts - Arquivo com Server Actions
'use server';
import type { Usuario } from '@/types/usuario';
export async function criarUsuario(dados: Omit<Usuario, 'id'>) {
const response = await fetch('https://seu-banco.com/usuarios', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dados),
});
if (!response.ok) {
throw new Error('Falha ao criar usuário');
}
return response.json();
}
export async function deletarUsuario(id: string) {
const response = await fetch(`https://seu-banco.com/usuarios/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Falha ao deletar usuário');
}
return true;
}
// app/usuarios/form-novo-usuario.tsx - Client Component usando Server Action
'use client';
import { useState } from 'react';
import { criarUsuario } from './acoes';
export default function FormNovoUsuario() {
const [loading, setLoading] = useState(false);
const [erro, setErro] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setErro(null);
const formData = new FormData(e.currentTarget);
const dados = {
nome: formData.get('nome') as string,
email: formData.get('email') as string,
};
try {
await criarUsuario(dados);
alert('Usuário criado com sucesso!');
e.currentTarget.reset();
} catch (err) {
setErro(err instanceof Error ? err.message : 'Erro desconhecido');
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="nome" placeholder="Nome" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={loading}>
{loading ? 'Criando...' : 'Criar Usuário'}
</button>
{erro && <p className="erro">{erro}</p>}
</form>
);
}
Tipagem TypeScript Avançada no Next.js
Tipando Props de Componentes e Páginas
No App Router, as props que as páginas recebem seguem padrões específicos. Páginas recebem params (segmentos dinâmicos) e searchParams (query string). Componentes podem receber children e outras props customizadas. Tipar isso corretamente evita erros e facilita manutenção.
// app/produtos/[categoria]/[id]/page.tsx - Página com múltiplos parâmetros
import type { ReactNode } from 'react';
interface ProdutoPageProps {
params: {
categoria: string;
id: string;
};
searchParams: {
highlight?: string;
origem?: string;
};
}
export default async function ProdutoPage({
params,
searchParams,
}: ProdutoPageProps) {
const { categoria, id } = params;
const { highlight, origem } = searchParams;
const produto = await fetch(
`https://api.exemplo.com/produtos/${categoria}/${id}`
).then(r => r.json());
return (
<div>
<h1>{produto.nome}</h1>
{highlight && <p className="destaque">{highlight}</p>}
<p>Origem: {origem || 'Desconhecida'}</p>
</div>
);
}
Criando tipos customizados reutilizáveis
Defina interfaces e tipos em um arquivo types/ centralizado. Isso melhora a organização e permite reutilização em toda a aplicação. Use type para tipos simples e interface para objetos complexos que podem ser estendidos.
// types/usuario.ts
export interface Usuario {
id: string;
nome: string;
email: string;
criado_em: Date;
ativo: boolean;
}
export type NovoUsuario = Omit<Usuario, 'id' | 'criado_em'>;
export interface RespuestaAPI<T> {
dados: T;
erro: string | null;
status: number;
}
export interface UsuarioComPosts extends Usuario {
posts: Post[];
}
// types/post.ts
export interface Post {
id: string;
titulo: string;
conteudo: string;
autor_id: string;
criado_em: Date;
}
// app/usuarios/[id]/page.tsx - Usando tipos customizados
import type { Usuario, UsuarioComPosts } from '@/types/usuario';
interface PageProps {
params: {
id: string;
};
}
async function buscarUsuarioComPosts(id: string): Promise<UsuarioComPosts> {
const response = await fetch(
`https://api.exemplo.com/usuarios/${id}?include=posts`
);
if (!response.ok) throw new Error('Usuário não encontrado');
return response.json();
}
export default async function UsuarioPage({ params }: PageProps) {
const usuario = await buscarUsuarioComPosts(params.id);
return (
<div>
<h1>{usuario.nome}</h1>
<p>{usuario.email}</p>
<section>
<h2>Posts ({usuario.posts.length})</h2>
<ul>
{usuario.posts.map((post) => (
<li key={post.id}>{post.titulo}</li>
))}
</ul>
</section>
</div>
);
}
Tipando variáveis de ambiente
Use um arquivo env.ts para tipar suas variáveis de ambiente e garantir que todas as variáveis necessárias existam.
// env.ts
const requiredEnvVars = [
'NEXT_PUBLIC_API_URL',
'DATABASE_URL',
'JWT_SECRET',
] as const;
type EnvVar = typeof requiredEnvVars[number];
function getEnv(key: EnvVar): string {
const value = process.env[key];
if (!value) {
throw new Error(`Variável de ambiente ${key} não definida`);
}
return value;
}
export const env = {
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
databaseUrl: process.env.DATABASE_URL!,
jwtSecret: process.env.JWT_SECRET!,
} as const;
// Usar na aplicação
// import { env } from '@/env';
// const resposta = await fetch(`${env.apiUrl}/usuarios`);
Conclusão
Ao dominar o Next.js 13+ com TypeScript e App Router, você aprendeu três conceitos fundamentais que transformam a forma como você constrói aplicações web. Primeiro, o sistema de arquivos intuitivo do App Router elimina a necessidade de rotas explícitas — a estrutura de pastas é sua configuração. Segundo, Server Components por padrão reduzem drasticamente o JavaScript enviado ao cliente, melhorando performance e segurança ao permitir acesso seguro a dados sensíveis. Terceiro, TypeScript rigoroso nas props de componentes, tipos customizados e variáveis de ambiente previne classes inteiras de bugs em produção. Esses três pilares — roteamento inteligente, renderização no servidor e tipagem forte — formam a base de aplicações Next.js modernas, escaláveis e mantíveis.