Criar um servidor HTTP no Node sem framework nenhum
Subir um servidor com node:http, ler método e URL, responder JSON com o status certo e entender por que o Express existe depois de 60 linhas.
O Node já traz um servidor HTTP dentro dele. São cinco linhas, uma chamada de
createServer e uma porta: nenhum npm install, nenhum framework. O Express
resolve o que vem depois disso — e este artigo mostra exatamente o que é
esse “depois”, medindo em linhas de código.
Todos os exemplos são a agenda da Clínica Veterinária Pata Amiga: consultas
com pet, tutor e horário. A mesma API vai nascer crua no node:http e depois
renascer em Express, para você comparar os dois arquivos lado a lado.
Pense numa recepção com senha e protocolo
Um servidor HTTP se parece com a recepção de uma clínica. Alguém chega com um
pedido, a recepção lê o tipo de atendimento e o nome marcado na senha, encaminha
para o lugar certo e devolve uma resposta. No código, o pedido é a
requisição (req); o papel devolvido é a resposta (res); método e URL
dizem qual atendimento foi solicitado.
A comparação não significa que cada paciente ganha uma pessoa nova. O Node
recebe vários pedidos pelo mesmo processo e chama sua função para cada um.
Enquanto você lê o primeiro exemplo, localize quatro peças: quem abre a
recepção (createServer), quem atende (callback), onde ela escuta (porta) e
qual resposta encerra o atendimento (res.end). Se uma delas faltar, a clínica
pode até estar aberta, mas o pedido não termina.
createServer: cinco linhas e a clínica já responde
Crie servidor.mjs. A extensão .mjs deixa o import funcionar sem mexer no
package.json.
import { createServer } from 'node:http';
const servidor = createServer((req, res) => {
res.end('Clinica Pata Amiga no ar\n');
});
servidor.listen(3777, () => {
console.log('Servidor ouvindo em http://localhost:3777');
});A função que você passa para createServer é chamada uma vez por
requisição. Ela recebe dois objetos: req, com o que o cliente pediu, e
res, onde você escreve a resposta. listen é o que prende o processo na
porta — sem ele, o Node executa o arquivo e sai.
node servidor.mjs
curl -i http://localhost:3777/HTTP/1.1 200 OK Date: Sat, 22 Aug 2026 19:28:11 GMT Connection: keep-alive Keep-Alive: timeout=5 Content-Length: 25
Clinica Pata Amiga no ar
Repare no que não está ali: não existe Content-Type. O Node não adivinha o
tipo do que você mandou. O 200 OK e o Content-Length ele calcula sozinho; o
resto é trabalho seu. Guarde essa ausência, porque ela vai voltar a doer daqui
a pouco.
req e res crus: o que chega e o que você precisa escrever
Antes de rotear, vale olhar o que o Node entrega em req. Nada aqui é mágico:
são os campos da primeira linha da requisição HTTP mais os cabeçalhos.
import { createServer } from 'node:http';
const servidor = createServer((req, res) => {
console.log('method :', req.method);
console.log('url :', req.url);
console.log('httpVersion:', req.httpVersion);
console.log('host :', req.headers.host);
console.log('user-agent :', req.headers['user-agent']);
res.end('ok\n');
});
servidor.listen(3777);curl -s "http://localhost:3777/consultas?dia=2026-06-17" -H "User-Agent: curl-do-artigo"Três detalhes que economizam muita dor de cabeça:
req.urlnão é a URL inteira. É só o caminho com a query string. Domínio e protocolo ficam de fora, porque o servidor já sabe quem ele é.req.headerstem as chaves em minúsculas, sempre, independentemente de como o cliente enviou.- Não existe
req.body. Nemreq.params, nemreq.query. Isso é vocabulário de framework — nonode:httpvocê monta cada um deles.
Roteando na mão: método, caminho e query string
Para separar /consultas de ?dia=2026-06-17 você não precisa de regex. A
classe URL, que já existe no Node, faz o trabalho — ela só exige uma base,
porque req.url é relativo.
const cru = '/consultas?dia=2026-06-17&veterinario=marina';
const url = new URL(cru, 'http://localhost:3777');
console.log('pathname:', url.pathname);
console.log('dia :', url.searchParams.get('dia'));
console.log('vet :', url.searchParams.get('veterinario'));
console.log('partes :', url.pathname.split('/').filter(Boolean));O split('/').filter(Boolean) é o truque que substitui o /consultas/:id do
Express: ele quebra o caminho em pedaços e joga fora as strings vazias das
barras. Com partes[0] você sabe o recurso, e com partes[1] você tem o id.
Roteamento em duas comparações.
Respondendo JSON: status, header e end — nessa ordem
Responder JSON é escrever três coisas na ordem certa. Primeiro o status e os cabeçalhos, depois o corpo, e nunca o contrário.
function enviarJson(res, status, corpo) {
const texto = JSON.stringify(corpo);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(texto),
});
res.end(texto);
}Dois pontos merecem atenção. O charset=utf-8 no Content-Type é o que faz
“Bruno Sá” chegar com acento no navegador. E o Content-Length usa
Buffer.byteLength, não texto.length: em UTF-8 um “á” ocupa dois bytes, e
declarar o número de caracteres em vez do número de bytes entrega uma resposta
truncada.
Se você não sabe qual número usar em cada situação, a lição sobre métodos HTTP e status code tem a tabela completa. Aqui bastam cinco: 200 para leitura, 201 para criação, 400 para corpo malformado, 404 para não achou e 405 para método errado no recurso certo.
O erro que derruba o servidor: responder duas vezes
Este é o erro número um de quem escreve rota na mão. O código responde, mas esquece de parar, e continua até tentar responder de novo:
const servidor = createServer((req, res) => {
if (req.url === '/consultas') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 1, pet: 'Nina' }]));
}
// faltou o return: a execução continua e tenta responder de novo
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ erro: 'rota nao encontrada' }));
});
servidor.listen(3777);O cliente até recebe o JSON certo. O servidor é que morre logo depois:
Error [ERR_HTTP_HEADERS_SENT]: Cannot write headers after they are sent to the client at ServerResponse.writeHead (node:_http_server:365:11) at Server.<anonymous> (file:///private/tmp/patamiga/duplo-envio.mjs:10:7) at Server.emit (node:events:509:28) at parserOnIncoming (node:_http_server:1226:12) at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17) { code: ‘ERR_HTTP_HEADERS_SENT’ }
Node.js v24.16.0
A exceção acontece fora do ciclo da requisição que já terminou, então
ninguém a captura e o processo inteiro cai. Em produção isso é o servidor
saindo do ar por causa de um return esquecido.
O corpo da requisição chega em pedaços
req é uma stream. O corpo do POST não vem pronto: ele chega em eventos data,
um pedaço por vez, e só termina no evento end. Este servidor mostra os pedaços
brutos:
import { createServer } from 'node:http';
createServer((req, res) => {
console.log('req.body vale:', req.body);
let recebido = 0;
let pedacos = 0;
req.on('data', (pedaco) => {
pedacos += 1;
recebido += pedaco.length;
console.log(`pedaço ${pedacos}: ${pedaco.length} bytes (é Buffer? ${Buffer.isBuffer(pedaco)})`);
});
req.on('end', () => {
console.log(`fim: ${pedacos} pedaços, ${recebido} bytes`);
res.end('recebido\n');
});
}).listen(3777);Mandando dois POSTs — um minúsculo e um com 3000 consultas — a diferença fica óbvia:
Corpo pequeno cabe num pedaço só. Corpo de 200 KB chegou em quatro, com o
tamanho ditado pelo buffer do sistema, não por você. É por isso que juntar os
pedaços com += numa string é uma armadilha: cada pedaço vira texto sozinho, e
um caractere de dois bytes partido na emenda vira �.
Enviei o mesmo corpo de 150.000 letras “é” seis vezes, comparando as duas formas de juntar:
req.on('data', (pedaco) => {
comoTexto += pedaco; // ingênuo: cada pedaço vira string sozinho
pedacos.push(pedaco); // correto: guarda os bytes
});
req.on('end', () => {
const comoBuffer = Buffer.concat(pedacos).toString('utf8');
console.log(
`${req.headers['x-teste']}: string=${comoTexto.length} chars`,
`| buffer=${comoBuffer.length} chars`,
`| iguais? ${comoTexto === comoBuffer}`,
);
});A versão em string ganhou quatro caracteres a mais — os quebrados nas emendas dos pedaços. E repare no envio 6: cinco. O mesmo corpo, resultado diferente, porque o corte depende da rede. Bug que muda de tamanho a cada requisição é exatamente o tipo que ninguém consegue reproduzir na segunda-feira.
A forma certa cabe numa promessa:
function lerCorpo(req) {
return new Promise((resolve, reject) => {
const pedacos = [];
req.on('data', (pedaco) => pedacos.push(pedaco));
req.on('end', () => resolve(Buffer.concat(pedacos).toString('utf8')));
req.on('error', reject);
});
}A API da Pata Amiga inteira, em 66 linhas
Juntando roteamento, JSON e leitura de corpo, a agenda da clínica vira este
arquivo. Ele atende GET /consultas, GET /consultas/:id e POST /consultas,
com 404 para rota inexistente e 405 para método errado.
import { createServer } from 'node:http';
const consultas = [
{ id: 1, pet: 'Nina', especie: 'gato', tutor: 'Ana Prado', horario: '09:00' },
{ id: 2, pet: 'Thor', especie: 'cachorro', tutor: 'Bruno Sá', horario: '10:30' },
];
let proximoId = 3;
function enviarJson(res, status, corpo) {
const texto = JSON.stringify(corpo);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(texto),
});
res.end(texto);
}
function lerCorpo(req) {
return new Promise((resolve, reject) => {
const pedacos = [];
req.on('data', (pedaco) => pedacos.push(pedaco));
req.on('end', () => resolve(Buffer.concat(pedacos).toString('utf8')));
req.on('error', reject);
});
}
const servidor = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const partes = url.pathname.split('/').filter(Boolean);
if (partes[0] === 'consultas' && partes.length === 1) {
if (req.method === 'GET') {
return enviarJson(res, 200, consultas);
}
if (req.method === 'POST') {
const bruto = await lerCorpo(req);
let dados;
try {
dados = JSON.parse(bruto);
} catch {
return enviarJson(res, 400, { erro: 'corpo não é JSON válido' });
}
if (!dados.pet || !dados.horario) {
return enviarJson(res, 422, { erro: 'pet e horario são obrigatórios' });
}
const nova = { id: proximoId++, ...dados };
consultas.push(nova);
res.setHeader('Location', `/consultas/${nova.id}`);
return enviarJson(res, 201, nova);
}
res.setHeader('Allow', 'GET, POST');
return enviarJson(res, 405, { erro: `método ${req.method} não permitido aqui` });
}
if (partes[0] === 'consultas' && partes.length === 2 && req.method === 'GET') {
const consulta = consultas.find((c) => c.id === Number(partes[1]));
if (!consulta) {
return enviarJson(res, 404, { erro: `consulta ${partes[1]} não existe` });
}
return enviarJson(res, 200, consulta);
}
enviarJson(res, 404, { erro: `rota ${req.method} ${url.pathname} não existe` });
});
servidor.listen(3777, () => console.log('API da Pata Amiga em http://localhost:3777'));Note o res.setHeader('Location', ...) antes do writeHead: cabeçalhos
definidos com setHeader sobrevivem, porque writeHead acrescenta os dele em
vez de apagar os anteriores. E o Allow: GET, POST no 405 não é enfeite — é o
que a especificação exige para essa resposta.
Os dados moram num array na memória. Isso é de propósito: banco é outra lição.
Se subir a porta por variável de ambiente for o seu próximo passo, a lição de
variáveis de ambiente no Node mostra
como trocar o 3777 fixo por process.env.PORT.
Conferindo com curl -i em vez do navegador
Barra de endereço só faz GET, esconde os cabeçalhos e ainda formata o JSON
com plugin. O curl -i mostra a resposta como ela é. Abaixo, cada rota da API
rodando de verdade — aqui e no resto do artigo, tirei das saídas só as linhas
de Date, Connection e Keep-Alive, que mudam a cada execução:
curl -i http://localhost:3777/consultas
curl -i http://localhost:3777/consultas/99
curl -i -X POST http://localhost:3777/consultas \
-H 'Content-Type: application/json' \
-d '{"pet":"Mel","especie":"coelho","tutor":"Carla Dias","horario":"14:00"}'
curl -i -X POST http://localhost:3777/consultas \
-H 'Content-Type: application/json' -d '{"pet":"Mel"}'
curl -i -X DELETE http://localhost:3777/consultas
curl -i http://localhost:3777/vacinas[{“id”:1,“pet”:“Nina”,“especie”:“gato”,“tutor”:“Ana Prado”,“horario”:“09:00”},{“id”:2,“pet”:“Thor”,“especie”:“cachorro”,“tutor”:“Bruno Sá”,“horario”:“10:30”}]
HTTP/1.1 404 Not Found Content-Type: application/json; charset=utf-8 Content-Length: 34
{“erro”:“consulta 99 não existe”}
HTTP/1.1 201 Created Location: /consultas/3 Content-Type: application/json; charset=utf-8 Content-Length: 78
{“id”:3,“pet”:“Mel”,“especie”:“coelho”,“tutor”:“Carla Dias”,“horario”:“14:00”}
HTTP/1.1 422 Unprocessable Entity Content-Type: application/json; charset=utf-8 Content-Length: 43
{“erro”:“pet e horario são obrigatórios”}
HTTP/1.1 405 Method Not Allowed Allow: GET, POST Content-Type: application/json; charset=utf-8 Content-Length: 45
{“erro”:“método DELETE não permitido aqui”}
HTTP/1.1 404 Not Found Content-Type: application/json; charset=utf-8 Content-Length: 40
{“erro”:“rota GET /vacinas não existe”}
Duas coisas ficam visíveis só assim: o Location: /consultas/3, que diz ao
cliente onde a consulta nova passou a morar, e o Allow: GET, POST, que
transforma um 405 vago em instrução. Nenhum dos dois aparece no navegador.
Servindo o HTML da agenda sem biblioteca nenhuma
Servir arquivo é ler do disco com fs/promises e acertar o Content-Type pela
extensão. O mapa é seu:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
const TIPOS = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
};
const RAIZ = new URL('./publico/', import.meta.url).pathname;
createServer(async (req, res) => {
const pedido = req.url === '/' ? '/index.html' : decodeURIComponent(req.url);
const arquivo = join(RAIZ, pedido);
if (!arquivo.startsWith(RAIZ)) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
return res.end(`403 — ${pedido} está fora da pasta publica\n`);
}
try {
const conteudo = await readFile(arquivo);
res.writeHead(200, { 'Content-Type': TIPOS[extname(arquivo)] ?? 'application/octet-stream' });
res.end(conteudo);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`404 — não existe ${pedido}\n`);
}
}).listen(3777);Aquele if de três linhas é a parte que ninguém lembra de escrever — e é a
mais importante. Sem ele, join resolve o .. do caminho pedido e o servidor
entrega qualquer arquivo do seu disco. Rodei a versão sem a guarda e pedi um
arquivo de fora da pasta:
curl -s --path-as-is 'http://localhost:3777/../api-express.mjs' | head -6const app = express(); app.use(express.json());
const consultas = [
O servidor entregou o próprio código-fonte. Com a guarda de volta, o mesmo pedido bate na porta certa:
403 — /../api-express.mjs está fora da pasta publica
Se ler e escrever arquivo no Node ainda é território novo, a lição de fs/promises e path cobre o assunto antes de você juntar as duas coisas.
As 33 linhas que o Express passa a escrever por você
Agora a mesma API, com as mesmas rotas e as mesmas regras, em Express 5:
import express from 'express';
const app = express();
app.use(express.json());
const consultas = [
{ id: 1, pet: 'Nina', especie: 'gato', tutor: 'Ana Prado', horario: '09:00' },
{ id: 2, pet: 'Thor', especie: 'cachorro', tutor: 'Bruno Sá', horario: '10:30' },
];
let proximoId = 3;
app.get('/consultas', (req, res) => {
res.json(consultas);
});
app.get('/consultas/:id', (req, res) => {
const consulta = consultas.find((c) => c.id === Number(req.params.id));
if (!consulta) {
return res.status(404).json({ erro: `consulta ${req.params.id} não existe` });
}
res.json(consulta);
});
app.post('/consultas', (req, res) => {
if (!req.body.pet || !req.body.horario) {
return res.status(422).json({ erro: 'pet e horario são obrigatórios' });
}
const nova = { id: proximoId++, ...req.body };
consultas.push(nova);
res.status(201).location(`/consultas/${nova.id}`).json(nova);
});
app.listen(3778, () => console.log('API da Pata Amiga (Express) em http://localhost:3778'));Os dois arquivos, contados na mesma máquina:
wc -l api.mjs api-express.mjsMetade. E o que sumiu não foi a lógica da clínica — essa é idêntica nos dois.
Sumiu a infraestrutura: parsing de URL, leitura de stream, JSON.parse com
try, montagem de cabeçalho e o if de roteamento.
| tarefa | no node:http |
no Express |
|---|---|---|
| casar caminho e método | if com split('/') |
app.get('/consultas/:id') |
ler :id da URL |
partes[1] |
req.params.id |
| ler query string | url.searchParams.get() |
req.query |
| juntar o corpo | Promise sobre data/end |
express.json() |
| serializar e mandar | writeHead + JSON.stringify |
res.json() |
| 404 de rota inexistente | último if do handler |
automático |
405 com Allow |
você escreve | não vem pronto |
| arquivo estático | readFile + mapa de MIME |
express.static() |
As duas últimas linhas dessa tabela são o pedágio honesto. O Express responde
404 Cannot DELETE /consultas onde a versão crua devolvia 405 com Allow —
e devolve isso em HTML, não em JSON:
<!DOCTYPE html> <html lang=“en”> <head> <meta charset=“utf-8”> <title>Error</title> </head> <body> <pre>Cannot DELETE /consultas</pre> </body> </html>
Um cliente que faz response.json() nessa resposta quebra. O mesmo vale para
JSON malformado: o express.json() derruba a requisição com uma página HTML de
1509 bytes contendo o stack trace inteiro do body-parser. Corrigir isso é o
assunto do tratamento de erro centralizado, e é trabalho que continua sendo seu.
Escrever a versão crua uma vez muda como você lê o framework depois. req.body
deixa de ser mágica e vira “alguém rodou o Buffer.concat por mim”; res.json
vira “alguém chamou writeHead com o Content-Type certo”. Quando o Express
faz algo estranho, você já sabe em que camada olhar.
O que vem depois
O caminho natural é criar a primeira rota no Express e reconhecer, linha por linha, o que o framework passou a fazer no lugar deste arquivo de 66 linhas. Antes disso, se a conversa sobre porta, processo e uma thread só ainda estiver nebulosa, vale voltar em o que é o Node.js. E o guia completo de Node mostra a ordem inteira: HTTP cru, Express, banco, autenticação e deploy.
Prefere aprender em vídeo?
Tem aula sobre este assunto no nosso canal.
DevClub no YouTubeCOMO CRIAR UMA API COM NODE | MÉTODOS HTTP | APRENDENDO BACK-ENDAssistir a aula
DevClub no YouTubeCriando API do Zero com Node e Express | Passo a passoAssistir a aula
Perguntas frequentes
Preciso instalar alguma coisa para usar o módulo http?
node:http vem junto com o Node, sem npm install e sem dependência. O prefixo node: só deixa explícito que o módulo é do próprio runtime e não um pacote com o mesmo nome vindo do npm.Dá para usar node:http em produção de verdade?
Qual a diferença entre res.end, res.write e res.writeHead?
writeHead define status e cabeçalhos, write envia um pedaço do corpo e pode ser chamado várias vezes, e end fecha a resposta. Depois do end nada mais pode ser enviado naquela requisição.Por que o servidor não devolve nada quando eu abro no navegador?
res.end() em algum caminho do código. Sem end, a conexão fica aberta e o navegador roda até estourar o tempo limite, sem mensagem de erro nenhuma.Como reiniciar o servidor sozinho a cada alteração?
node --watch servidor.mjs. O Node observa os arquivos importados e reinicia o processo, sem nodemon e sem configuração.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, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Node.js — HTTP module — nodejs.org
- MDN — HTTP response status codes — developer.mozilla.org


