Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

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

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.

Rodolfo Mori10 min de leitura

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.

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

bash
node servidor.mjs
curl -i http://localhost:3777/
Servidor ouvindo em 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.

js
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);
bash
curl -s "http://localhost:3777/consultas?dia=2026-06-17" -H "User-Agent: curl-do-artigo"
method : GET url : /consultas?dia=2026-06-17 httpVersion: 1.1 host : localhost:3777 user-agent : curl-do-artigo

Três detalhes que economizam muita dor de cabeça:

  • req.url nã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.headers tem as chaves em minúsculas, sempre, independentemente de como o cliente enviou.
  • Não existe req.body. Nem req.params, nem req.query. Isso é vocabulário de framework — no node:http você 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.

js
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));
pathname: /consultas dia : 2026-06-17 vet : marina partes : [ 'consultas' ]

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.

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

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

node:_http_server:365 throw new ERR_HTTP_HEADERS_SENT('write'); ^

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:

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

req.body vale: undefined pedaço 1: 31 bytes (é Buffer? true) fim: 1 pedaços, 31 bytes req.body vale: undefined pedaço 1: 48701 bytes (é Buffer? true) pedaço 2: 65536 bytes (é Buffer? true) pedaço 3: 65536 bytes (é Buffer? true) pedaço 4: 20898 bytes (é Buffer? true) fim: 4 pedaços, 200671 bytes

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:

js
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}`,
  );
});
envio 1: string=150004 chars | buffer=150000 chars | iguais? false envio 2: string=150004 chars | buffer=150000 chars | iguais? false envio 3: string=150004 chars | buffer=150000 chars | iguais? false envio 4: string=150004 chars | buffer=150000 chars | iguais? false envio 5: string=150004 chars | buffer=150000 chars | iguais? false envio 6: string=150005 chars | buffer=150000 chars | iguais? false

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:

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

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

bash
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
HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Content-Length: 159

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

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

bash
curl -s --path-as-is 'http://localhost:3777/../api-express.mjs' | head -6
import express from 'express';

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

HTTP/1.1 403 Forbidden Content-Type: text/plain; charset=utf-8

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:

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

bash
wc -l api.mjs api-express.mjs
66 api.mjs 33 api-express.mjs 99 total

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

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

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

Ver todos os vídeos do canal
  • node
  • http
  • servidor
  • api
  • curl

Perguntas frequentes

Preciso instalar alguma coisa para usar o módulo http?
Não. O 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?
Dá, e vários serviços fazem isso — inclusive o Express, que roda em cima dele. A questão não é desempenho, é manutenção: cada rota nova custa mais linhas de roteamento escritas na mão e mais chance de esquecer um return.
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?
Quase sempre porque o handler não chamou 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?
Rode com 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.

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

  1. Node.js — HTTP module — nodejs.org
  2. MDN — HTTP response status codes — developer.mozilla.org

Continue por aqui