Express do zero: instalar, subir o servidor e criar a rota
Instalar o Express, criar o app, responder na primeira rota e entender o que o app.listen faz, com o servidor subindo no terminal de verdade.
O Express é uma camada fina em cima do servidor HTTP do Node: ele guarda uma
lista de rotas e chama a sua função quando a requisição bate no caminho certo.
Três linhas põem uma API no ar — criar o app, registrar uma rota e chamar
app.listen.
O projeto desta lição é a API da Padaria da Esquina: ela precisa publicar o cardápio do dia e receber encomendas pelo site. Tudo que aparece dentro de um bloco de saída aqui saiu do terminal de uma máquina real, um MacBook rodando Node 24.16.0 e Express 5.2.1.
Uma recepção com uma lista de endereços
Pense na API como a recepção da padaria. Atrás do balcão existe uma lista: quem
pedir o endereço /cardapio fala com uma função; quem enviar um POST para
/encomendas fala com outra. Quando método e caminho não batem, a recepção não
encontra atendimento para aquele pedido.
No Express, essa lista é a tabela de rotas do app. Cada registro combina
método HTTP, caminho e handler; listen abre a porta de rede por onde os
pedidos chegam. Depois de subir o primeiro servidor, faça duas chamadas ao mesmo
caminho com métodos diferentes e compare os status: essa diferença confirma
que rota não é só URL.
O Express é um pacote do npm como qualquer outro. O projeto começa vazio:
mkdir padaria-api
cd padaria-api
npm init -y{ “name”: “padaria-api”, “version”: “1.0.0”, “description”: “”, “main”: “index.js”, “scripts”: { “test”: “echo "Error: no test specified" && exit 1” }, “keywords”: [], “author”: “”, “license”: “ISC”, “type”: “commonjs” }
Repare na última linha: "type": "commonjs". É o padrão do npm init, e é o
que faz import explodir. Antes de instalar qualquer coisa, troque:
npm pkg set type=module
npm i express27 packages are looking for funding
run npm fund for details
found 0 vulnerabilities
Sessenta e oito pacotes para um framework só. Não é desperdício: o Express 5 delega roteamento, leitura de corpo e negociação de tipo para pacotes separados do mesmo time. Se quiser ver qual versão entrou de verdade:
npm ls expressGuarde esse número. Quase todo tutorial antigo na internet fala do Express 4, e
uma diferença de comportamento entre a 4 e a 5 aparece ainda nesta lição — na
seção do express.json(), que é onde ela costuma morder.
Se você pular o npm pkg set type=module, o primeiro node servidor.js
devolve isto:
SyntaxError: Cannot use import statement outside a module at wrapSafe (node:internal/modules/cjs/loader:1787:18) at Module._compile (node:internal/modules/cjs/loader:1828:20)
E se você criar o arquivo antes de instalar o pacote, o erro é outro:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package ‘express’ imported from /private/tmp/padaria-api/servidor.js at Object.getPackageJSONURL (node:internal/modules/package_json_reader:301:9)
São duas mensagens diferentes para dois problemas diferentes: a primeira é
formato de módulo, a segunda é dependência que falta. Vale conhecer as duas de
cara — a segunda tem uma família inteira de causas, reunidas em
Cannot find module no Node. Se o campo
type ainda soa estranho, a diferença entre os dois formatos está em
ESM ou CommonJS no Node, e o papel de cada campo
do arquivo está em npm e package.json.
app, rota e listen: as três linhas obrigatórias
Crie servidor.js:
import express from 'express';
const app = express();
app.get('/', (req, res) => {
res.send('API da Padaria da Esquina no ar');
});
app.listen(3200, () => {
console.log('Servidor ouvindo em http://localhost:3200');
});node servidor.jsO cursor não volta. Isso é o certo: o processo está vivo, esperando conexão. Em outro terminal, peça a página:
curl -i http://localhost:3200/API da Padaria da Esquina no ar
Três coisas aconteceram, e cada uma tem um nome:
express()cria o app — uma função que sabe receber requisição e devolver resposta, e que carrega dentro dela a lista de rotas.app.get('/', handler)registra uma rota: quando chegar umGETno caminho/, chame esta função. Oreqé o pedido, oresé a resposta.app.listen(3200)abre a porta. Sem essa linha, nada escuta nada.
Não é força de expressão. O mesmo arquivo sem o app.listen roda e termina:
import express from 'express';
const app = express();
app.get('/', (req, res) => {
res.send('API da Padaria da Esquina no ar');
});
console.log('cheguei ao fim do arquivo');O prompt voltou na hora. É o listen que segura o processo de pé — ele deixa um
socket aberto, e o Node só encerra quando não sobra nada para esperar. Quem vem
do servidor HTTP puro do Node reconhece a
cena: por baixo, app.listen é um http.createServer(app).listen(...).
Qualquer caminho que você não registrou responde 404, e o Express escreve a resposta sozinho:
curl -i http://localhost:3200/produtos<!DOCTYPE html> <html lang=“en”> <head> <meta charset=“utf-8”> <title>Error</title> </head> <body> <pre>Cannot GET /produtos</pre> </body> </html>
Cannot GET /produtos é a frase que mais aparece na primeira semana de Express.
Ela quase nunca significa “o servidor caiu”: significa que o método e o caminho
que você pediu não batem com nenhuma rota registrada.
Respondendo texto, JSON e HTML na mesma API
Uma API de padaria precisa de duas coisas: uma lista de produtos em JSON, para o site consumir, e um cardápio legível, para colar na tela do balcão.
const produtos = [
{ id: 1, nome: 'Pão francês', preco: 0.9, unidade: 'unidade' },
{ id: 2, nome: 'Sonho de creme', preco: 6.5, unidade: 'unidade' },
{ id: 3, nome: 'Bolo de fubá', preco: 24.9, unidade: 'kg' },
];
app.get('/produtos', (req, res) => {
res.json(produtos);
});
app.get('/cardapio', (req, res) => {
const itens = produtos.map((p) => `<li>${p.nome}</li>`).join('');
res.send(`<h2>Cardápio de hoje</h2><ul>${itens}</ul>`);
});curl -i http://localhost:3200/produtos[{“id”:1,“nome”:“Pão francês”,“preco”:0.9,“unidade”:“unidade”},{“id”:2,“nome”:“Sonho de creme”,“preco”:6.5,“unidade”:“unidade”},{“id”:3,“nome”:“Bolo de fubá”,“preco”:24.9,“unidade”:“kg”}]
curl -i http://localhost:3200/cardapio<h2>Cardápio de hoje</h2><ul><li>Pão francês</li><li>Sonho de creme</li><li>Bolo de fubá</li></ul>
Compare o Content-Type das duas respostas. Você não escreveu esse cabeçalho em
lugar nenhum: res.json sempre manda application/json e serializa com
JSON.stringify; res.send olha o que recebeu e, para string, assume
text/html. É por isso que a rota / do exemplo anterior, que devolvia texto
simples, também saiu como text/html.
O ETag e o Content-Length também vieram de graça. O ETag é a impressão
digital do corpo. Devolva esse mesmo valor no cabeçalho If-None-Match e a
resposta muda:
curl -s -i -H 'If-None-Match: W/"be-EromempV1Pyrk7AOGUFUIDA8A5I"' \
http://localhost:3200/produtos | head -5304 Not Modified, sem corpo nenhum: o cliente já tem essa versão. Você não
programou isso — veio junto com o res.json.
node –watch: reiniciar sozinho, sem nodemon
Toda vez que você muda o arquivo, precisa parar e subir o servidor de novo. Por
anos a resposta foi instalar o nodemon. Desde o Node 22 isso é uma flag:
node --watch servidor.jsCom o servidor no ar, acrescente uma rota nova ao arquivo e salve:
app.get('/horarios', (req, res) => {
res.json({ abre: '06:00', fecha: '20:00', domingo: '06:00 as 13:00' });
});curl http://localhost:3200/horariosNinguém apertou nada entre salvar e responder. O --watch mata o processo,
sobe outro e imprime o Restarting. Quando o arquivo salvo tem erro de sintaxe,
ele não deixa você no escuro:
SyntaxError: Unexpected token ‘)’ at compileSourceTextModule (node:internal/modules/esm/utils:318:16) at ModuleLoader.moduleStrategy (node:internal/modules/esm/translators:90:18) at #translate (node:internal/modules/esm/loader:435:20)
Node.js v24.16.0 Failed running ‘servidor.js’. Waiting for file changes before restarting…
Failed running em vez de Completed running: o processo morreu e o watcher
continua vivo, esperando você consertar. Salve de novo com o código correto e
ele sobe sem que você toque no terminal.
A porta fixa no código ou vinda do ambiente
O 3200 cravado no meio do arquivo funciona na sua máquina e falha no deploy,
porque quem hospeda escolhe a porta e informa por variável de ambiente. A
correção é de uma linha:
const PORT = process.env.PORT ?? 3200;
app.listen(PORT, () => {
console.log(`Servidor ouvindo em http://localhost:${PORT}`);
});node servidor.js
# ctrl+C para derrubar, e de novo com a porta vinda do ambiente
PORT=4010 node servidor.jsO número fixo virou padrão de desenvolvimento, e o ambiente manda quando quer.
Repare que usei ?? e não ||: com ||, uma variável valendo 0 ou string
vazia cairia no padrão sem você perceber.
Sobre o número em si: a porta 3000 é a que todo tutorial de Express usa, e é
exatamente por isso que ela vive ocupada. Na máquina em que escrevi esta lição
já havia outro projeto nela, e o Node avisa com EADDRINUSE: address already in use. Escolher 3200 para a padaria custou nada e evitou o conflito. Como o
.env entra nessa história é assunto de
variáveis de ambiente no Node.
express.json(): por que req.body chega undefined sem ele
Publicar cardápio é metade do trabalho. A padaria precisa receber encomenda,
e isso é um POST com corpo:
app.post('/encomendas', (req, res) => {
console.log('req.body =', req.body);
const { cliente, item, quantidade } = req.body;
const encomenda = { id: encomendas.length + 1, cliente, item, quantidade };
encomendas.push(encomenda);
res.status(201).json(encomenda);
});curl -i -X POST http://localhost:3200/encomendas \
-H 'Content-Type: application/json' \
-d '{"cliente":"Dona Marli","item":"Bolo de fubá","quantidade":2}' | head -6E no terminal do servidor:
O corpo chegou pela rede — o curl mandou —, mas o Express não leu. Ele não lê
corpo por padrão: a requisição chega como um fluxo de bytes e alguém precisa
juntar os pedaços e decidir que aquilo é JSON. Esse alguém é o express.json(),
registrado uma vez, antes das rotas:
const app = express();
app.use(express.json());curl -i -X POST http://localhost:3200/encomendas \
-H 'Content-Type: application/json' \
-d '{"cliente":"Dona Marli","item":"Bolo de fubá","quantidade":2}'{“id”:1,“cliente”:“Dona Marli”,“item”:“Bolo de fubá”,“quantidade”:2}
Agora a parte que pega mesmo quem já sabe disso. Com o express.json() no
lugar, mande o mesmo POST sem o cabeçalho Content-Type:
curl -i -X POST http://localhost:3200/encomendas \
-d '{"cliente":"Seu Jorge","item":"Pão francês","quantidade":10}'Mesma mensagem, mas olhe a última linha da pilha: agora ela passa por
body-parser/lib/read.js. O express.json() rodou, olhou o Content-Type que
o curl mandou por padrão e decidiu que aquilo não era JSON — então não tocou
no req.body. No Express 4 ele ficava {} nesse caso; no 5 ele fica
undefined. Se um tutorial antigo prometeu objeto vazio, é essa a mudança.
A estrutura de pastas que vale desde o primeiro dia
Com quatro rotas o arquivo único já incomoda. A divisão mínima que resolve por muito tempo separa o que a API é do como ela sobe:
padaria-api/
├── .gitignore
├── package.json
├── package-lock.json
└── src
├── app.js
└── servidor.jssrc/app.js monta o app, registra middlewares e rotas e exporta — sem abrir
porta nenhuma:
import express from 'express';
export const app = express();
app.use(express.json());
const encomendas = [];
const produtos = [
{ id: 1, nome: 'Pão francês', preco: 0.9, unidade: 'unidade' },
{ id: 2, nome: 'Sonho de creme', preco: 6.5, unidade: 'unidade' },
{ id: 3, nome: 'Bolo de fubá', preco: 24.9, unidade: 'kg' },
];
app.get('/', (req, res) => {
res.send('API da Padaria da Esquina no ar');
});
app.get('/produtos', (req, res) => {
res.json(produtos);
});
app.get('/encomendas', (req, res) => {
res.json(encomendas);
});
app.post('/encomendas', (req, res) => {
const { cliente, item, quantidade } = req.body;
const encomenda = { id: encomendas.length + 1, cliente, item, quantidade };
encomendas.push(encomenda);
res.status(201).json(encomenda);
});src/servidor.js só liga a chave:
import { app } from './app.js';
const PORT = process.env.PORT ?? 3200;
app.listen(PORT, () => {
console.log(`Servidor ouvindo em http://localhost:${PORT}`);
});Essa separação não é frescura de arquitetura: teste automatizado importa o app
e faz requisição sem abrir porta, e é isso que evita que a sua suíte trave
tentando escutar duas vezes na mesma porta. Feche registrando os dois comandos
no package.json:
{
"type": "module",
"scripts": {
"start": "node src/servidor.js",
"dev": "node --watch src/servidor.js"
},
"dependencies": {
"express": "^5.2.1"
}
}npm run devServidor ouvindo em http://localhost:3200
Uma última coisa sobre esse array encomendas: ele vive na memória do processo.
Mande um POST, confira, salve o arquivo para o --watch reiniciar e confira
de novo:
curl -X POST http://localhost:3200/encomendas \
-H 'Content-Type: application/json' \
-d '{"cliente":"Dona Marli","item":"Bolo de fubá","quantidade":2}'
curl http://localhost:3200/encomendas
# salva o arquivo, o --watch reinicia
curl http://localhost:3200/encomendasA encomenda da Dona Marli evaporou no reinício. Não é bug: é o lembrete de que array em memória é rascunho, e que banco de dados entra na história assim que a API precisa lembrar de alguma coisa entre dois deploys.
Confirmando pelo curl antes de abrir o navegador
O navegador é péssimo cliente de API. Ele só faz GET na barra de endereço,
esconde os cabeçalhos, formata o JSON do jeito dele e ainda pede um
/favicon.ico que suja o seu log. O curl mostra o que realmente trafegou.
| o que você quer saber | comando | o que aparece |
|---|---|---|
| se subiu | curl http://localhost:3200/ |
só o corpo da resposta |
| qual status e qual tipo | curl -i http://localhost:3200/produtos |
os cabeçalhos antes do corpo |
| um resumo de várias rotas | curl -s -o /dev/null -w "%{http_code} %{content_type}\n" ... |
uma linha por rota |
| se o POST funciona | curl -X POST -H 'Content-Type: application/json' -d '{...}' |
a resposta do verbo certo |
Aquela terceira linha é a que eu mais uso para conferir uma API inteira de enfiada:
for r in / /produtos /encomendas /cardapio; do
curl -s -o /dev/null -w "%{http_code} %{content_type} $r\n" "http://localhost:3200$r"
doneA última linha é a prova de que isso serve para alguma coisa: a rota /cardapio
ficou para trás quando movi o código para src/app.js, e eu só descobri aqui.
No navegador ela apareceria como uma página de erro qualquer, fácil de confundir
com “o servidor caiu”; nesta lista, um 404 no meio de três 200 diz
exatamente o que houve.
E o teste que mais economiza tempo é mandar o verbo errado de propósito:
curl -i -X POST http://localhost:3200/produtos | head -3POST em rota registrada como GET dá 404, não 405. Método e caminho formam a
chave da rota no Express: mudar um dos dois é procurar outra rota. Guarde isso,
porque é a causa de metade dos Cannot POST que aparecem quando o formulário do
front começa a conversar com a API.
O que vem depois
A API responde, recebe corpo e reinicia sozinha. Falta ela distinguir quem
está pedindo o quê — o id na URL, o filtro na query string, o corpo no POST —,
e é isso que vem em
req.params, req.query e req.body no Express.
Depois disso, express.Router divide as rotas por recurso e o tratamento de
erro deixa de ser um try/catch por rota. O caminho inteiro, na ordem, está na
trilha de Node.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Preciso instalar o nodemon para o servidor reiniciar sozinho?
Qual a diferença entre res.send e res.json?
Dá para usar require em vez de import com o Express?
Por que o npm instala 68 pacotes para um Express só?
Posso deixar as rotas todas em um arquivo só?
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 — Hello world example — expressjs.com
- Express 5 — express.json() — expressjs.com
- Node.js CLI — a flag --watch — nodejs.org



