Como usar Express com React: Guia Completo
Introdução
Existe um momento na carreira de todo desenvolvedor frontend em que uma aplicação React precisa de algo além de chamadas a APIs externas. Você precisa de autenticação customizada, de um proxy para serviços internos, de server-side rendering controlado ou simplesmente de um backend próprio que converse diretamente com o seu frontend. É nesse momento que a combinação Express com React se torna uma escolha natural.
Neste guia, vou mostrar como integrar Express e React em um projeto fullstack usando TypeScript do início ao fim. Não vamos ficar no "hello world": o objetivo aqui é construir uma arquitetura que funcione em projetos reais, com padrões que você pode levar direto para produção.
Se você já tem pelo menos dois anos de experiência com desenvolvimento web e quer dominar essa integração, este post é para você.
Por que usar Express com React juntos
Antes de colocar a mão no código, vale entender em quais cenários essa combinação faz mais sentido do que alternativas como Next.js ou Remix.
Controle total sobre o backend
Frameworks fullstack como Next.js (inclusive com as novas Server Actions do Next.js 15) são incríveis para muitos cenários. Porém, quando o projeto exige controle granular sobre middlewares, autenticação via sessão, integração com sistemas legados ou orquestração de múltiplos microsserviços, ter um servidor Express dedicado oferece flexibilidade que frameworks opinados nem sempre permitem.
Casos de uso reais onde essa combinação brilha
Ao longo dos anos, usei Express com React em cenários muito específicos que justificam o esforço adicional de configuração:
- Dashboards corporativos que precisam de um BFF (Backend for Frontend) para agregar dados de múltiplas APIs internas e proteger credenciais sensíveis.
- Aplicações SaaS multi-tenant onde o servidor precisa resolver o tenant antes de servir o frontend, injetando configurações dinâmicas no HTML.
- Sistemas com upload e processamento de arquivos que exigem streaming no servidor e feedback em tempo real via WebSockets.
- Projetos que migram gradualmente de um backend monolítico para uma SPA, onde o Express já existe e o React está sendo introduzido como nova camada de interface.
Separação clara de responsabilidades
Com Express cuidando exclusivamente do backend e React cuidando exclusivamente do frontend, cada camada pode ser testada, deployada e escalada de forma independente. Essa separação pode parecer mais trabalhosa no início, mas em projetos de médio e grande porte ela paga dividendos enormes em manutenibilidade.
Setup do projeto Express com React usando TypeScript
Vamos criar a estrutura do projeto do zero. A ideia é ter um monorepo simples com duas pastas: server para o Express e client para o React.
Estrutura de pastas
fullstack-app/
├── server/
│ ├── src/
│ │ ├── index.ts
│ │ ├── routes/
│ │ │ └── api.ts
│ │ └── middleware/
│ │ └── errorHandler.ts
│ ├── tsconfig.json
│ └── package.json
├── client/
│ ├── src/
│ │ ├── App.tsx
│ │ ├── hooks/
│ │ │ └── useApi.ts
│ │ └── components/
│ │ └── TaskList.tsx
│ ├── tsconfig.json
│ └── package.json
└── package.jsonConfigurando o servidor Express
Comece inicializando o projeto do servidor e instalando as dependências:
mkdir -p server/src/routes server/src/middleware
cd server
npm init -y
npm install express cors
npm install -D typescript @types/express @types/cors ts-node-devConfigure o tsconfig.json do servidor:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}Configurando o cliente React
Para o frontend, use o Vite por ser mais rápido e mais flexível que o Create React App:
cd ..
npm create vite@latest client -- --template react-ts
cd client
npm installExemplo prático: API de tarefas com Express e consumo no React
Agora vem a parte que interessa. Vamos construir uma API de tarefas no Express e consumi-la no React, tudo tipado com TypeScript.
Tipos compartilhados
Primeiro, defina os tipos que serão usados tanto no backend quanto no frontend. Em um projeto real, você colocaria isso em um pacote compartilhado. Para simplificar, vamos definir em ambos os lados:
// Tipo compartilhado (usado tanto no server quanto no client)
interface Task {
id: string;
title: string;
description: string;
completed: boolean;
createdAt: string;
}
interface CreateTaskDTO {
title: string;
description: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}Servidor Express completo com rotas tipadas
Este é o primeiro exemplo prático completo. Observe como cada rota está tipada e como o middleware de erro centraliza o tratamento de exceções:
// server/src/routes/api.ts
import { Router, Request, Response, NextFunction } from "express";
interface Task {
id: string;
title: string;
description: string;
completed: boolean;
createdAt: string;
}
interface CreateTaskDTO {
title: string;
description: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}
const router = Router();
// Simulando um banco de dados em memória
const tasks: Task[] = [
{
id: "1",
title: "Configurar projeto fullstack",
description: "Integrar Express com React usando TypeScript",
completed: false,
createdAt: new Date().toISOString(),
},
];
// GET /api/tasks
router.get(
"/tasks",
(_req: Request, res: Response<ApiResponse<Task[]>>) => {
res.json({
success: true,
data: tasks,
});
}
);
// POST /api/tasks
router.post(
"/tasks",
(
req: Request<unknown, unknown, CreateTaskDTO>,
res: Response<ApiResponse<Task>>,
next: NextFunction
) => {
try {
const { title, description } = req.body;
if (!title || !description) {
res.status(400).json({
success: false,
data: {} as Task,
error: "Os campos title e description são obrigatórios",
});
return;
}
const newTask: Task = {
id: String(Date.now()),
title,
description,
completed: false,
createdAt: new Date().toISOString(),
};
tasks.push(newTask);
res.status(201).json({
success: true,
data: newTask,
});
} catch (error) {
next(error);
}
}
);
// PATCH /api/tasks/:id/toggle
router.patch(
"/tasks/:id/toggle",
(
req: Request<{ id: string }>,
res: Response<ApiResponse<Task>>,
next: NextFunction
) => {
try {
const task = tasks.find((t) => t.id === req.params.id);
if (!task) {
res.status(404).json({
success: false,
data: {} as Task,
error: "Tarefa não encontrada",
});
return;
}
task.completed = !task.completed;
res.json({
success: true,
data: task,
});
} catch (error) {
next(error);
}
}
);
export default router;
// server/src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
interface ErrorResponse {
success: false;
data: null;
error: string;
}
export function errorHandler(
err: Error,
_req: Request,
res: Response<ErrorResponse>,
_next: NextFunction
): void {
console.error(`[ERROR] ${err.message}`, err.stack);
res.status(500).json({
success: false,
data: null,
error:
process.env.NODE_ENV === "production"
? "Erro interno do servidor"
: err.message,
});
}
// server/src/index.ts
import express from "express";
import cors from "cors";
import apiRoutes from "./routes/api";
import { errorHandler } from "./middleware/errorHandler";
const app = express();
const PORT = process.env.PORT || 3001;
// Middlewares globais
app.use(
cors({
origin: process.env.CLIENT_URL || "http://localhost:5173",
credentials: true,
})
);
app.use(express.json());
// Rotas
app.use("/api", apiRoutes);
// Health check
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Middleware de erro (deve ser o último)
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Servidor rodando em http://localhost:${PORT}`);
});Cliente React consumindo a API com hook customizado
Este segundo exemplo prático mostra o lado do React. Criei um hook customizado que encapsula toda a comunicação com o backend e um componente que o utiliza:
// client/src/hooks/useApi.ts
import { useState, useEffect, useCallback } from "react";
interface Task {
id: string;
title: string;
description: string;
completed: boolean;
createdAt: string;
}
interface CreateTaskDTO {
title: string;
description: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001/api";
export function useTasks() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const fetchTasks = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_BASE}/tasks`);
const result: ApiResponse<Task[]> = await response.json();
if (!result.success) {
throw new Error(result.error || "Falha ao carregar tarefas");
}
setTasks(result.data);
} catch (err) {
const message =
err instanceof Error ? err.message : "Erro desconhecido";
setError(message);
} finally {
setLoading(false);
}
}, []);
const createTask = useCallback(
async (dto: CreateTaskDTO): Promise<Task | null> => {
try {
setError(null);
const response = await fetch(`${API_BASE}/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dto),
});
const result: ApiResponse<Task> = await response.json();
if (!result.success) {
throw new Error(result.error || "Falha ao criar tarefa");
}
setTasks((prev) => [...prev, result.data]);
return result.data;
} catch (err) {
const message =
err instanceof Error ? err.message : "Erro desconhecido";
setError(message);
return null;
}
},
[]
);
const toggleTask = useCallback(async (id: string) => {
try {
setError(null);
const response = await fetch(`${API_BASE}/tasks/${id}/toggle`, {
method: "PATCH",
});
const result: ApiResponse<Task> = await response.json();
if (!result.success) {
throw new Error(result.error || "Falha ao atualizar tarefa");
}
setTasks((prev) =>
prev.map((task) => (task.id === id ? result.data : task))
);
} catch (err) {
const message =
err instanceof Error ? err.message : "Erro desconhecido";
setError(message);
}
}, []);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
return { tasks, loading, error, createTask, toggleTask, refetch: fetchTasks };
}
// client/src/components/TaskList.tsx
import { useState, FormEvent } from "react";
import { useTasks } from "../hooks/useApi";
export function TaskList() {
const { tasks, loading, error, createTask, toggleTask } = useTasks();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!title.trim() || !description.trim()) return;
setSubmitting(true);
const result = await createTask({ title, description });
setSubmitting(false);
if (result) {
setTitle("");
setDescription("");
}
}
if (loading) return <p>Carregando tarefas...</p>;
return (
<div>
<h1>Minhas Tarefas</h1>
{error && (
<div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
{error}
</div>
)}
<form onSubmit={handleSubmit} style={{ marginBottom: "2rem" }}>
<div>
<input
type="text"
placeholder="Título da tarefa"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div>
<textarea
placeholder="Descrição"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
/>
</div>
<button type="submit" disabled={submitting}>
{submitting ? "Criando..." : "Criar Tarefa"}
</button>
</form>
<ul>
{tasks.map((task) => (
<li key={task.id} style={{ marginBottom: "0.5rem" }}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
/>
<span
style={{
textDecoration: task.completed ? "line-through" : "none",
marginLeft: "0.5rem",
}}
>
{task.title}
</span>Guias Relacionados
Explore mais guias técnicos
Mais de 100 guias práticos de integração entre frameworks, ferramentas e plataformas.
Ver todos os guias