express.Router: dividir a API em arquivos por recurso
Como quebrar um index.js de 400 linhas em rotas, controllers e serviços com express.Router, sem perder o prefixo nem os middlewares do grupo.
express.Router() cria um mini-aplicativo que você monta dentro do principal.
Cada recurso da API — livros, leitores, empréstimos — ganha o próprio arquivo de
rotas, e o index.js volta a caber na tela. O contrato não muda: mesmas URLs,
mesmos status, mesmas respostas.
Os exemplos desta lição são a API da Biblioteca Vila Maria, uma biblioteca de
bairro com acervo, leitores cadastrados e empréstimo com prazo. Ela já existe e
funciona; o que vamos fazer aqui é reorganizar o mesmo código em arquivos, e
depois provar, com diff, que nenhuma resposta mudou.
Pense no balcão de uma biblioteca
Imagine uma biblioteca pequena com uma única pessoa atendendo empréstimo,
cadastro, devolução, multa e reserva no mesmo balcão. No começo funciona. Quando
a fila cresce, cada pedido obriga essa pessoa a procurar a regra certa no meio
de um fichário enorme. Separar o código com express.Router() é abrir um balcão
para cada assunto: livros de um lado, leitores de outro, empréstimos em outro.
O nome técnico continua sendo roteador. Ele recebe uma requisição, compara método e caminho e escolhe qual função vai atendê-la. A analogia termina aí: o router não cria outro servidor nem muda a URL pública. Ele só organiza a mesma porta de entrada por dentro. Durante a lição, confira essa ideia em cada etapa: se o cliente continua recebendo a mesma resposta, a biblioteca foi organizada sem mudar o serviço.
O index.js que cresceu: os sintomas antes da refatoração
A versão original é um arquivo só. Todas as rotas, os dados em memória e as regras moram nele:
wc -l antes/index.jsCento e cinquenta linhas é pequeno perto do index.js de quatrocentas que você
vai encontrar num projeto de verdade — e já é grande demais para trabalhar
confortavelmente. Repare no que se repete:
app.post('/livros', (req, res) => {
if (req.get('x-cracha') !== 'bibliotecario') {
return res.status(403).json({ erro: 'Só bibliotecário altera o acervo' });
}
// ...
});
app.patch('/livros/:id', (req, res) => {
if (req.get('x-cracha') !== 'bibliotecario') {
return res.status(403).json({ erro: 'Só bibliotecário altera o acervo' });
}
// ...
});
app.delete('/livros/:id', (req, res) => {
if (req.get('x-cracha') !== 'bibliotecario') {
return res.status(403).json({ erro: 'Só bibliotecário altera o acervo' });
}
// ...
});Três sintomas, e você provavelmente reconhece os três:
- a mesma verificação de crachá copiada em três handlers;
Ctrl+Fvirou o jeito de navegar no arquivo;- duas pessoas mexendo em recursos diferentes dão conflito no mesmo arquivo.
Nenhum é problema de sintaxe. São problemas de onde o código mora.
express.Router(): um mini-app por recurso
Router() devolve um objeto com a mesma cara do app: tem .get(), .post(),
.patch(), .delete() e .use(). A diferença é que ele não escuta porta
nenhuma. Ele é uma peça, não um servidor.
// src/routes/livros.routes.js
import { Router } from 'express';
import * as controller from '../controllers/livros.controller.js';
import exigirCracha from '../middlewares/exigirCracha.js';
const router = Router();
router.get('/', controller.listar);
router.get('/:id', controller.mostrar);
router.use(exigirCracha('altera o acervo'));
router.post('/', controller.criar);
router.patch('/:id', controller.atualizar);
router.delete('/:id', controller.remover);
export default router;Duas coisas para reparar já. Primeiro: os caminhos são '/' e '/:id', sem a
palavra livros em lugar nenhum. Segundo: router.use(...) no meio do arquivo,
depois dos GET — a ordem ali é intencional, e a gente volta nela.
app.use(‘/livros’, router): onde o prefixo realmente entra
A palavra livros entra uma vez só, na hora de montar:
app.use('/livros', livrosRotas);O que o Express faz com esse prefixo não é concatenar string. Ele corta o
trecho combinado da URL antes de entregar a requisição ao router, e guarda o
que cortou em req.baseUrl. Dá para ver isso:
import express, { Router } from 'express';
const livros = Router();
livros.get('/:id', (req, res) => {
res.json({
originalUrl: req.originalUrl,
baseUrl: req.baseUrl,
url: req.url,
path: req.path,
params: req.params,
});
});
const app = express();
app.use('/livros', livros);
app.listen(3030);curl -s "http://localhost:3030/livros/2?formato=curto"Leia a saída com calma, porque ela explica quase tudo:
| propriedade | valor | quem enxerga |
|---|---|---|
req.originalUrl |
/livros/2?formato=curto |
a URL que o cliente pediu, intacta |
req.baseUrl |
/livros |
o prefixo consumido pelo app.use |
req.url |
/2?formato=curto |
o que sobrou para o router resolver |
req.path |
/2 |
o mesmo, sem a query string |
Por isso o arquivo de rotas escreve '/:id': quando a requisição chega nele, o
/livros já foi embora. É também por isso que o seu middleware de log deve usar
req.originalUrl — dentro de um router montado, req.url mostra o caminho
mutilado.
O 404 que aparece quando você repete o prefixo
Esse é o tropeço número um de quem acabou de mudar para router. Você mantém o caminho completo dentro do arquivo:
import express, { Router } from 'express';
const livros = Router();
livros.get('/livros/:id', (req, res) => { // errado: prefixo repetido
res.json({ id: req.params.id });
});
const app = express();
app.use('/livros', livros);
app.use((req, res) => {
res.status(404).json({ erro: `Rota ${req.method} ${req.path} não existe` });
});
app.listen(3030);curl -s -w ' [%{http_code}]\n' http://localhost:3030/livros/2
curl -s -w ' [%{http_code}]\n' http://localhost:3030/livros/livros/2A rota não sumiu: ela mudou de endereço para /livros/livros/2. Quando um 404
aparece logo depois de você extrair um router, é quase sempre isto.
O erro mais comum: o router que nunca foi exportado
O segundo tropeço não dá 404 — ele nem deixa o servidor subir. Você escreve o arquivo inteiro e esquece a última linha:
// src/routes/livros.routes.js
import { Router } from 'express';
const router = Router();
router.get('/', (req, res) => res.json([]));
// (faltou a última linha: export default router)node provas/sem-export/app.jsO erro é bom: ele aponta o arquivo, a linha e o nome do export que faltou. Em
projeto CommonJS, o mesmo esquecimento produz uma mensagem bem menos generosa,
porque o require devolve um objeto vazio em vez de falhar:
// livros.routes.js — versão CommonJS do mesmo erro
const { Router } = require('express');
const router = Router();
router.get('/', (req, res) => res.json([]));
// (faltou a última linha: module.exports = router)O app.js que importa esse arquivo continua idêntico. A diferença é que agora
ele recebe {}, e só descobre isso na linha do app.use:
node provas/cjs/app.jsTypeError: argument handler must be a function at router.use (/…/node_modules/router/index.js:392:13) at app.<anonymous> (/…/node_modules/express/lib/application.js:222:21) at Array.forEach (<anonymous>) at app.use (/…/node_modules/express/lib/application.js:219:7) at Object.<anonymous> (/…/provas/cjs/app.js:5:5) Node.js v24.16.0
argument handler must be a function quer dizer “você passou para o use uma
coisa que não é middleware”. Na prática, quase sempre é um router que não foi
exportado, ou um module.exports = { router } importado como se fosse o router
direto. Se você ainda não decidiu entre os dois sistemas de módulo, a lição
ESM ou CommonJS no Node compara os dois no mesmo
projeto.
Separando rota, controller e serviço sem inventar camada
Com o recurso já isolado, o arquivo de rotas fica sendo o índice do recurso: quem lê ele vê a API inteira de livros em seis linhas de rota. O que cada rota faz mora em outro lugar.
O controller conhece HTTP. Ele lê req, escolhe o status e escreve res:
// src/controllers/livros.controller.js
import * as servico from '../services/livros.service.js';
export function mostrar(req, res) {
const livro = servico.porId(req.params.id);
if (!livro) return res.status(404).json({ erro: 'Livro não encontrado' });
res.json(livro);
}
export function criar(req, res) {
const { titulo, autor, exemplares } = req.body;
if (!titulo || !autor) {
return res.status(400).json({ erro: 'titulo e autor são obrigatórios' });
}
res.status(201).json(servico.criar({ titulo, autor, exemplares }));
}O serviço não conhece HTTP. Ele não recebe req, não devolve res, e por isso
você consegue testá-lo sem subir servidor nenhum:
// src/services/livros.service.js
import { acervo, proximoId } from '../acervo.js';
export function porId(id) {
return acervo.livros.find((l) => l.id === Number(id));
}
export function criar({ titulo, autor, exemplares }) {
const livro = { id: proximoId('livros'), titulo, autor, exemplares: exemplares ?? 1 };
acervo.livros.push(livro);
return livro;
}
export function remover(id) {
const indice = acervo.livros.findIndex((l) => l.id === Number(id));
if (indice === -1) return false;
acervo.livros.splice(indice, 1);
return true;
}Repare que remover devolve true ou false, não 204 nem 404. Traduzir
isso para status é trabalho do controller. Essa fronteira é o critério inteiro:
| camada | pode tocar em | nunca toca em |
|---|---|---|
| rotas | caminho, método, middleware do grupo | req.body, banco, regra |
| controller | req, res, status, formato da resposta |
consulta ao banco, cálculo de regra |
| serviço | dados, regra de negócio, outros serviços | req, res, status HTTP |
E agora a parte que quase nenhum tutorial diz: não crie camada onde não há
regra. Na Biblioteca Vila Maria, leitores só guarda e devolve. O controller
fala direto com os dados, e não existe leitores.service.js nenhum:
// src/controllers/leitores.controller.js
import { acervo, proximoId } from '../acervo.js';
export function criar(req, res) {
const { nome, email } = req.body;
if (!nome || !email) {
return res.status(400).json({ erro: 'nome e email são obrigatórios' });
}
const leitor = { id: proximoId('leitores'), nome, email };
acervo.leitores.push(leitor);
res.status(201).json(leitor);
}Um service que só repassa a chamada não organiza nada — ele só acrescenta um arquivo que você vai abrir por engano. Empréstimo tem regra (baixa de exemplar, devolução repetida, disponibilidade), então empréstimo tem service. Leitor não tem, então não tem.
Middleware que vale só para um grupo de rotas
Lembra da verificação de crachá copiada três vezes? Ela vira um middleware, e o router decide o alcance dela. Como o crachá protege ações com mensagens diferentes, o middleware é uma função que devolve um middleware:
// src/middlewares/exigirCracha.js
export default function exigirCracha(acao) {
return (req, res, next) => {
if (req.get('x-cracha') !== 'bibliotecario') {
return res.status(403).json({ erro: `Só bibliotecário ${acao}` });
}
next();
};
}Existem duas formas de aplicar, e elas resolvem casos diferentes. A primeira é
router.use(), que vale para tudo que vier depois dele no arquivo:
router.get('/', controller.listar); // público
router.get('/:id', controller.mostrar); // público
router.use(exigirCracha('altera o acervo'));
router.post('/', controller.criar); // exige crachá
router.patch('/:id', controller.atualizar);
router.delete('/:id', controller.remover);A segunda é passar o middleware direto na rota, quando só uma precisa dele:
router.post('/', exigirCracha('cadastra leitor'), controller.criar);O erro aqui é de posição, não de sintaxe. Subindo aquele router.use() três
linhas, para antes dos GET, a leitura pública do acervo morre:
curl -s -w ' [%{http_code}]\n' http://localhost:3030/livros
curl -s -w ' [%{http_code}]\n' http://localhost:3030/leitoresDuas lições numa saída só. /livros quebrou porque o middleware passou a rodar
antes das rotas públicas. E /leitores continuou aberto — prova de que o
router.use() não vaza para fora do router em que foi escrito. Esse escopo é
exatamente o que você não tinha no index.js gigante, onde app.use() pegava
o aplicativo inteiro. Se a mecânica de next() e ordem ainda não estiver clara,
vale reler middleware no Express antes de
seguir.
mergeParams: quando a rota filha precisa do :id do pai
Empréstimo não existe sozinho: ele é sempre de um leitor. A URL diz isso, e o router também pode dizer:
// src/routes/leitores.routes.js
import { Router } from 'express';
import * as controller from '../controllers/leitores.controller.js';
import emprestimosRotas from './emprestimos.routes.js';
import exigirCracha from '../middlewares/exigirCracha.js';
const router = Router();
router.use('/:leitorId/emprestimos', emprestimosRotas);
router.get('/', controller.listar);
router.get('/:id', controller.mostrar);
router.post('/', exigirCracha('cadastra leitor'), controller.criar);
export default router;O router de empréstimos, então, só cuida do que vem depois:
// src/routes/emprestimos.routes.js
import { Router } from 'express';
import * as controller from '../controllers/emprestimos.controller.js';
const router = Router({ mergeParams: true });
router.get('/', controller.listarDoLeitor);
router.post('/', controller.criar);
router.post('/:id/devolucao', controller.devolver);
export default router;Aquele { mergeParams: true } não é enfeite. Por padrão, cada router só enxerga
os parâmetros dos próprios caminhos — os do prefixo em que ele foi montado
ficam de fora. Montei os dois lado a lado para medir a diferença:
import express, { Router } from 'express';
const semMerge = Router();
semMerge.get('/', (req, res) => res.json({ params: req.params }));
const comMerge = Router({ mergeParams: true });
comMerge.get('/', (req, res) => res.json({ params: req.params }));
const app = express();
app.use('/leitores/:leitorId/sem-merge', semMerge);
app.use('/leitores/:leitorId/com-merge', comMerge);
app.listen(3030);curl -s http://localhost:3030/leitores/1/sem-merge
curl -s http://localhost:3030/leitores/1/com-mergeObjeto vazio contra objeto com o leitorId dentro. E o estrago no aplicativo
real é silencioso: trocando Router({ mergeParams: true }) por Router() em
emprestimos.routes.js, req.params.leitorId vira undefined, Number(undefined)
vira NaN, e a busca pelo leitor não acha ninguém:
curl -s -w ' [%{http_code}]\n' http://localhost:3030/leitores/1/emprestimosUm 404 que mente. O leitor 1 existe; quem sumiu foi o parâmetro. Quando uma
rota aninhada devolve “não encontrado” para um id que você acabou de listar,
mergeParams é o primeiro lugar para olhar — antes disso, confira também se o
parâmetro está mesmo onde você acha que está, em
req.params, req.query e req.body.
Um arquivo de rotas que carrega todos os outros
Falta juntar tudo. Em vez de o app.js importar quatro routers, existe um
routes/index.js que só faz a montagem:
// src/routes/index.js
import { Router } from 'express';
import livrosRotas from './livros.routes.js';
import leitoresRotas from './leitores.routes.js';
const rotas = Router();
rotas.use('/livros', livrosRotas);
rotas.use('/leitores', leitoresRotas);
export default rotas;E o app.js monta esse único router, cercado pelos middlewares globais e pelo
404 do fim:
// src/app.js
import express from 'express';
import rotas from './routes/index.js';
import registrarAcesso from './middlewares/registrarAcesso.js';
const app = express();
app.use(express.json());
app.use(registrarAcesso);
app.use(rotas);
app.use((req, res) => {
res.status(404).json({ erro: `Rota ${req.method} ${req.path} não existe` });
});
export default app;Repare que o app.js não chama listen. Quem sobe o servidor é o server.js,
com sete linhas:
// src/server.js
import app from './app.js';
const PORTA = process.env.PORT ?? 3030;
app.listen(PORTA, () => {
console.log(`Biblioteca Vila Maria no ar em http://localhost:${PORTA}`);
});Essa separação parece cerimônia até o dia do primeiro teste automatizado: o
teste importa app.js e nunca ocupa uma porta.
O ganho aparece quando chega um recurso novo. A biblioteca decidiu reservar
salas de estudo. Criei routes/salas.routes.js e controllers/salas.controller.js,
e o routes/index.js mudou assim:
import livrosRotas from './livros.routes.js';
import leitoresRotas from './leitores.routes.js';
+import salasRotas from './salas.routes.js';
const rotas = Router();
rotas.use('/livros', livrosRotas);
rotas.use('/leitores', leitoresRotas);
+rotas.use('/salas', salasRotas);Duas linhas em arquivo existente, e nenhuma linha tocada em livros, leitores ou empréstimos:
curl -s -X POST -H 'x-cracha: bibliotecario' http://localhost:3030/salas/1/reserva
curl -s http://localhost:3030/livros/2Antes e depois: o mesmo projeto em duas árvores de arquivo
O arquivo único virou isto:
npx tree-cli -l 4E aqui vem o número que ninguém mostra, porque ele não é o que a gente espera:
find src -type f -name '*.js' | sort | xargs wc -lModularizar aumentou o total de linhas: 150 viraram 264. Os import, os
export e as assinaturas de função custam. Quem prometer que separar em
arquivos “reduz o código” está vendendo outra coisa.
O que caiu foi o tamanho do arquivo que você precisa abrir. O maior arquivo do projeto tem 36 linhas; a mediana fica em 16,5. Para mexer no cálculo de devolução, você abre um arquivo de 36 linhas em vez de caçar a linha 130 de um de 150 — e daqui a um ano, de um de 600.
A prova que fecha a refatoração: 20 rotas, zero diferença
Refatoração sem verificação é reescrita torcendo. Antes de mexer no código, escrevi um script que bate em 20 rotas — sucesso, erro de validação, 403 sem crachá, 404 de recurso inexistente, 409 de devolução repetida — e grava status e corpo de cada uma:
B=http://localhost:3030
bater() {
local metodo=$1 caminho=$2
shift 2
echo "--- $metodo $caminho"
curl -s -o /tmp/corpo.txt -w '%{http_code}\n' -X "$metodo" "$B$caminho" "$@"
cat /tmp/corpo.txt
echo
}
bater GET /livros
bater POST /livros -H 'content-type: application/json' -d '{"titulo":"Angústia","autor":"Graciliano Ramos"}'
bater POST /leitores/1/emprestimos/2/devolucao
# ... 17 outrasRodei contra o index.js de 150 linhas, guardei em antes.txt. Rodei contra os
14 arquivos, guardei em depois.txt. Aí:
diff antes.txt depois.txt
echo "diff -> $?"Nenhuma linha de diferença em 20 requisições. É isso que separa refatoração de reescrita: o cliente da API não tem como perceber que você mudou alguma coisa.
Catorze arquivos sobem mais devagar?
Essa pergunta aparece toda vez, e ela merece número em vez de opinião. Medi o
tempo entre o início do processo e o callback do listen, com
performance.now() dentro dele, em 11 execuções de cada versão, descartando a
primeira (aquecimento):
| versão | mediana | mínimo | máximo |
|---|---|---|---|
| 1 arquivo, 150 linhas | 45,0 ms | 39,8 ms | 61,1 ms |
| 14 arquivos, 264 linhas | 47,5 ms | 43,6 ms | 53,5 ms |
Medido num MacBook, Node 24.16.0 com Express 5.2.1, 10 execuções válidas por versão. A diferença de mediana é de 2,5 ms — e os intervalos se sobrepõem quase inteiros, o que quer dizer que boa parte disso é ruído da máquina, não custo dos arquivos. Resolver 13 módulos a mais custa menos que uma consulta ao banco. Organize por legibilidade; o boot aguenta.
O que vem depois
Você tem a API dividida por recurso, com prefixo, middleware de grupo e rota
aninhada funcionando. Ainda sobrou repetição: cada controller escreve o próprio
res.status(404).json(...). O passo seguinte é jogar tudo isso num
handler de erro centralizado, onde o
controller só precisa avisar que deu errado.
Se alguma dessas rotas está quebrando antes de você chegar lá, volte para Express do zero e confira a base. Para ver onde esta lição entra no caminho inteiro, o guia de Node.js mostra a ordem, e a trilha de Node lista as lições na sequência.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Preciso mesmo de controller e service, ou só o arquivo de rotas basta?
Posso ter mais de um Router no mesmo arquivo?
O nome do arquivo precisa ser livros.routes.js?
Dá para importar um Router dentro de outro Router mais de um nível?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 com Express 5.2.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Express 5 — API Reference: Router — expressjs.com
- Express — Routing guide — expressjs.com



