Un servidor web con Node.js, sin frameworks

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.

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

Una petición llega a la función del servidor, que elige la ruta y responde con writeHead y endEl recorrido de una petición por el servidornavegadoro curlGET(req, res) => { … }tu función se ejecutaen cada petición ymira req.methody la ruta de req.urlGET /una página HTMLGET /api/productosla lista en JSONGET /api/productos/2uno, o un 404cualquier otra404 en JSONres.writeHead(estado, cabeceras) y res.end(cuerpo)la respuesta vuelve por la misma conexión

Cada petición ejecuta tu función una vez; ella decide la respuesta según el método y la ruta. Esquema: decodigo.com.

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/otra
salida<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ónLo que conviene
Una API pequeña, una herramienta interna, aprendernode:http, como aquí
Muchas rutas con parámetros, middleware, sesionesExpress, Fastify o Hono
Servir solo archivos estáticosUn servidor estático; no hace falta programarlo
Este servidor no está listo para internet tal cual. Para exponerlo hacen falta, como mínimo, HTTPS (normalmente con un proxy delante, como Nginx o Caddy), un límite al tamaño del cuerpo que aceptas y validar los datos que llegan en el POST.

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.