Next.js com TypeScript: App Router, Server Components e Tipos de Rota na Prática Já leu

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 . Diferentemente do Pages Router

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.

Referências


Artigos relacionados