⚡ Cap 05: Stack PERN: Express com TypeScript, PostgreSQL, Prisma ORM e React com TanStack Query
🗺️ Mapa Arquitetural do Tópico
flowchart LR
REACT["⚛️ React<br/>TanStack Query (cache do servidor)"]
REACT -->|"fetch /api/... (proxy do Vite em dev)"| EXPRESS["🚂 Express + TypeScript<br/>router → Zod → handler de erros"]
EXPRESS --> PRISMA["🔷 Prisma Client<br/>(um por processo)"]
PRISMA --> PG[("🐘 PostgreSQL")]
style REACT fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px
style EXPRESS fill:#fff3e0,stroke:#ff9800,stroke-width:2px
style PRISMA fill:#e8f5e9,stroke:#4caf50,stroke-width:1px
style PG fill:#ede7f6,stroke:#673ab7,stroke-width:1px
🧩 1. O problema: uma linguagem de ponta a ponta, mas sem regras
PERN é PostgreSQL, Express, React e Node.js. A promessa é usar TypeScript dos dois lados: o mesmo tipo Tarefa descreve o que o banco guarda, o que a API devolve e o que a tela mostra (o contrato compartilhado do Cap. 01 e o schema Zod do Cap. 04).
O preço é que as duas pontas são minimalistas e não opinam:
| O que vem pronto | O que você precisa decidir | |
|---|---|---|
| Express | Rotas, middlewares, req/res |
Pastas, validação, tratamento de erros, como falar com o banco |
| React | Componentes e estado local | Como buscar dados, cache, carregamento, erro, recarregar depois de uma mudança |
Este capítulo mostra as decisões que fazem uma API Express e um cliente React não quebrarem em produção. O NestJS do Cap. 06 é a resposta “com opinião” ao mesmo problema.
🚂 2. Express com TypeScript: router, validação e erros
Uma API Express organizada tem três peças: um router por recurso, a validação na entrada e um único handler de erros no fim.
const NovaTarefa = z.object({
titulo: z.string().min(3),
prioridade: z.enum(["BAIXA", "MEDIA", "ALTA"]).default("MEDIA"),
});
export class ErroHttp extends Error {
constructor(public status: number, message: string) { super(message); }
}
// Express 4 não repassa promessas rejeitadas para o handler de erros.
export const assincrono =
(fn: (req: Request, res: Response) => Promise<unknown>) =>
(req: Request, res: Response, next: NextFunction) => { fn(req, res).catch(next); };
export const tarefasRouter = express.Router();
tarefasRouter.post("/", assincrono(async (req, res) => {
const dados = NovaTarefa.parse(req.body); // ZodError vai para o handler de erros
const tarefa = { id: proximoId++, concluida: false, ...dados };
tarefas.set(tarefa.id, tarefa);
res.status(201).location(`/api/tarefas/${tarefa.id}`).json(tarefa);
}));
tarefasRouter.patch("/:id/concluir", assincrono(async (req, res) => {
const tarefa = tarefas.get(Number(req.params.id));
if (!tarefa) throw new ErroHttp(404, "tarefa não encontrada");
tarefa.concluida = true;
res.json(tarefa);
}));
O handler de erros tem quatro parâmetros (é assim que o Express o reconhece) e é registrado depois das rotas:
export function tratarErros(err: unknown, _req: Request, res: Response, _next: NextFunction) {
if (err instanceof z.ZodError) {
res.status(400).json({ erro: "dados inválidos", campos: err.flatten().fieldErrors });
} else if (err instanceof ErroHttp) {
res.status(err.status).json({ erro: err.message });
} else {
console.error(err);
res.status(500).json({ erro: "erro interno" }); // nunca o stack trace para o cliente
}
}
const app = express();
app.use(express.json()); // sem isto, req.body chega undefined
app.use("/api/tarefas", tarefasRouter);
app.use(tratarErros);
Com isso, cada rota só descreve o caminho feliz. Rodando esse código, um POST com {"titulo":"ab"} devolve 400 {"erro":"dados inválidos","campos":{"titulo":["String must contain at least 3 character(s)"]}}, e PATCH /api/tarefas/99/concluir devolve 404 {"erro":"tarefa não encontrada"}, sem nenhum try/catch nas rotas.
Por que o assincrono? No Express 4, um throw dentro de uma rota async não chega ao handler de erros: vira uma promessa rejeitada sem tratamento, e no Node.js 22 isso derruba o processo inteiro, não só aquela requisição. O Express 5 passou a repassar promessas rejeitadas sozinho; no 4, o embrulho (ou try/catch + next(err)) é obrigatório.
🐘 3. PostgreSQL com Prisma dentro da rota
O Prisma gera um cliente tipado a partir do schema.prisma. A rota pede exatamente o que a tela usa:
// UM cliente para o processo inteiro: cada PrismaClient abre o seu pool de conexões.
export const prisma = new PrismaClient();
router.get("/", async (_req, res, next) => {
try {
const pendentes = await prisma.tarefa.findMany({
where: { concluida: false },
orderBy: { criadaEm: "desc" },
select: { id: true, titulo: true, prioridade: true }, // só as colunas que a tela usa
});
res.json(pendentes);
} catch (e) {
next(e);
}
});
O select não é detalhe: sem ele, a API devolve todas as colunas, inclusive as que não deveriam sair do servidor. Migrações, relações, transações e a comparação com TypeORM e Drizzle ficam para o Cap. 07.
⚛️ 4. React com TanStack Query: estado do servidor não é estado da tela
Buscar dados com useEffect + useState parece simples, mas cada tela reimplementa a mesma coisa: estado de carregamento, de erro, recarregar depois de criar ou excluir, evitar a requisição repetida quando duas telas pedem o mesmo dado. O TanStack Query trata dados do servidor como um cache identificado por uma chave:
export function ListaTarefas() {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
queryKey: ["tarefas"], // a chave identifica o cache
queryFn: () => api<Tarefa[]>("/tarefas"),
staleTime: 30_000, // por 30 s, outra tela reaproveita sem nova requisição
});
const concluir = useMutation({
mutationFn: (id: number) => api<Tarefa>(`/tarefas/${id}/concluir`, { method: "PATCH" }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tarefas"] }), // recarrega a lista
});
if (isPending) return <p>Carregando…</p>;
if (error) return <p>Erro: {error.message}</p>;
return (
<ul>
{data.map((t) => (
<li key={t.id}>
{t.titulo}
{!t.concluida && (
<button onClick={() => concluir.mutate(t.id)} disabled={concluir.isPending}>Concluir</button>
)}
</li>
))}
</ul>
);
}
A função api é um fetch com um detalhe que todo mundo esquece: fetch não rejeita em 4xx ou 5xx. Ela precisa conferir resp.ok e lançar o erro, senão o TanStack Query trata um 404 como sucesso:
async function api<T>(caminho: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`/api${caminho}`, { headers: { "Content-Type": "application/json" }, ...init });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json() as Promise<T>;
}
Estado do servidor × estado do cliente. O TanStack Query é para o que mora na API (a lista de tarefas). O que só existe na tela (aba aberta, carrinho ainda não enviado, texto digitado) continua em useState ou num hook próprio.
4.1 Frontend e API em portas diferentes: proxy e CORS
Em desenvolvimento, o Vite roda na porta 5173 e o Express na 3000. Em vez de liberar CORS, o mais simples é o proxy do Vite, que faz o navegador enxergar uma origem só:
// vite.config.ts
export default defineConfig({
plugins: [react()],
server: { proxy: { "/api": "http://localhost:3000" } }, // /api/... vai para o Express
});
Em produção, com domínios diferentes, a API libera apenas a origem do frontend:
app.use(cors({
origin: process.env.WEB_ORIGIN ?? "http://localhost:5173", // nunca "*" junto com credentials
credentials: true, // permite o cookie do refresh token
}));
⚖️ 5. Comparação
Backend em Node.js:
| Express | Fastify | NestJS (Cap. 06) | |
|---|---|---|---|
| Estrutura | Você decide | Você decide, com plugins | Módulos, controllers e serviços impostos |
| Validação | Biblioteca à parte (Zod) | JSON Schema embutido | class-validator com DTOs |
Erros em rotas async |
Embrulho no Express 4; automático no 5 | Automático | Automático (filtros de exceção) |
| Desempenho | Bom | O mais rápido dos três | Igual ao adaptador por baixo (Express ou Fastify) |
| Quando usar | APIs pequenas, aprender o básico | APIs com muito tráfego | Equipes grandes, domínio complexo |
Busca de dados no React:
useEffect + useState |
TanStack Query | SWR | |
|---|---|---|---|
| Cache entre telas | ❌ cada tela busca de novo | ✅ por queryKey |
✅ por chave |
| Carregando, erro, recarregar | À mão em cada tela | Pronto | Pronto |
| Mutações e invalidação | À mão (setTasks(...)) |
useMutation + invalidateQueries |
mutate |
| Quando usar | Uma tela, um dado, nada compartilhado | Aplicações com várias telas sobre a mesma API | Leitura simples, sobretudo com Next.js |
⚠️ 6. Armadilhas comuns
| Sintoma | Causa | Correção |
|---|---|---|
| O servidor cai inteiro quando o banco falha numa rota | throw em rota async no Express 4, sem next(err) |
Embrulho assincrono (Seção 2) ou Express 5 |
req.body é undefined |
Falta app.use(express.json()), ou ele foi registrado depois das rotas |
Registre o parser antes das rotas |
| O handler de erros nunca é chamado | Registrado antes das rotas, ou com 3 parâmetros | 4 parâmetros e app.use(tratarErros) por último |
| “Too many connections” no PostgreSQL | new PrismaClient() dentro da rota (ou recriado a cada hot reload) |
Um cliente por processo, exportado de um módulo |
| A lista não atualiza depois de concluir | Mutação sem invalidateQueries |
onSuccess invalidando a mesma queryKey |
| Filtro mostra dados do filtro anterior | queryKey: ["tarefas"] sem o parâmetro |
queryKey: ["tarefas", { status }]: a chave inclui tudo de que o resultado depende |
| Erro 404 tratado como sucesso | fetch não rejeita em 4xx/5xx |
Conferir resp.ok e lançar |
| Chave de API visível no navegador | Variável VITE_* com segredo: tudo que começa com VITE_ vai para o JavaScript público |
Segredos só no backend |
🗂️ 7. No Catálogo de Projetos
O Catálogo ainda não tem um projeto PERN completo. Os mais próximos mostram as peças separadas, e cada um tem algo para observar:
fullstack_tarefas_07_spring_react: o cliente React (web-react/src/pages/HomePage.jsx) busca as tarefas comuseEffect+useStatee, depois de excluir ou concluir, atualiza a lista à mão comsetTasks(prev => ...). É o “antes” da Seção 4: compare cada trecho com ouseQuery/useMutationequivalente. O acesso à API fica emweb-react/src/services/TaskService.js.fullstack_tecloja_03_nest_react: oweb-react/src/hooks/useCart.jsé estado do cliente (o carrinho, comuseState,useCallbackeuseMemo), o caso em que TanStack Query não se aplica. O backend é NestJS, que por padrão roda sobre o Express.infra_nginx_01_nodejs: o Express mais simples possível (api-01/index.js, uma rotaGET /), em duas instâncias atrás de um Nginx que balanceia a carga. Bom para ver o Express sem nenhuma camada, e o que falta nele (validação, erros, banco).fullstack_pi_01_parkmanagerafullstack_pi_05_stockflow: frontends React com os dados iniciais fixos no próprio código (no PI 01,web-react/src/data.ts) e guardados nolocalStoragedo navegador, sem API. Escrever a API Express que falta para um deles e trocar o estado local por TanStack Query é o desafio dos exercícios deste capítulo.