Desenvolvimento Full Stack com TypeScript e Next.js
Uma trilha estruturada que parte do zero absoluto e chega a tópicos de nível sênior: tipos avançados, React Server Components, Server Actions, Prisma, autenticação, testes, arquitetura, performance e deploy — sempre conectando cada assunto ao que as empresas realmente pedem em vagas.
Como usar esta apostila
Esta apostila foi desenhada como uma trilha de estudo orientada a empregabilidade. Cada módulo tem teoria, código comentado, exercícios e um projeto incremental que vira peça de portfólio.
A trilha em 4 fases
| Fase | Módulos | O que você conquista | Vagas compatíveis |
|---|---|---|---|
| Fundação | M00–M02 | Lógica, ambiente, TypeScript sólido | Estágio |
| Front-end | M03–M06 | React + Next.js modernos (App Router) | Júnior front-end |
| Back-end | M07–M09 | APIs, banco de dados, autenticação | Júnior/Pleno full stack |
| Avançado + Carreira | M10–M15 | Tipos avançados, testes, arquitetura, deploy | Pleno/Sênior |
Método de estudo recomendado
- Leia digitando: nunca copie e cole os exemplos. Digitar cria memória muscular e força você a ler os erros do compilador.
- Quebre o código de propósito: mude um tipo, remova um
await, veja o que o TypeScript e o Next.js reclamam. Entender erros é 50% do trabalho real. - Faça os exercícios antes de abrir a resposta (eles ficam em blocos recolhíveis).
- Commite tudo no GitHub: recrutadores olham consistência de commits mais do que você imagina.
TypeScript + Next.js é hoje uma das combinações mais pedidas em vagas full stack no Brasil e no exterior (especialmente em startups, fintechs e agências). A stack aparece com nomes como "React/Next", "Node + TS" ou "T3 stack". Dominar o App Router e Server Components diferencia você da maioria dos candidatos, que só conhece o modelo antigo (Pages Router).
Ao longo dos módulos você construirá o DevBoard — um SaaS de gerenciamento de tarefas/projetos com autenticação, banco de dados, dashboard e API pública. É um projeto com cara de produto real, ideal para portfólio:
- M04–M06: interface, rotas, listagem com dados reais
- M07–M08: API tipada + Prisma + PostgreSQL
- M09: login com credenciais e OAuth (GitHub)
- M11–M14: testes, refino de arquitetura e deploy com CI/CD
Fundamentos da Web e Node.js
Antes de qualquer framework, você precisa entender o que acontece quando alguém digita uma URL: cliente, servidor, HTTP e JavaScript rodando dos dois lados.
1.1 Como a web funciona
Toda aplicação web é uma conversa entre um cliente (navegador) e um servidor. A conversa usa o protocolo HTTP: o cliente envia uma requisição (request) e o servidor devolve uma resposta (response).
| Método HTTP | Uso típico | Exemplo |
|---|---|---|
GET | Buscar dados | Listar produtos |
POST | Criar recurso | Cadastrar usuário |
PUT / PATCH | Atualizar recurso | Editar perfil |
DELETE | Remover recurso | Excluir tarefa |
Os status codes mais cobrados em entrevistas: 200 (OK), 201 (criado), 301/308 (redirecionamento), 400 (requisição inválida), 401 (não autenticado), 403 (sem permissão), 404 (não encontrado), 422 (dados inválidos), 500 (erro do servidor).
1.2 O papel de cada tecnologia
- HTML — estrutura e semântica do conteúdo.
- CSS — aparência e layout (no mercado atual, muito via Tailwind CSS).
- JavaScript — comportamento; a única linguagem nativa do navegador.
- Node.js — ambiente que executa JavaScript fora do navegador (no servidor).
- TypeScript — JavaScript com tipos, compilado para JavaScript.
1.3 Preparando o ambiente
Você vai precisar de: Node.js LTS (versão 20+), VS Code (ou Cursor), Git e uma conta no GitHub. Instale o Node preferencialmente via um gerenciador de versões:
# Instalar o nvm (Linux/macOS) e o Node LTS curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash nvm install --lts node -v # deve mostrar v20.x ou superior npm -v # No Windows: use o nvm-windows ou o instalador oficial do nodejs.org
Extensões essenciais do VS Code
- ESLint e Prettier — padrão de código (toda empresa usa).
- Tailwind CSS IntelliSense — autocompletar de classes.
- Pretty TypeScript Errors — erros de tipo legíveis.
1.4 JavaScript moderno que você precisa dominar
O TypeScript é construído sobre o JavaScript moderno (ES2015+). Estes recursos aparecem o tempo todo em código de produção:
// 1. const/let (nunca use var) const nome = "Ana"; // não pode ser reatribuída let contador = 0; // pode ser reatribuída // 2. Arrow functions const dobrar = (n) => n * 2; // 3. Template literals const saudacao = `Olá, ${nome}! Você tem ${contador} pontos.`; // 4. Destructuring const usuario = { id: 1, email: "ana@ex.com", plano: "pro" }; const { email, plano } = usuario; // 5. Spread e rest const atualizado = { ...usuario, plano: "enterprise" }; const somar = (...nums) => nums.reduce((acc, n) => acc + n, 0); // 6. Métodos de array (o coração do React!) const precos = [10, 25, 40]; const comDesconto = precos.map((p) => p * 0.9); const caros = precos.filter((p) => p > 20); const total = precos.reduce((acc, p) => acc + p, 0); // 7. Promises e async/await async function buscarUsuario(id) { const res = await fetch(`https://api.exemplo.com/users/${id}`); if (!res.ok) throw new Error("Falha na requisição"); return res.json(); } // 8. Optional chaining e nullish coalescing const cidade = usuario.endereco?.cidade ?? "Não informada"; // 9. Módulos ES export function formatarMoeda(v) { return `R$ ${v.toFixed(2)}`; } // import { formatarMoeda } from "./utils";
Pular direto para React/Next.js sem dominar map, filter, destructuring e async/await. Em entrevistas de júnior, a maioria das reprovações acontece em JavaScript básico, não no framework.
1.5 Git essencial para o dia a dia
git init # inicia o repositório git add . # prepara as mudanças git commit -m "feat: cria header" # salva um ponto na história git branch -M main git remote add origin git@github.com:voce/projeto.git git push -u origin main # fluxo de trabalho em equipe (o que cai em entrevista) git checkout -b feat/login # cria branch de feature git push origin feat/login # sobe e abre Pull Request git pull --rebase origin main # atualiza sua branch
Use Conventional Commits (feat:, fix:, chore:, refactor:) desde o primeiro projeto. Além de organizar o histórico, sinaliza maturidade profissional no seu GitHub — e muitas empresas exigem esse padrão em CI.
Exercícios do módulo 01
- Escreva uma função
agruparPorPlano(usuarios)que recebe um array de usuários e retorna um objeto{ pro: [...], free: [...] }usandoreduce. - Usando
fetcheasync/await, consumahttps://api.github.com/users/SEU_USUARIOe imprima nome e número de repositórios públicos. Trate erros comtry/catch. - Crie um repositório no GitHub com README explicando o que você está estudando e faça 3 commits com Conventional Commits.
TypeScript essencial
TypeScript é JavaScript com um sistema de tipos estático. Ele não muda como seu código roda — muda quantos bugs chegam à produção e quão rápido você navega em bases de código grandes.
2.1 Por que empresas exigem TypeScript
- Menos bugs: erros como "undefined is not a function" são pegos antes de rodar.
- Autocomplete e refatoração seguros: renomear uma função atualiza todos os usos.
- Documentação viva: os tipos explicam a intenção do código.
- Escala de time: em times grandes, tipos são o contrato entre pessoas.
2.2 Primeiro contato
mkdir ts-lab && cd ts-lab npm init -y npm install -D typescript tsx npx tsc --init # cria o tsconfig.json npx tsx arquivo.ts # roda TypeScript direto, sem compilar manualmente
2.3 Tipos primitivos e inferência
// Anotação explícita let idade: number = 25; let nome: string = "Ana"; let ativo: boolean = true; // Inferência: o TS deduz o tipo sozinho — prefira isso! let cidade = "Recife"; // tipo: string cidade = 10; // ❌ Erro: number não é string // Arrays e tuplas const notas: number[] = [8, 9.5, 7]; const coordenada: [number, number] = [-8.05, -34.9]; // União de tipos (union) let id: string | number = "abc-123"; // Literais: o valor É o tipo type Status = "pendente" | "pago" | "cancelado"; let statusPedido: Status = "pago"; statusPedido = "enviado"; // ❌ Erro: não faz parte da união
Unions de literais ("pendente" | "pago") substituem enum na maior parte dos códigos modernos: são mais leves, não geram JavaScript extra e funcionam melhor com inferência.
2.4 Objetos: type vs interface
// interface: ideal para "formatos" de objetos, pode ser estendida interface Usuario { id: string; nome: string; email: string; avatarUrl?: string; // opcional readonly criadoEm: Date; // não pode ser alterado } interface Admin extends Usuario { permissoes: string[]; } // type: mais flexível — unions, tuplas, tipos calculados type Resultado = | { sucesso: true; dados: Usuario } | { sucesso: false; erro: string }; function exibir(r: Resultado) { if (r.sucesso) { console.log(r.dados.nome); // TS sabe que 'dados' existe aqui } else { console.log(r.erro); // e que 'erro' existe aqui } }
"Qual a diferença entre type e interface?" — Interfaces podem ser estendidas e sofrem declaration merging (duas declarações com o mesmo nome se fundem); types aceitam unions, interseções e tipos utilitários complexos. Na prática: use o que o time já usa; muitos padronizam type para tudo, reservando interface para contratos públicos de bibliotecas.
2.5 Funções tipadas
// Parâmetros e retorno function calcularDesconto(preco: number, percentual: number = 10): number { return preco * (1 - percentual / 100); } // Tipando callbacks type Comparador<T> = (a: T, b: T) => number; const porPreco: Comparador<{ preco: number }> = (a, b) => a.preco - b.preco; // void e never function logar(msg: string): void { console.log(msg); } function falhar(msg: string): never { throw new Error(msg); }
2.6 Narrowing: o superpoder do dia a dia
Narrowing é o TypeScript "estreitando" um tipo amplo conforme você escreve condições. É o que torna unions seguras:
function formatarId(id: string | number) { if (typeof id === "string") { return id.toUpperCase(); // aqui id é string } return id.toFixed(0); // aqui id é number } // Discriminated union: o padrão mais importante do TS no mercado type Evento = | { tipo: "click"; x: number; y: number } | { tipo: "tecla"; codigo: string } | { tipo: "scroll"; delta: number }; function tratar(e: Evento) { switch (e.tipo) { case "click": return `(${e.x}, ${e.y})`; case "tecla": return e.codigo; case "scroll": return e.delta; default: { // exhaustiveness check: se alguém adicionar um novo tipo // de Evento e esquecer de tratar, o TS acusa erro aqui const _nunca: never = e; return _nunca; } } }
2.7 Generics: funções que preservam tipos
// Sem generic: perde a informação do tipo function primeiroRuim(arr: any[]): any { return arr[0]; } // Com generic: o tipo "atravessa" a função function primeiro<T>(arr: T[]): T | undefined { return arr[0]; } const n = primeiro([1, 2, 3]); // n: number | undefined const s = primeiro(["a", "b"]); // s: string | undefined // Constraints: limitando o que T pode ser function pegarProp<T, K extends keyof T>(obj: T, chave: K): T[K] { return obj[chave]; } const u = { id: 1, nome: "Ana" }; pegarProp(u, "nome"); // string pegarProp(u, "email"); // ❌ Erro: "email" não existe em u // Generic em fetch tipado — você usará isso a apostila inteira async function api<T>(url: string): Promise<T> { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise<T>; }
2.8 Utility types que caem em prova (e em produção)
| Utility | O que faz | Exemplo |
|---|---|---|
Partial<T> | Todas as props opcionais | Payload de update |
Required<T> | Todas obrigatórias | Config completa |
Pick<T, K> | Seleciona propriedades | Pick<Usuario, "id" | "nome"> |
Omit<T, K> | Remove propriedades | Omit<Usuario, "senha"> |
Record<K, V> | Objeto chave→valor | Record<string, number> |
ReturnType<F> | Tipo de retorno de função | Derivar tipos de libs |
Awaited<T> | "Desembrulha" Promise | Awaited<ReturnType<typeof fn>> |
interface Tarefa { id: string; titulo: string; concluida: boolean; autorId: string; } // Criar: sem id (o banco gera) type NovaTarefa = Omit<Tarefa, "id">; // Atualizar: qualquer subconjunto, mas nunca o id type AtualizarTarefa = Partial<Omit<Tarefa, "id">>; // Resposta pública da API: só o essencial type TarefaPublica = Pick<Tarefa, "id" | "titulo" | "concluida">;
2.9 O tsconfig que o mercado espera
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true, // inegociável em produção
"noUncheckedIndexedAccess": true, // arr[i] pode ser undefined
"noImplicitOverride": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}
anyany desliga o TypeScript. Em código profissional ele é praticamente proibido (times configuram ESLint para bloquear). Quando você realmente não sabe o tipo, use unknown e faça narrowing antes de usar — é a versão segura do any.
Exercícios do módulo 02
- Modele um
type Pagamentocomo discriminated union com os tipos"pix"(comchave),"cartao"(comparcelas) e"boleto"(comvencimento). EscrevadescreverPagamento(p)com exhaustiveness check. - Implemente
function agrupar<T, K extends keyof T>(itens: T[], chave: K): Record<string, T[]>. - A partir de uma interface
Produtocom 6 campos, derive com utility types:NovoProduto,EditarProdutoeProdutoResumo. - Reescreva a função
api<T>para retornar{ ok: true; data: T } | { ok: false; error: string }em vez de lançar exceção, e consuma-a com narrowing.
React com TypeScript
React é a biblioteca de UI mais usada do mercado. Next.js é construído sobre ela — então antes de aprender o framework, você precisa dominar componentes, props, estado e hooks, tudo com tipos.
3.1 Componentes e JSX
Um componente é uma função que recebe props e retorna JSX (HTML dentro do JavaScript). Em TypeScript, os arquivos usam a extensão .tsx.
type BotaoProps = { texto: string; variante?: "primario" | "secundario"; onClick: () => void; children?: React.ReactNode; // conteúdo entre as tags }; export function Botao({ texto, variante = "primario", onClick }: BotaoProps) { return ( <button className={variante === "primario" ? "btn-azul" : "btn-cinza"} onClick={onClick} > {texto} </button> ); } // Uso: // <Botao texto="Salvar" onClick={() => salvar()} />
- Retorne um único elemento raiz (use
<>...</>— Fragment — se precisar agrupar). - Atributos em camelCase:
className,onClick,htmlFor. - Expressões JavaScript entre chaves:
{usuario.nome}. - Listas precisam de
keyestável (id do dado, nunca o índice quando a lista muda).
3.2 Estado com useState
"use client"; // necessário no Next.js para componentes interativos (M05) import { useState } from "react"; type Tarefa = { id: number; titulo: string; feita: boolean }; export function ListaDeTarefas() { const [tarefas, setTarefas] = useState<Tarefa[]>([]); const [titulo, setTitulo] = useState(""); function adicionar() { if (!titulo.trim()) return; // Estado é imutável: sempre crie um NOVO array/objeto setTarefas((atual) => [ ...atual, { id: Date.now(), titulo, feita: false }, ]); setTitulo(""); } function alternar(id: number) { setTarefas((atual) => atual.map((t) => (t.id === id ? { ...t, feita: !t.feita } : t)) ); } return ( <div> <input value={titulo} onChange={(e) => setTitulo(e.target.value)} placeholder="Nova tarefa" /> <button onClick={adicionar}>Adicionar</button> <ul> {tarefas.map((t) => ( <li key={t.id} onClick={() => alternar(t.id)}> {t.feita ? "✅" : "⬜"} {t.titulo} </li> ))} </ul> </div> ); }
- Mutar o estado:
tarefas.push(...)não re-renderiza. Sempre crie novos arrays/objetos. - Ler estado desatualizado: use a forma de função
setX(atual => ...)quando o novo valor depende do anterior. - Estado derivado duplicado: se dá para calcular a partir de outro estado (ex.: total de tarefas feitas), calcule na renderização em vez de criar outro
useState.
3.3 Efeitos com useEffect — e quando NÃO usar
import { useEffect, useState } from "react"; export function Relogio() { const [hora, setHora] = useState(new Date()); useEffect(() => { const id = setInterval(() => setHora(new Date()), 1000); return () => clearInterval(id); // cleanup: SEMPRE limpe }, []); // [] = roda só ao montar return <p>{hora.toLocaleTimeString()}</p>; }
useEffect serve para sincronizar com sistemas externos (timers, WebSocket, APIs do navegador). Para buscar dados, o mercado migrou para Server Components (M05/M06) ou bibliotecas como TanStack Query — usar useEffect + fetch manualmente é considerado sinal de código legado.
3.4 Outros hooks essenciais
| Hook | Para quê | Frequência no mercado |
|---|---|---|
useState | Estado local | Sempre |
useEffect | Sincronizar com o mundo externo | Alta (com cautela) |
useRef | Referência a DOM / valor mutável sem re-render | Alta |
useContext | Compartilhar dados sem prop drilling | Alta |
useMemo / useCallback | Memoizar cálculos/funções caras | Média (não abuse) |
useReducer | Estado complexo com ações | Média |
3.5 Composição: o padrão que separa júnior de pleno
// Em vez de um componente com 15 props booleanas... // ...componha peças pequenas com children: export function Card({ children }: { children: React.ReactNode }) { return <div className="card">{children}</div>; } Card.Header = function Header({ children }: { children: React.ReactNode }) { return <header className="card-header">{children}</header>; }; Card.Body = function Body({ children }: { children: React.ReactNode }) { return <div className="card-body">{children}</div>; }; // Uso flexível: // <Card> // <Card.Header>Título</Card.Header> // <Card.Body>Qualquer conteúdo aqui</Card.Body> // </Card>
Vagas de front-end pedem, além de React: Tailwind CSS, componentes acessíveis (Radix UI / shadcn/ui), formulários com React Hook Form + Zod e estado de servidor com TanStack Query. Você verá Zod no M07 — ele é o mesmo dos formulários, o que torna a stack muito coesa.
Exercícios do módulo 03
- Crie um componente
<Badge status="ativo" | "inativo" | "pendente" />com cores diferentes por status, tipado com union de literais. - Implemente um filtro na lista de tarefas: "todas | pendentes | concluídas", derivando a lista filtrada sem criar um novo estado.
- Construa um
useLocalTimer(segundos)— hook customizado que faz contagem regressiva e retorna{ restante, pausar, retomar }. - Refatore um componente
<Modal title footer size showClose ... />cheio de props para o padrão de composição.
Next.js e o App Router
Next.js é um framework full stack sobre React: roteamento por arquivos, renderização no servidor, API integrada, otimizações automáticas. É o padrão de fato para React em produção.
4.1 Criando o projeto (início do DevBoard)
npx create-next-app@latest devboard # Responda: # ✔ TypeScript? ........ Yes # ✔ ESLint? ............ Yes # ✔ Tailwind CSS? ...... Yes # ✔ src/ directory? .... Yes # ✔ App Router? ........ Yes ← fundamental # ✔ Import alias @/* ... Yes cd devboard && npm run dev # http://localhost:3000
4.2 Roteamento por sistema de arquivos
No App Router, pastas definem rotas e arquivos especiais definem a UI:
src/app/ ├── layout.tsx # layout raiz (html, body) — obrigatório ├── page.tsx # rota / ├── globals.css ├── dashboard/ │ ├── layout.tsx # layout compartilhado do dashboard │ ├── page.tsx # rota /dashboard │ ├── loading.tsx # UI de carregamento automática (Suspense) │ ├── error.tsx # captura erros dessa subárvore │ └── projetos/ │ ├── page.tsx # /dashboard/projetos │ └── [id]/ │ └── page.tsx # /dashboard/projetos/42 (dinâmica) ├── (marketing)/ # route group: organiza sem afetar a URL │ ├── sobre/page.tsx # /sobre │ └── precos/page.tsx # /precos └── api/ # Route Handlers (M07) └── tarefas/route.ts # /api/tarefas
| Arquivo | Função |
|---|---|
page.tsx | UI da rota (torna a pasta acessível por URL) |
layout.tsx | UI compartilhada que envolve as páginas filhas; preserva estado ao navegar |
loading.tsx | Fallback de carregamento (Suspense automático) |
error.tsx | Error boundary da subárvore (precisa ser Client Component) |
not-found.tsx | UI para 404 / notFound() |
route.ts | Endpoint de API (GET, POST...) |
4.3 Páginas, layouts e navegação
import Link from "next/link"; export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { return ( <div className="flex min-h-screen"> <aside className="w-64 border-r p-4"> <nav className="flex flex-col gap-2"> <Link href="/dashboard">Visão geral</Link> <Link href="/dashboard/projetos">Projetos</Link> </nav> </aside> <main className="flex-1 p-6">{children}</main> </div> ); }
import { notFound } from "next/navigation"; // Em Next 15, params é uma Promise — sempre use await type Props = { params: Promise<{ id: string }> }; export default async function ProjetoPage({ params }: Props) { const { id } = await params; const projeto = await buscarProjeto(id); // função sua (M06) if (!projeto) notFound(); // renderiza o not-found.tsx return <h1 className="text-2xl font-bold">{projeto.nome}</h1>; }
Use <Link> para links (com prefetch automático) e o hook useRouter() de next/navigation para navegar programaticamente (router.push("/dashboard")). Nunca importe de next/router — esse é o pacote do Pages Router antigo.
4.4 Estratégias de renderização (tema favorito de entrevista)
| Estratégia | Quando o HTML é gerado | Ideal para |
|---|---|---|
| SSG (estática) | No build | Blog, docs, landing pages |
| ISR (incremental) | No build + revalidação periódica | E-commerce, catálogos |
| SSR (dinâmica) | A cada requisição | Dashboards, dados por usuário |
| CSR (cliente) | No navegador | Partes altamente interativas |
No App Router você não escolhe com funções especiais (getStaticProps etc. são do modelo antigo): a estratégia emerge de como você busca dados e configura cache — detalhado no M06.
4.5 Metadados e SEO
import type { Metadata } from "next"; export const metadata: Metadata = { title: "Preços | DevBoard", description: "Planos flexíveis para times de todos os tamanhos.", openGraph: { title: "DevBoard", images: ["/og.png"] }, }; // Metadados dinâmicos: // export async function generateMetadata({ params }): Promise<Metadata> { ... }
Saber explicar SSR vs SSG vs ISR e por que isso importa para SEO e performance é pergunta quase garantida em entrevistas de Next.js. Prepare uma resposta com exemplo concreto: "página de produto com ISR de 60s: rápida como estática, atualiza preço sem rebuild".
Exercícios do módulo 04
- Monte o esqueleto do DevBoard: landing page em
(marketing), área/dashboardcom layout de sidebar e rota dinâmica/dashboard/projetos/[id]. - Adicione
loading.tsxeerror.tsxno dashboard; simule lentidão comawait new Promise(r => setTimeout(r, 2000))e um erro proposital para ver ambos funcionando. - Implemente
generateMetadatana página de projeto usando o nome do projeto no título.
Server Components e Client Components
Este é o conceito que define o Next.js moderno — e o que mais derruba candidatos. Entenda isso bem e você estará à frente da maioria.
5.1 O modelo mental
No App Router, todo componente é Server Component por padrão: ele roda no servidor, pode acessar banco de dados diretamente e envia ao navegador apenas HTML + uma descrição serializada da árvore (o RSC payload) — sem o JavaScript do componente.
Quando você precisa de interatividade (estado, eventos, APIs do navegador), marca o arquivo com "use client". Esse componente e tudo que ele importa passam a ser enviados como JavaScript para o navegador.
| Capacidade | Server Component | Client Component |
|---|---|---|
| Acessar banco/segredos diretamente | ✅ | ❌ |
Ser async e usar await no corpo | ✅ | ❌ |
useState, useEffect, eventos | ❌ | ✅ |
| APIs do navegador (localStorage, window) | ❌ | ✅ |
| Peso no bundle do cliente | Zero | Conta no bundle |
5.2 Combinando os dois — o padrão certo
import { db } from "@/lib/db"; import { FiltroProjetos } from "./filtro"; // Server Component: async, busca dados direto, zero JS no cliente export default async function ProjetosPage() { const projetos = await db.projeto.findMany(); return ( <section> <h1>Projetos</h1> {/* passa dados do servidor para o componente interativo */} <FiltroProjetos projetos={projetos} /> </section> ); }
"use client"; import { useState } from "react"; import type { Projeto } from "@prisma/client"; export function FiltroProjetos({ projetos }: { projetos: Projeto[] }) { const [busca, setBusca] = useState(""); const filtrados = projetos.filter((p) => p.nome.toLowerCase().includes(busca.toLowerCase()) ); return ( <> <input value={busca} onChange={(e) => setBusca(e.target.value)} /> <ul>{filtrados.map((p) => <li key={p.id}>{p.nome}</li>)}</ul> </> ); }
5.3 Regras que evitam 90% dos bugs
- Empurre o
"use client"para as folhas: mantenha páginas e layouts como Server Components; isole a interatividade em componentes pequenos. - Server pode renderizar Client, mas Client não importa Server. Para colocar conteúdo de servidor "dentro" de um Client Component, passe como
children. - Props precisam ser serializáveis: você não pode passar funções,
Datevira string, classes não atravessam a fronteira. - Segredos ficam no servidor: variáveis de ambiente sem prefixo
NEXT_PUBLIC_nunca chegam ao navegador. Use o pacoteserver-onlypara garantir que módulos sensíveis não vazem.
// ✅ Client Component que "abraça" conteúdo de servidor "use client"; export function Accordion({ children }: { children: React.ReactNode }) { const [aberto, setAberto] = useState(false); return ( <div> <button onClick={() => setAberto(!aberto)}>Detalhes</button> {aberto && children} {/* children veio pronto do servidor */} </div> ); }
"use client"não significa "renderiza só no navegador" — Client Components também são pré-renderizados no servidor (SSR) e depois "hidratados".- Server Components não substituem API: se um app mobile ou terceiros precisam dos dados, você ainda expõe endpoints (M07).
Exercícios do módulo 05
- No DevBoard, crie a página de projetos como Server Component com dados mockados e um componente cliente de busca, como no exemplo.
- Tente usar
useStatenum Server Component e importar um módulo comimport "server-only"num Client Component. Leia e anote as mensagens de erro — você vai revê-las na vida real. - Implemente um
<ThemeToggle />(dark mode) como Client Component minúsculo dentro de um layout 100% server.
Data fetching, cache e Server Actions
Aqui o Next.js vira full stack de verdade: buscar dados no servidor, controlar cache com precisão e mutar dados sem escrever endpoints manuais.
6.1 Buscando dados em Server Components
// Com fetch (APIs externas) export default async function Page() { const res = await fetch("https://api.exemplo.com/posts", { next: { revalidate: 60 }, // ISR: revalida a cada 60s // cache: "force-cache" → estático // cache: "no-store" → sempre dinâmico (SSR puro) }); const posts: Post[] = await res.json(); return <Lista posts={posts} />; } // Direto no banco (sem fetch) — cache controlado na rota: export const revalidate = 60; // ISR para a página inteira // export const dynamic = "force-dynamic"; // SSR sempre
Requisições fetch não são mais cacheadas por padrão (mudança em relação ao Next 14 — pegadinha de entrevista!). Você ativa cache explicitamente com cache: "force-cache" ou next.revalidate. Rotas continuam sendo estáticas por padrão quando não usam dados dinâmicos (cookies(), headers(), searchParams).
6.2 Streaming com Suspense
Em vez de esperar a página inteira, envie o que está pronto e "streame" o resto:
import { Suspense } from "react"; export default function Dashboard() { return ( <> <h1>Dashboard</h1> {/* aparece na hora */} <Suspense fallback={<SkeletonCards />}> <MetricasLentas /> {/* streama quando pronto */} </Suspense> <Suspense fallback={<SkeletonLista />}> <AtividadesRecentes /> </Suspense> </> ); }
6.3 Server Actions: mutações sem endpoint
Server Actions são funções que rodam no servidor mas podem ser chamadas de formulários e componentes como se fossem locais. O Next.js cria o endpoint por baixo dos panos.
"use server"; import { z } from "zod"; import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; const schema = z.object({ titulo: z.string().min(3, "Título muito curto"), projetoId: z.string().cuid(), }); export type ActionState = { ok: boolean; message: string }; export async function criarTarefa( _prev: ActionState, formData: FormData ): Promise<ActionState> { // 1. NUNCA confie no cliente: valide sempre no servidor const parsed = schema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return { ok: false, message: parsed.error.issues[0].message }; } // 2. (M09) Verifique autenticação/autorização aqui! await db.tarefa.create({ data: parsed.data }); // 3. Invalida o cache da página para refletir o novo dado revalidatePath("/dashboard/tarefas"); return { ok: true, message: "Tarefa criada!" }; }
"use client"; import { useActionState } from "react"; import { criarTarefa, type ActionState } from "./actions"; const inicial: ActionState = { ok: true, message: "" }; export function NovaTarefaForm({ projetoId }: { projetoId: string }) { const [state, action, pending] = useActionState(criarTarefa, inicial); return ( <form action={action} className="flex gap-2"> <input name="titulo" placeholder="Nova tarefa" required /> <input type="hidden" name="projetoId" value={projetoId} /> <button disabled={pending}> {pending ? "Salvando..." : "Criar"} </button> {!state.ok && <p className="text-red-600">{state.message}</p>} </form> ); }
6.4 Revalidação e UI otimista
revalidatePath("/rota")— invalida o cache de uma rota após mutação.revalidateTag("tarefas")— invalida qualquer fetch marcado comnext: { tags: ['tarefas'] }; mais cirúrgico em apps grandes.useOptimistic— mostra o resultado imediatamente enquanto a action roda, revertendo em caso de erro; padrão esperado em produtos com boa UX.
O trio Server Actions + Zod + revalidate substituiu, em muitos times, dezenas de endpoints CRUD. Em entrevistas de nível pleno, espere perguntas como: "Server Action ou Route Handler — quando usar cada um?" Resposta curta: Actions para mutações do próprio app (forms internos); Route Handlers para APIs consumidas por terceiros, webhooks e mobile.
Exercícios do módulo 06
- Implemente o CRUD de tarefas do DevBoard com Server Actions (criar, alternar concluída, excluir), validando com Zod e revalidando a rota.
- Adicione
useOptimisticao alternar o checkbox de "concluída". - Divida o dashboard em 2 blocos com
Suspensee delays artificiais diferentes; observe o streaming na aba Network. - Marque um
fetchcom tag e crie um botão "Atualizar dados" que chama uma action comrevalidateTag.
API com Route Handlers e validação
Nem tudo é Server Action: apps mobile, integrações e webhooks precisam de uma API HTTP de verdade. No Next.js, isso é feito com Route Handlers — endpoints tipados dentro do próprio projeto.
7.1 Anatomia de um Route Handler
import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { db } from "@/lib/db"; // GET /api/tarefas?status=pendente&page=1 export async function GET(req: NextRequest) { const status = req.nextUrl.searchParams.get("status"); const page = Number(req.nextUrl.searchParams.get("page") ?? 1); const tarefas = await db.tarefa.findMany({ where: status ? { status } : undefined, take: 20, skip: (page - 1) * 20, orderBy: { criadoEm: "desc" }, }); return NextResponse.json({ data: tarefas, page }); } const criarSchema = z.object({ titulo: z.string().min(3).max(120), projetoId: z.string().cuid(), }); // POST /api/tarefas export async function POST(req: NextRequest) { const body = await req.json().catch(() => null); const parsed = criarSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Dados inválidos", issues: parsed.error.flatten() }, { status: 422 } ); } const tarefa = await db.tarefa.create({ data: parsed.data }); return NextResponse.json(tarefa, { status: 201 }); }
Rotas dinâmicas seguem o mesmo padrão de pastas: src/app/api/tarefas/[id]/route.ts exporta GET, PATCH e DELETE recebendo { params } (também uma Promise no Next 15).
7.2 Zod: a fronteira entre o caos e o seu código
Todo dado que entra pela rede é unknown. O Zod valida em runtime e gera o tipo TypeScript de graça:
import { z } from "zod"; export const usuarioSchema = z.object({ nome: z.string().min(2), email: z.string().email(), idade: z.coerce.number().int().min(18).optional(), papel: z.enum(["admin", "membro"]).default("membro"), }); // Um schema, um tipo: fonte única de verdade export type Usuario = z.infer<typeof usuarioSchema>; // { nome: string; email: string; idade?: number; papel: "admin" | "membro" } // Também valide variáveis de ambiente na inicialização: export const env = z.object({ DATABASE_URL: z.string().url(), AUTH_SECRET: z.string().min(32), }).parse(process.env);
Coloque os schemas em src/lib/schemas/ e reutilize o mesmo schema no formulário (React Hook Form + zodResolver), na Server Action e no Route Handler. Validação duplicada e divergente é fonte clássica de bug em produção.
7.3 Padrões de API que entrevistadores procuram
- REST consistente: substantivos no plural (
/api/projetos/:id/tarefas), verbos pelos métodos HTTP, status codes corretos. - Formato de erro previsível: sempre
{ error, issues? }— o front agradece. - Paginação:
page/limit(simples) ou cursor (escala melhor; padrão em feeds). - Rate limiting em rotas públicas (ex.: Upstash Ratelimit) e verificação de assinatura em webhooks (Stripe, GitHub).
- Nunca vaze campos sensíveis: selecione colunas explicitamente; jamais retorne hash de senha.
Exercícios do módulo 07
- Implemente a API pública do DevBoard:
GET/POST /api/tarefaseGET/PATCH/DELETE /api/tarefas/[id], com Zod e status codes corretos. - Adicione paginação por cursor em
GET /api/tarefase documente o contrato num README. - Crie um handler
POST /api/webhooks/exemploque rejeita requisições sem um headerx-signatureválido. - Teste tudo com a extensão REST Client do VS Code ou com
curl, incluindo os casos de erro.
Banco de dados com Prisma
Prisma é o ORM mais popular do ecossistema TypeScript: você descreve o schema, ele gera migrações SQL e um cliente 100% tipado. Vamos usá-lo com PostgreSQL, o banco relacional padrão do mercado.
8.1 Setup
npm install prisma -D npm install @prisma/client npx prisma init --datasource-provider postgresql # Postgres local rápido com Docker: docker run --name pg-devboard -e POSTGRES_PASSWORD=dev \ -e POSTGRES_DB=devboard -p 5432:5432 -d postgres:16 # .env # DATABASE_URL="postgresql://postgres:dev@localhost:5432/devboard"
8.2 Modelando o DevBoard
model User { id String @id @default(cuid()) email String @unique nome String senhaHash String? papel Papel @default(MEMBRO) projetos Projeto[] tarefas Tarefa[] criadoEm DateTime @default(now()) } model Projeto { id String @id @default(cuid()) nome String dono User @relation(fields: [donoId], references: [id]) donoId String tarefas Tarefa[] @@index([donoId]) } model Tarefa { id String @id @default(cuid()) titulo String status Status @default(PENDENTE) projeto Projeto @relation(fields: [projetoId], references: [id], onDelete: Cascade) projetoId String responsavel User? @relation(fields: [userId], references: [id]) userId String? criadoEm DateTime @default(now()) @@index([projetoId, status]) } enum Papel { ADMIN MEMBRO } enum Status { PENDENTE EM_ANDAMENTO CONCLUIDA }
npx prisma migrate dev --name init # gera e aplica a migração SQL npx prisma studio # GUI para inspecionar os dados
8.3 O cliente singleton (obrigatório no Next.js)
import { PrismaClient } from "@prisma/client"; // Em dev, o hot reload recriaria clientes infinitamente // e esgotaria as conexões do banco. Este padrão evita isso. const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
8.4 Consultas do dia a dia (tudo tipado)
// Relacionamentos: include vs select const projeto = await db.projeto.findUnique({ where: { id }, select: { id: true, nome: true, tarefas: { where: { status: "PENDENTE" }, orderBy: { criadoEm: "desc" } }, _count: { select: { tarefas: true } }, }, }); // O tipo de retorno reflete EXATAMENTE o select — autocomplete total. // Agregações const porStatus = await db.tarefa.groupBy({ by: ["status"], _count: { _all: true }, where: { projetoId }, }); // Transações: tudo ou nada await db.$transaction(async (tx) => { const p = await tx.projeto.create({ data: { nome, donoId } }); await tx.tarefa.createMany({ data: modelos.map((t) => ({ ...t, projetoId: p.id })), }); }); // Upsert: cria ou atualiza await db.user.upsert({ where: { email }, update: { nome }, create: { email, nome }, });
8.5 O que separa você da média
- Problema N+1: buscar uma lista e depois fazer uma query por item. Solução:
include/selectna query original. Saiba explicar isso — é pergunta recorrente. - Índices: crie
@@indexpara colunas usadas emwhereeorderBy; entenda que@uniquejá cria índice. - Migrações em produção:
prisma migrate deployno CI, nuncamigrate dev. - Connection pooling em serverless: em plataformas como Vercel, cada função abre conexões; use um pooler (PgBouncer, Prisma Accelerate, ou o pooler do Neon/Supabase).
- SQL puro por baixo: saiba escrever
JOIN,GROUP BYe ler umEXPLAIN. ORMs mudam; SQL é para sempre.
Bancos gerenciados dominam as vagas: Neon e Supabase (Postgres serverless) e PlanetScale (MySQL). O concorrente direto do Prisma é o Drizzle ORM — mais leve e próximo do SQL; vale conhecer os dois nomes para conversar em entrevista.
Exercícios do módulo 08
- Implemente o schema do DevBoard, rode a migração e crie um
prisma/seed.tsque popula 2 usuários, 3 projetos e 15 tarefas. - Escreva a query do dashboard: projetos do usuário com contagem de tarefas por status, em uma ida ao banco.
- Provoque um N+1 de propósito (loop com
findUnique), meça comconsole.time, depois corrija. - Adicione um campo
prioridadeao modelo, gere a migração e ajuste o código que quebrar (o TS vai te guiar).
Autenticação e autorização
Login é a funcionalidade mais sensível de qualquer app — e um dos temas mais cobrados em entrevistas de full stack. Vamos entender os conceitos e implementar com Auth.js (NextAuth v5).
9.1 Conceitos que você PRECISA saber explicar
| Conceito | Em uma frase |
|---|---|
| Autenticação | Provar quem você é (login). |
| Autorização | O que você pode fazer (permissões, papéis). |
| Sessão (stateful) | Servidor guarda a sessão; navegador guarda só um ID em cookie. |
| JWT (stateless) | Token assinado carrega os dados; servidor só verifica a assinatura. |
| OAuth | "Entrar com Google/GitHub": delega a autenticação a um provedor. |
| Hash de senha | Senhas nunca são salvas em texto; use bcrypt ou argon2. |
Cookies de sessão devem ser HttpOnly (JS não lê — mitiga XSS), Secure (só HTTPS) e SameSite=Lax (mitiga CSRF). Guardar token de autenticação em localStorage é considerado má prática — qualquer XSS rouba o token.
9.2 Auth.js (NextAuth v5) no DevBoard
npm install next-auth@beta @auth/prisma-adapter bcryptjs
npx auth secret # gera AUTH_SECRET no .env.local
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import Credentials from "next-auth/providers/credentials"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { compare } from "bcryptjs"; import { db } from "@/lib/db"; import { z } from "zod"; const loginSchema = z.object({ email: z.string().email(), senha: z.string().min(8), }); export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), session: { strategy: "jwt" }, providers: [ GitHub, // AUTH_GITHUB_ID e AUTH_GITHUB_SECRET no .env Credentials({ credentials: { email: {}, senha: {} }, async authorize(creds) { const parsed = loginSchema.safeParse(creds); if (!parsed.success) return null; const user = await db.user.findUnique({ where: { email: parsed.data.email }, }); if (!user?.senhaHash) return null; const ok = await compare(parsed.data.senha, user.senhaHash); return ok ? { id: user.id, email: user.email, name: user.nome } : null; }, }), ], callbacks: { jwt({ token, user }) { if (user) token.id = user.id; // enriquece o token no login return token; }, session({ session, token }) { session.user.id = token.id as string; return session; }, }, });
import { handlers } from "@/auth"; export const { GET, POST } = handlers;
9.3 Protegendo páginas, actions e API
// 1) Página (Server Component) import { auth } from "@/auth"; import { redirect } from "next/navigation"; export default async function DashboardPage() { const session = await auth(); if (!session?.user) redirect("/login"); return <h1>Olá, {session.user.name}</h1>; } // 2) Server Action — verifique DE NOVO (defesa em profundidade) "use server"; export async function excluirTarefa(id: string) { const session = await auth(); if (!session?.user) throw new Error("Não autenticado"); // Autorização: o recurso pertence ao usuário? const tarefa = await db.tarefa.findUnique({ where: { id }, select: { projeto: { select: { donoId: true } } }, }); if (tarefa?.projeto.donoId !== session.user.id) { throw new Error("Sem permissão"); // evita IDOR } await db.tarefa.delete({ where: { id } }); } // 3) Middleware — proteção de rota em larga escala // middleware.ts na raiz: redireciona /dashboard/* sem sessão. // Importante: middleware é UX, não segurança final — // a checagem de verdade fica na página/action/handler.
9.4 Vulnerabilidades que caem em entrevista
| Ataque | O que é | Defesa |
|---|---|---|
| XSS | Injetar script na página | React escapa por padrão; evite dangerouslySetInnerHTML; sanitize HTML externo |
| SQL Injection | Injetar SQL via input | ORM/queries parametrizadas (Prisma já protege) |
| CSRF | Site malicioso dispara ações autenticadas | SameSite cookies; Server Actions têm proteção embutida |
| IDOR | Acessar recurso alheio trocando o ID na URL | Sempre checar dono/permissão no servidor |
| Brute force | Tentar milhares de senhas | Rate limiting + hash lento (bcrypt/argon2) |
Exercícios do módulo 09
- Implemente cadastro (hash com bcrypt), login com credenciais e login com GitHub no DevBoard.
- Proteja todas as Server Actions do CRUD com autenticação e verificação de posse do recurso.
- Crie o papel
ADMINcom uma página/adminque lista todos os usuários — bloqueada para membros comuns. - Escreva, com suas palavras, a diferença entre sessão em banco e JWT, e quando escolheria cada um. Treine falar em voz alta (2 minutos).
TypeScript avançado
O sistema de tipos do TypeScript é uma linguagem funcional completa. Dominá-lo permite criar APIs internas impossíveis de usar errado — a marca de um desenvolvedor sênior.
10.1 keyof, typeof e indexed access
const config = { api: { url: "https://api.dev", timeout: 5000 }, tema: { primario: "#3178C6" }, } as const; // congela: tipos viram literais readonly type Config = typeof config; // tipo a partir do valor type Secao = keyof Config; // "api" | "tema" type Timeout = Config["api"]["timeout"]; // 5000 (literal!) // Padrão de mercado: derivar unions de arrays com as const const STATUS = ["pendente", "em_andamento", "concluida"] as const; type Status = (typeof STATUS)[number]; // "pendente" | "em_andamento" | "concluida"
10.2 Conditional types e infer
// Sintaxe: A extends B ? X : Y — um if/else de tipos type EhString<T> = T extends string ? true : false; // infer: captura um tipo "de dentro" de outro type ElementoDe<T> = T extends (infer U)[] ? U : never; type A = ElementoDe<string[]>; // string // Reimplementando utilities nativas (clássico de entrevista): type MeuReturnType<T> = T extends (...args: any[]) => infer R ? R : never; type MeuAwaited<T> = T extends Promise<infer U> ? MeuAwaited<U> : T; // recursivo! // Distribuição sobre unions: acontece automaticamente type SemNulos<T> = T extends null | undefined ? never : T; type B = SemNulos<string | null | number>; // string | number
10.3 Mapped types e template literal types
// Mapped type: transforma cada propriedade de um tipo type Opcional<T> = { [K in keyof T]?: T[K] }; // = Partial type SomenteLeitura<T> = { readonly [K in keyof T]: T[K] }; // Template literals: strings com estrutura tipada type Rota = `/api/${string}`; const ok: Rota = "/api/tarefas"; // ✅ const ruim: Rota = "/tarefas"; // ❌ // Combinando os dois: gerar getters automaticamente type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]; }; type UserGetters = Getters<{ nome: string; idade: number }>; // { getNome: () => string; getIdade: () => number } // Extraindo params de rota — como o Next.js tipa rotas dinâmicas type Params<T extends string> = T extends `${string}/[${infer P}]/${infer Resto}` ? { [K in P]: string } & Params<`/${Resto}`> : T extends `${string}/[${infer P}]` ? { [K in P]: string } : {}; type R = Params<"/projetos/[id]/tarefas/[tarefaId]">; // { id: string } & { tarefaId: string }
10.4 Type guards, satisfies e branded types
// Type guard customizado: função que ensina o compilador function ehUsuario(v: unknown): v is { id: string; email: string } { return ( typeof v === "object" && v !== null && "id" in v && "email" in v ); } // satisfies: valida o formato SEM alargar o tipo const paleta = { primario: "#3178C6", erro: [180, 35, 24], } satisfies Record<string, string | number[]>; paleta.primario.toUpperCase(); // ✅ TS sabe que é string paleta.erro.map((n) => n); // ✅ TS sabe que é number[] // Com ": Record<...>" em vez de satisfies, os dois virariam // "string | number[]" e você perderia os métodos. Pegadinha clássica! // Branded types: IDs que não se misturam type UserId = string & { readonly __brand: "UserId" }; type ProjetoId = string & { readonly __brand: "ProjetoId" }; function buscarUsuario(id: UserId) { /* ... */ } declare const projetoId: ProjetoId; buscarUsuario(projetoId); // ❌ Erro: brands diferentes! // Em runtime são strings normais; a proteção existe só em compile time.
10.5 Padrão Result: erros como valores
export type Result<T, E = string> = | { ok: true; value: T } | { ok: false; error: E }; export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value }); export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error }); // Uso: quem chama é OBRIGADO a tratar o erro async function cobrar(valor: number): Promise<Result<{ id: string }, "saldo" | "rede">> { if (valor > 1000) return Err("saldo"); return Ok({ id: "tx_123" }); } const r = await cobrar(500); if (!r.ok) { // r.error é "saldo" | "rede" — trate cada caso } else { r.value.id; // seguro }
Para treinar tipos avançados no formato que aparece em entrevistas técnicas, resolva os desafios do repositório type-challenges no GitHub (níveis easy → medium cobrem 95% do que se pede). Citar que você pratica lá é um ótimo sinal em entrevista.
Exercícios do módulo 10
- Implemente do zero:
MeuPick<T, K>,MeuOmit<T, K>eDeepPartial<T>(recursivo). - Crie
type QueryString<T>que converte{ page: number; q: string }em"page=${number}&q=${string}"com template literals. - Adicione branded types
UserIdeProjetoIdao DevBoard com funções construtoras validadas por Zod. - Refatore uma Server Action do DevBoard para retornar
Resultcom união de erros literais em vez de lançar exceções.
Testes automatizados
"Você escreve testes?" é pergunta eliminatória em processos para pleno. A boa notícia: a pirâmide de testes moderna em Next.js é bem definida — Vitest para unidade, Testing Library para componentes, Playwright para ponta a ponta.
11.1 A pirâmide na prática
| Camada | Ferramenta | Testa o quê | Quantidade |
|---|---|---|---|
| Unitário | Vitest | Funções puras, schemas, utils | Muitos |
| Componente | Vitest + Testing Library | Comportamento da UI | Médio |
| E2E | Playwright | Fluxos críticos no navegador real | Poucos |
11.2 Vitest: testes unitários
npm install -D vitest @testing-library/react @testing-library/user-event \ @testing-library/jest-dom jsdom @vitejs/plugin-react
import { describe, it, expect } from "vitest"; import { calcularDesconto } from "./preco"; describe("calcularDesconto", () => { it("aplica o percentual sobre o preço", () => { expect(calcularDesconto(100, 10)).toBe(90); }); it("lança erro para percentual inválido", () => { expect(() => calcularDesconto(100, 150)).toThrow(); }); // it.each: tabela de casos — impressiona em code review it.each([ [200, 0, 200], [200, 50, 100], [200, 100, 0], ])("preço %i com %i%% => %i", (preco, pct, esperado) => { expect(calcularDesconto(preco, pct)).toBe(esperado); }); });
11.3 Testando componentes: comportamento, não implementação
import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import { NovaTarefaForm } from "./form"; describe("NovaTarefaForm", () => { it("envia o título digitado", async () => { const onSubmit = vi.fn(); render(<NovaTarefaForm onSubmit={onSubmit} />); // Busque como o USUÁRIO enxerga: por papel e rótulo acessível await userEvent.type( screen.getByRole("textbox", { name: /título/i }), "Estudar Playwright" ); await userEvent.click(screen.getByRole("button", { name: /criar/i })); expect(onSubmit).toHaveBeenCalledWith("Estudar Playwright"); }); it("mostra erro para título curto", async () => { render(<NovaTarefaForm onSubmit={vi.fn()} />); await userEvent.click(screen.getByRole("button", { name: /criar/i })); expect(await screen.findByText(/muito curto/i)).toBeInTheDocument(); }); });
"Quanto mais seus testes se parecem com o modo como o software é usado, mais confiança eles dão." Prefira getByRole e getByLabelText; evite testar estado interno ou classes CSS — de quebra, isso força você a escrever HTML acessível.
11.4 Playwright: E2E dos fluxos críticos
import { test, expect } from "@playwright/test"; test("usuário faz login e cria uma tarefa", async ({ page }) => { await page.goto("/login"); await page.getByLabel("E-mail").fill("ana@teste.com"); await page.getByLabel("Senha").fill("senha-segura-123"); await page.getByRole("button", { name: "Entrar" }).click(); await expect(page).toHaveURL("/dashboard"); await page.getByPlaceholder("Nova tarefa").fill("Revisar PR"); await page.getByRole("button", { name: "Criar" }).click(); await expect(page.getByText("Revisar PR")).toBeVisible(); });
- Rode E2E contra um banco de teste resetado por seed a cada execução.
- Teste E2E apenas os fluxos que quebram o negócio: login, cadastro, checkout/criação principal.
- Server Components e Actions são melhor cobertos por E2E ou testes de integração do que por testes unitários de componente.
Exercícios do módulo 11
- Cubra seus schemas Zod do DevBoard com testes unitários (casos válidos e inválidos com
it.each). - Teste o componente de filtro de projetos (M05): digitar filtra a lista, campo vazio mostra tudo.
- Escreva o E2E "cadastro → login → criar projeto → criar tarefa → marcar concluída" com Playwright.
- Configure
npm run testenpm run test:e2ecomo scripts separados — você os usará no CI (M14).
Arquitetura e padrões de projeto
Código que funciona é o mínimo. O que diferencia níveis de senioridade é organizar código que continua fácil de mudar depois de 2 anos e 20 desenvolvedores.
12.1 Estrutura de pastas que escala
src/ ├── app/ # SÓ roteamento: pages, layouts, handlers finos ├── features/ # o coração do app, por domínio │ ├── tarefas/ │ │ ├── components/ # UI específica de tarefas │ │ ├── actions.ts # Server Actions (camada fina) │ │ ├── service.ts # regras de negócio (funções puras quando possível) │ │ ├── repository.ts # acesso a dados (Prisma isolado aqui) │ │ └── schemas.ts # Zod + tipos do domínio │ ├── auth/ │ └── projetos/ ├── components/ui/ # design system compartilhado (Button, Card...) └── lib/ # infra transversal: db, env, utils
A regra central: as dependências apontam para dentro. A página chama a action; a action valida e chama o service; o service decide e chama o repository; só o repository conhece o Prisma. Trocar o ORM (ou mockar o banco num teste) vira trabalho local, não cirurgia geral.
import { tarefaRepo } from "./repository"; import { Result, Ok, Err } from "@/lib/result"; type ErroConcluir = "nao_encontrada" | "sem_permissao" | "ja_concluida"; export async function concluirTarefa( tarefaId: string, usuarioId: string ): Promise<Result<void, ErroConcluir>> { const tarefa = await tarefaRepo.buscarComDono(tarefaId); if (!tarefa) return Err("nao_encontrada"); if (tarefa.donoId !== usuarioId) return Err("sem_permissao"); if (tarefa.status === "CONCLUIDA") return Err("ja_concluida"); await tarefaRepo.atualizarStatus(tarefaId, "CONCLUIDA"); return Ok(undefined); } // Regra de negócio testável SEM Next.js, SEM HTTP, SEM mock de framework.
12.2 Padrões que valem citar em entrevista
- Repository: isola o acesso a dados atrás de uma interface (você viu acima).
- Factory: centraliza criação de objetos complexos (ex.: montar um cliente de pagamento por gateway).
- Strategy: comportamentos intercambiáveis —
Record<Metodo, (p: Pedido) => Promise<Result>>em vez deif/elsegigante. - Adapter: embrulha SDKs externos (Stripe, S3) num contrato seu; trocar fornecedor deixa de ser reescrita.
- Injeção de dependência simples: passe dependências como parâmetro (
criarService({ repo, clock })) — não precisa de framework de DI para ter testabilidade.
12.3 Princípios acima de padrões
- SRP (responsabilidade única): um módulo, um motivo para mudar.
- Composição > herança: em TS/React, praticamente sempre.
- Parse, don't validate: valide na fronteira (Zod) e circule tipos ricos por dentro — o interior do sistema nunca vê
unknown. - Torne estados inválidos irrepresentáveis:
{ status: 'sucesso'; dados: T } | { status: 'erro'; msg: string }em vez de{ dados?: T; erro?: string; carregando?: boolean }onde combinações impossíveis compilam. - YAGNI: não crie abstração para necessidade hipotética. Sênior também é saber não abstrair.
12.4 Monorepos (contexto de empresas maiores)
Times grandes organizam múltiplos apps e pacotes num único repositório com pnpm workspaces + Turborepo: apps/web, apps/api, packages/ui, packages/config. Benefícios: compartilhar tipos e componentes sem publicar em registro, builds com cache incremental. Saber o conceito e o vocabulário já ajuda em entrevistas para empresas maiores.
Exercícios do módulo 12
- Refatore a feature de tarefas do DevBoard para a estrutura action → service → repository, com o service retornando
Result. - Escreva testes unitários do service passando um repository fake por parâmetro (sem tocar no banco).
- Implemente notificações (e-mail/console) com o padrão Strategy, escolhendo a estratégia por configuração.
- Encontre no seu código um tipo com campos opcionais que permitem estados impossíveis e refatore para discriminated union.
Performance e otimização
Performance é dinheiro: conversão, SEO e custo de infraestrutura. Next.js entrega muita otimização de graça — seu trabalho é não desligá-la e medir o que importa.
13.1 Core Web Vitals: o vocabulário
| Métrica | Mede | Meta | Vilões comuns |
|---|---|---|---|
| LCP | Tempo até o maior elemento aparecer | < 2,5s | Imagens pesadas, servidor lento |
| INP | Resposta às interações | < 200ms | JavaScript demais no cliente |
| CLS | Estabilidade visual (pulos de layout) | < 0,1 | Imagens/anúncios sem dimensão |
Ferramentas: Lighthouse (DevTools), PageSpeed Insights e o profiler do React DevTools. Meça antes de otimizar — otimização sem medição é chute.
13.2 As otimizações nativas do Next.js
// 1. Imagens: redimensiona, converte p/ WebP/AVIF, lazy load, evita CLS import Image from "next/image"; <Image src="/capa.jpg" alt="Capa" width={1200} height={630} priority /> // priority: use na imagem do LCP (acima da dobra) // 2. Fontes: self-host automático, sem flash de fonte errada import { Inter } from "next/font/google"; const inter = Inter({ subsets: ["latin"] }); // 3. Code splitting: cada rota já é um bundle separado. // Componentes pesados só quando necessários: import dynamic from "next/dynamic"; const Grafico = dynamic(() => import("./grafico"), { loading: () => <Skeleton />, });
13.3 Estratégia de performance em RSC
- Menos JavaScript no cliente: a otimização nº 1 do App Router é manter componentes como Server Components. Antes de otimizar re-render, pergunte: "isso precisa ser client?"
- Requisições em paralelo:
Promise.allpara dados independentes;awaitsequencial só quando um depende do outro. - Streaming (M06): Suspense em blocos lentos melhora LCP percebido.
- Cache em camadas: ISR para conteúdo semi-estático;
revalidateTagpara invalidar cirurgicamente.
// ❌ Cascata: 300ms + 300ms + 300ms = 900ms const user = await buscarUser(); const projetos = await buscarProjetos(); const avisos = await buscarAvisos(); // ✅ Paralelo: ~300ms total const [user2, projetos2, avisos2] = await Promise.all([ buscarUser(), buscarProjetos(), buscarAvisos(), ]);
13.4 Otimizando o lado client
import { memo, useMemo, useCallback } from "react"; // useMemo: cachear CÁLCULO caro entre renders const ordenados = useMemo( () => [...itens].sort((a, b) => b.pontos - a.pontos), [itens] ); // useCallback + memo: evitar re-render de filho pesado const aoSelecionar = useCallback((id: string) => setSel(id), []); const LinhaMemo = memo(Linha);
Memoizar tudo por padrão polui o código e raramente ajuda. Use o React DevTools Profiler para achar o re-render caro primeiro. E fique de olho no React Compiler, que automatiza boa parte dessa memoização — vale citar em entrevista que você acompanha isso.
- Listas enormes: virtualização com TanStack Virtual (renderiza só o visível).
- Inputs de busca: debounce (300ms) antes de disparar requisições.
- Bundle: analise com
@next/bundle-analyzer; troque libs pesadas (ex.: date-fns em vez de moment).
Exercícios do módulo 13
- Rode o Lighthouse no DevBoard, registre as notas, aplique 3 melhorias e compare (antes/depois no README — excelente para portfólio).
- Encontre uma cascata de
awaitno seu código e paralelize comPromise.all. - Adicione debounce à busca de projetos e virtualização a uma lista de 5.000 itens simulados.
- Rode o bundle analyzer e identifique a maior dependência do bundle client. Dá para removê-la ou carregá-la dinamicamente?
Deploy, Docker e CI/CD
Projeto que não está no ar não existe para recrutador. Aqui você aprende o caminho fácil (Vercel), o caminho corporativo (Docker) e a esteira que roda testes a cada push.
14.1 Deploy na Vercel (o caminho padrão)
- Suba o projeto no GitHub.
- Em vercel.com, importe o repositório — o Next.js é detectado automaticamente.
- Configure as variáveis de ambiente (
DATABASE_URL,AUTH_SECRET, chaves OAuth) no painel. - Use um Postgres gerenciado (Neon/Supabase) com connection pooling habilitado.
- Cada Pull Request ganha um Preview Deploy com URL própria — mostre isso em entrevistas, é o fluxo real das empresas.
Adicione ao build: "build": "prisma generate && prisma migrate deploy && next build" — assim o banco de produção sempre acompanha o schema.
14.2 Docker: o padrão corporativo
Empresas com infraestrutura própria (AWS, GCP, Kubernetes) rodam Next.js em containers. Habilite output: "standalone" no next.config.ts e use um build multi-stage:
# Etapa 1: dependências FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # Etapa 2: build FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx prisma generate && npm run build # Etapa 3: imagem final enxuta (~150MB) FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public EXPOSE 3000 CMD ["node", "server.js"]
14.3 CI/CD com GitHub Actions
name: CI on: pull_request: push: { branches: [main] } jobs: quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - run: npm run lint # ESLint - run: npx tsc --noEmit # checagem de tipos - run: npm test -- --run # Vitest - run: npm run build # o build precisa passar
Com isso, todo PR só pode ser mesclado se lint, tipos, testes e build passarem — exatamente o portão de qualidade usado em empresas. Configure "branch protection" no GitHub exigindo o check verde.
14.4 Observabilidade mínima
- Erros: Sentry (captura exceções de server e client com stack trace e release).
- Logs estruturados: JSON com contexto (userId, rota) em vez de
console.logsolto. - Métricas de produto: Vercel Analytics / PostHog para Web Vitals reais e funil.
- Uptime: um monitor gratuito (ex.: UptimeRobot) pingando sua rota
/api/health.
Exercícios do módulo 14
- Publique o DevBoard na Vercel com banco Neon/Supabase e OAuth do GitHub funcionando em produção.
- Escreva o Dockerfile standalone e rode localmente com
docker compose(app + Postgres). - Configure o workflow de CI acima e quebre um teste de propósito para ver o PR bloqueado.
- Crie
GET /api/healthque checa a conexão com o banco e retorna{ status: 'ok' }.
Mercado de trabalho e portfólio
Habilidade técnica te qualifica; posicionamento te contrata. Este módulo transforma o que você construiu em entrevistas marcadas.
15.1 Portfólio que funciona: 2–3 projetos profundos
Recrutadores técnicos preferem poucos projetos completos a dezenas de clones de tutorial. Cada projeto do portfólio deve ter:
- Deploy no ar com link funcionando (e usuário demo:
demo@ex.com / demo1234). - README caprichado: screenshot/GIF, stack, decisões técnicas ("por que Server Actions e não REST aqui"), como rodar local.
- Autenticação + banco de dados + testes + CI — o combo que prova "full stack".
- Histórico de commits limpo com Conventional Commits.
- Uma feature não-óbvia: upload de arquivos, pagamento (Stripe modo teste), tempo real, exportação de relatório — algo além do CRUD.
- Micro-SaaS de nicho: agendamento para barbearias, controle de comandas, gestor de assinaturas — problemas reais impressionam mais que clones.
- Ferramenta dev: encurtador de URL com analytics, gerador de changelog a partir do GitHub.
- Contribuição open source: uma issue
good first issueaceita num projeto conhecido vale ouro no currículo.
15.2 O que as vagas pedem (e como se preparar)
| Nível | Esperam que você... | Perguntas típicas |
|---|---|---|
| Estágio/Júnior | Domine JS/TS básico, React, Git; aprenda rápido; comunique-se bem | map/filter, estado imutável, o que é uma API, diferença GET/POST |
| Pleno | Entregue features de ponta a ponta sozinho, com testes e boas práticas | SSR vs SSG, Server vs Client Components, N+1, JWT vs sessão, como estrutura um projeto |
| Sênior | Decida arquitetura, faça trade-offs, mentore, pense em custo/escala | Desenho de sistema, estratégia de cache, migrações sem downtime, "conte uma decisão técnica difícil" |
Faixas salariais variam muito por região, porte da empresa e câmbio — consulte pesquisas atuais (Glassdoor, levantamentos de comunidades como o da Rocketseat/Codigo Fonte TV, relatórios da GeekHunter) na época da sua busca. Vale saber: o mercado brasileiro tem muitas vagas remotas, e a stack TS/Next abre portas para vagas internacionais (contrato PJ via plataformas como Deel) — inglês intermediário para leitura/conversação técnica costuma ser o divisor de águas de remuneração.
15.3 Preparação para entrevistas técnicas
- Perguntas conceituais: revise os boxes "🎯" desta apostila; treine respostas de 2 minutos em voz alta.
- Live coding: pratique exercícios de arrays/objetos/strings em TypeScript falando o raciocínio enquanto codifica — avaliam comunicação tanto quanto código.
- Take-home: capriche em README, testes e commits; entregue algo pequeno e polido em vez de grande e quebrado.
- Sobre seu projeto: saiba defender cada decisão do DevBoard: "por que Prisma?", "como protege contra IDOR?", "o que mudaria com 100x mais usuários?".
- Comportamental: prepare 3 histórias no formato STAR (situação → tarefa → ação → resultado): um bug difícil, um conflito de opinião técnica, algo que você aprendeu errando.
15.4 Presença profissional
- LinkedIn: título claro ("Desenvolvedor Full Stack | TypeScript, React, Next.js, Node"), seção "Projetos" com links, e 1 post por semana sobre o que estudou — recrutadores buscam por palavras-chave.
- GitHub: README de perfil, pins nos melhores repositórios, contribuições consistentes.
- Comunidades: He4rt, Frontend Fusion, servidores de Discord de tecnologia, meetups locais — a maioria das primeiras vagas vem de indicação.
- Inglês técnico: 20 minutos por dia lendo documentação oficial em inglês já constrói o vocabulário necessário.
15.5 Roadmap de continuação
Depois desta apostila, os próximos degraus mais valorizados são: tRPC (RPC tipado ponta a ponta), TanStack Query a fundo, WebSockets/tempo real, filas e background jobs (BullMQ, Inngest), Stripe (billing de SaaS), React Native/Expo (reaproveitando seu TS) e fundamentos de AWS. Escolha um por vez, sempre construindo algo público.
Evolua o DevBoard para um SaaS completo e público:
- Times/workspaces com convites por e-mail (Resend) e papéis por workspace
- Plano pago com Stripe em modo teste (checkout + webhook + bloqueio de features)
- API pública documentada com chave de API e rate limiting
- Suíte de testes no CI + preview deploys + Sentry em produção
- Um post no LinkedIn/blog contando as decisões técnicas do projeto