Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

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.

Rodolfo Mori11 min de leitura

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:

bash
mkdir padaria-api
cd padaria-api
npm init -y
Wrote to /private/tmp/padaria-api/package.json:

{ “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:

bash
npm pkg set type=module
npm i express
added 68 packages, and audited 69 packages in 453ms

27 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:

bash
npm ls express
padaria-api@1.0.0 /private/tmp/padaria-api └── express@5.2.1

Guarde 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:

(node:61919) Warning: Failed to load the ES module: /private/tmp/padaria-api/servidor.js. Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension. (Use `node --trace-warnings ...` to show where the warning was created) /private/tmp/padaria-api/servidor.js:1 import express from 'express'; ^^^^^^

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:

node:internal/modules/package_json_reader:301 throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null); ^

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:

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');
});
bash
node servidor.js
Servidor ouvindo em http://localhost:3200

O cursor não volta. Isso é o certo: o processo está vivo, esperando conexão. Em outro terminal, peça a página:

bash
curl -i http://localhost:3200/
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: text/html; charset=utf-8 Content-Length: 31 ETag: W/"1f-wBpU+4nxpzcRcunU/F3MfdywcMk" Date: Sat, 22 Aug 2026 22:51:00 GMT Connection: keep-alive Keep-Alive: timeout=5

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 um GET no caminho /, chame esta função. O req é o pedido, o res é 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:

js
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');
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:

bash
curl -i http://localhost:3200/produtos
HTTP/1.1 404 Not Found X-Powered-By: Express Content-Security-Policy: default-src 'none' X-Content-Type-Options: nosniff Content-Type: text/html; charset=utf-8 Content-Length: 147 Date: Sat, 22 Aug 2026 22:51:00 GMT Connection: keep-alive Keep-Alive: timeout=5

<!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.

js
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>`);
});
bash
curl -i http://localhost:3200/produtos
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 190 ETag: W/"be-EromempV1Pyrk7AOGUFUIDA8A5I" Date: Sat, 22 Aug 2026 22:51:12 GMT Connection: keep-alive Keep-Alive: timeout=5

[{“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”}]

bash
curl -i http://localhost:3200/cardapio
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: text/html; charset=utf-8 Content-Length: 102 ETag: W/"66-qy1gZ/LNnnx0IBPhz7EM5gM0oLY" Date: Sat, 22 Aug 2026 22:51:12 GMT Connection: keep-alive Keep-Alive: timeout=5

<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:

bash
curl -s -i -H 'If-None-Match: W/"be-EromempV1Pyrk7AOGUFUIDA8A5I"' \
  http://localhost:3200/produtos | head -5
HTTP/1.1 304 Not Modified X-Powered-By: Express ETag: W/"be-EromempV1Pyrk7AOGUFUIDA8A5I" Date: Sat, 22 Aug 2026 23:02:36 GMT Connection: keep-alive

304 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:

bash
node --watch servidor.js
Servidor ouvindo em http://localhost:3200

Com o servidor no ar, acrescente uma rota nova ao arquivo e salve:

js
app.get('/horarios', (req, res) => {
  res.json({ abre: '06:00', fecha: '20:00', domingo: '06:00 as 13:00' });
});
Servidor ouvindo em http://localhost:3200 Restarting 'servidor.js' Servidor ouvindo em http://localhost:3200
bash
curl http://localhost:3200/horarios
{"abre":"06:00","fecha":"20:00","domingo":"06:00 as 13:00"}

Ningué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:

Restarting 'servidor.js' file:///private/tmp/padaria-api/servidor.js:22 }); ^

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:

js
const PORT = process.env.PORT ?? 3200;

app.listen(PORT, () => {
  console.log(`Servidor ouvindo em http://localhost:${PORT}`);
});
bash
node servidor.js
# ctrl+C para derrubar, e de novo com a porta vinda do ambiente
PORT=4010 node servidor.js
Servidor ouvindo em http://localhost:3200 Servidor ouvindo em http://localhost:4010

O 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:

js
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);
});
bash
curl -i -X POST http://localhost:3200/encomendas \
  -H 'Content-Type: application/json' \
  -d '{"cliente":"Dona Marli","item":"Bolo de fubá","quantidade":2}' | head -6
HTTP/1.1 500 Internal Server Error X-Powered-By: Express Content-Security-Policy: default-src 'none' X-Content-Type-Options: nosniff Content-Type: text/html; charset=utf-8 Content-Length: 1153

E no terminal do servidor:

req.body = undefined TypeError: Cannot destructure property 'cliente' of 'req.body' as it is undefined. at file:///private/tmp/padaria-api/servidor.js:29:11 at Layer.handleRequest (/private/tmp/padaria-api/node_modules/router/lib/layer.js:152:17) at next (/private/tmp/padaria-api/node_modules/router/lib/route.js:157:13) at Route.dispatch (/private/tmp/padaria-api/node_modules/router/lib/route.js:117:3)

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:

js
const app = express();

app.use(express.json());
bash
curl -i -X POST http://localhost:3200/encomendas \
  -H 'Content-Type: application/json' \
  -d '{"cliente":"Dona Marli","item":"Bolo de fubá","quantidade":2}'
HTTP/1.1 201 Created X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 69 ETag: W/"45-e5yjHddZyrL5YJVN6XKgb2wCm/Y" Date: Sat, 22 Aug 2026 22:52:12 GMT Connection: keep-alive Keep-Alive: timeout=5

{“id”:1,“cliente”:“Dona Marli”,“item”:“Bolo de fubá”,“quantidade”:2}

req.body = { 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:

bash
curl -i -X POST http://localhost:3200/encomendas \
  -d '{"cliente":"Seu Jorge","item":"Pão francês","quantidade":10}'
req.body = undefined TypeError: Cannot destructure property 'cliente' of 'req.body' as it is undefined. at file:///private/tmp/padaria-api/servidor.js:35:11 at next (/private/tmp/padaria-api/node_modules/router/index.js:291:5) at read (/private/tmp/padaria-api/node_modules/body-parser/lib/read.js:62:5)

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:

text
padaria-api/
├── .gitignore
├── package.json
├── package-lock.json
└── src
    ├── app.js
    └── servidor.js

src/app.js monta o app, registra middlewares e rotas e exporta — sem abrir porta nenhuma:

js
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:

js
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:

json
{
  "type": "module",
  "scripts": {
    "start": "node src/servidor.js",
    "dev": "node --watch src/servidor.js"
  },
  "dependencies": {
    "express": "^5.2.1"
  }
}
bash
npm run dev
> padaria-api@1.0.0 dev > node --watch src/servidor.js

Servidor 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:

bash
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/encomendas
{"id":1,"cliente":"Dona Marli","item":"Bolo de fubá","quantidade":2} [{"id":1,"cliente":"Dona Marli","item":"Bolo de fubá","quantidade":2}] []

A 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:

bash
for r in / /produtos /encomendas /cardapio; do
  curl -s -o /dev/null -w "%{http_code}  %{content_type}  $r\n" "http://localhost:3200$r"
done
200 text/html; charset=utf-8 / 200 application/json; charset=utf-8 /produtos 200 application/json; charset=utf-8 /encomendas 404 text/html; charset=utf-8 /cardapio

A ú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:

bash
curl -i -X POST http://localhost:3200/produtos | head -3
HTTP/1.1 404 Not Found X-Powered-By: Express Content-Security-Policy: default-src 'none'

POST 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.

Ver todos os vídeos do canal
  • express
  • node
  • rota
  • api
  • npm

Perguntas frequentes

Preciso instalar o nodemon para o servidor reiniciar sozinho?
Não. O Node traz a flag --watch desde a versão 18, estável a partir da 22. Ela reinicia o processo quando o arquivo muda e não adiciona dependência nenhuma ao projeto.
Qual a diferença entre res.send e res.json?
res.send olha o que você passou e decide o Content-Type; com string ele manda text/html. res.json sempre serializa com JSON.stringify e sempre manda application/json, mesmo quando o valor é null ou um número.
Dá para usar require em vez de import com o Express?
Dá, se o package.json não tiver "type":"module". Mas projeto novo em 2026 nasce em ESM: é o formato que o Node documenta primeiro e o que você vai encontrar na documentação do próprio Express.
Por que o npm instala 68 pacotes para um Express só?
O Express 5 é uma casca fina que delega quase tudo: roteamento, leitura de corpo, negociação de tipo e tratamento de erro vivem em pacotes separados, mantidos pelo mesmo time. Instalar um puxa a árvore inteira.
Posso deixar as rotas todas em um arquivo só?
No começo, sim. A conta vira ruim por volta de duzentas linhas ou quando dois recursos diferentes disputam o mesmo arquivo — aí entra o express.Router, que separa por recurso sem mudar nada do que você já escreveu.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. Express — Hello world example — expressjs.com
  2. Express 5 — express.json() — expressjs.com
  3. Node.js CLI — a flag --watch — nodejs.org

Continue por aqui