Node.js trae en node:http todo lo necesario para levantar un servidor web en unas pocas líneas. Antes de recurrir a un framework como Express, conviene ver qué hace él por debajo: es exactamente esto.
También te puede interesar
El servidor completo
Una pequeña API de productos: una página de inicio, la lista, un producto por su número y el alta de uno nuevo.
import { createServer } from "node:http";
const productos = [
{ id: 1, nombre: "Teclado", precio: 49.9 },
{ id: 2, nombre: "Ratón", precio: 19.5 },
];
function enviarJSON(res, estado, datos) {
res.writeHead(estado, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(datos));
}
async function leerJSON(req) {
let cuerpo = "";
for await (const trozo of req) cuerpo += trozo;
return JSON.parse(cuerpo);
}
const servidor = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
console.log(req.method, url.pathname);
if (req.method === "GET" && url.pathname === "/") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h1>Hola desde Node</h1>");
return;
}
if (req.method === "GET" && url.pathname === "/api/productos") {
const max = Number(url.searchParams.get("max") ?? Infinity);
enviarJSON(res, 200, productos.filter((p) => p.precio <= max));
return;
}
const coincide = url.pathname.match(/^\/api\/productos\/(\d+)$/);
if (req.method === "GET" && coincide) {
const producto = productos.find((p) => p.id === Number(coincide[1]));
if (producto) enviarJSON(res, 200, producto);
else enviarJSON(res, 404, { error: "No existe ese producto" });
return;
}
if (req.method === "POST" && url.pathname === "/api/productos") {
try {
const { nombre, precio } = await leerJSON(req);
const nuevo = { id: productos.length + 1, nombre, precio };
productos.push(nuevo);
enviarJSON(res, 201, nuevo);
} catch {
enviarJSON(res, 400, { error: "El cuerpo no es JSON válido" });
}
return;
}
enviarJSON(res, 404, { error: "Ruta no encontrada" });
});
servidor.listen(3000, () => {
console.log("Escuchando en http://localhost:3000");
});node servidor.mjs
salidaEscuchando en http://localhost:3000
Las piezas que importan
createServer recibe una función que Node ejecuta en cada petición, con dos objetos: req, lo que pide el cliente, y res, donde se escribe la respuesta. Toda respuesta tiene que terminar con res.end(): si se te olvida en alguna rama, el cliente se queda esperando.
new URL(req.url, ...) separa la ruta de los parámetros. req.url trae solo /api/productos?max=20, sin el dominio, y por eso hay que darle una base. Después, url.searchParams.get("max") lee el parámetro.
El cuerpo de un POST llega a trozos. req es un flujo, y for await los junta. Si no es JSON válido, JSON.parse lanza y se responde un 400, «petición incorrecta», en vez de dejar caer el servidor.
Probarlo con curl
curl -s localhost:3000/
curl -s localhost:3000/api/productos
curl -s "localhost:3000/api/productos?max=20"
curl -s localhost:3000/api/productos/2
curl -s -i localhost:3000/api/productos/9
curl -s -X POST localhost:3000/api/productos -H "Content-Type: application/json" -d '{"nombre":"Monitor","precio":189}'
curl -s -X POST localhost:3000/api/productos -d 'esto no es json'
curl -s localhost:3000/otrasalida<h1>Hola desde Node</h1>
[{"id":1,"nombre":"Teclado","precio":49.9},{"id":2,"nombre":"Ratón","precio":19.5}]
[{"id":2,"nombre":"Ratón","precio":19.5}]
{"id":2,"nombre":"Ratón","precio":19.5}
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Date: Mon, 19 Oct 2026 16:02:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"error":"No existe ese producto"}
{"id":3,"nombre":"Monitor","precio":189}
{"error":"El cuerpo no es JSON válido"}
{"error":"Ruta no encontrada"}Con -i, curl enseña también el código de estado y las cabeceras: ahí se ve el 404 Not Found y el Content-Type que pusimos con writeHead. Y en la terminal del servidor, cada petición queda registrada por el console.log:
terminal del servidorEscuchando en http://localhost:3000 GET / GET /api/productos GET /api/productos GET /api/productos/2 GET /api/productos/9 POST /api/productos POST /api/productos GET /otra
El error frecuente: el puerto ocupado
Si arrancas el servidor dos veces, o el puerto lo usa otro programa, la segunda vez no puede escuchar:
node servidor.mjs (por segunda vez)Error: listen EADDRINUSE: address already in use :::3000
EADDRINUSE significa «dirección en uso». Busca la otra terminal donde quedó arrancado y párala con Ctrl+C, o cambia el número de puerto.
Cuándo dar el salto a un framework
| Situación | Lo que conviene |
|---|---|
| Una API pequeña, una herramienta interna, aprender | node:http, como aquí |
| Muchas rutas con parámetros, middleware, sesiones | Express, Fastify o Hono |
| Servir solo archivos estáticos | Un servidor estático; no hace falta programarlo |
Todo el código de esta página se ejecutó con Node.js 24.22 en un contenedor limpio antes de publicarla; las salidas, incluidos los errores, están copiadas de esa ejecución. Documentación oficial: el módulo http de Node.js.