Cos'è Express e come si usa
Cos'è Express, il framework minimale per API in Node: middleware e next(), routing, gestione degli errori async e gli sbagli che fanno perdere ore.
Hai scritto il tuo primo server con il modulo http di Node e funziona, finché non devi distinguere /utenti da /prodotti, leggere un corpo JSON e restituire un 404 sensato. A quel punto ti accorgi che stai riscrivendo a mano cose che fanno tutti. Express è la libreria che le ha già scritte. In questo articolo trovi come funzionano i middleware, perché il loro ordine spiega metà dei bug di chi inizia, e come si gestiscono davvero gli errori nelle funzioni async.
Cos'è
Express è un framework minimale per Node che si occupa di instradare le richieste HTTP verso la funzione giusta e di far passare ogni richiesta attraverso una catena di funzioni intermedie chiamate middleware.
"Minimale" è la parola chiave e va presa alla lettera: Express non ti dà un ORM, né un sistema di autenticazione, né una struttura di cartelle obbligata. Ti dà il routing, i middleware e qualche utilità sull'oggetto richiesta e risposta. Tutto il resto lo scegli tu, e questo è insieme il suo pregio e il motivo per cui due progetti Express possono sembrare scritti in due linguaggi diversi.
Cosa aggiunge rispetto al modulo http
Con il solo http scrivi qualcosa del genere:
import http from "node:http";
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/utenti") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ utenti: [] }));
return;
}
res.writeHead(404);
res.end("Non trovato");
});
server.listen(3000);
Funziona, ma prova ad aggiungere /utenti/42, poi /utenti/42/ordini, poi il corpo JSON di una POST — che arriva a pezzi e va ricomposto a mano. Diventa in fretta un labirinto di if.
La stessa cosa con Express:
import express from "express";
const app = express();
app.use(express.json());
app.get("/utenti", (req, res) => {
res.json({ utenti: [] });
});
app.get("/utenti/:id", (req, res) => {
res.json({ id: req.params.id });
});
app.listen(3000, () => console.log("in ascolto su 3000"));
Quello che ottieni in cambio, concretamente: instradamento per metodo e percorso con parametri dinamici, parsing del corpo della richiesta, res.json() che imposta da solo le intestazioni, scomposizione dell'applicazione in router separati e — soprattutto — i middleware.
Il middleware: il concetto che devi capire
Un middleware è una funzione che riceve la richiesta, la risposta e una funzione next, e che sta in mezzo tra l'arrivo della richiesta e la risposta finale.
function registraRichiesta(req, res, next) {
console.log(`${req.method} ${req.url}`);
next(); // passa al prossimo anello della catena
}
app.use(registraRichiesta);
Ogni richiesta che entra attraversa in ordine tutti i middleware registrati, uno dopo l'altro, finché qualcuno risponde. Non è una lista di funzioni indipendenti: è una catena, e next() è l'unico modo per avanzare di un anello.
Da qui discendono tre regole che vale la pena imparare subito.
Se non chiami next() e non rispondi, la richiesta resta appesa. Il client aspetta finché non va in timeout. Nessun errore, nessun log: semplicemente il silenzio. È il bug più frustrante da diagnosticare perché non assomiglia a un errore.
Se chiami next() e poi rispondi, o rispondi due volte, ottieni ERR_HTTP_HEADERS_SENT. Le intestazioni HTTP si mandano una volta sola.
Un middleware può modificare la richiesta per quelli successivi. È il meccanismo con cui l'autenticazione passa l'utente al resto della catena:
function autentica(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ errore: "Token mancante" });
}
req.utente = verificaToken(token); // disponibile da qui in poi
next();
}
Nota il return davanti a res.status(401): senza, l'esecuzione prosegue e chiami next() anche per una richiesta che hai già rifiutato.
Perché l'ordine conta più di quanto pensi
Express esegue i middleware nell'ordine esatto in cui li registri. Non c'è priorità, non c'è ordinamento implicito: è una fila.
// SBAGLIATO: express.json() arriva dopo la rotta
app.post("/utenti", (req, res) => {
console.log(req.body); // undefined
res.json({ ok: true });
});
app.use(express.json());
La rotta è registrata prima del parser, quindi quando la richiesta arriva a /utenti il corpo non è ancora stato letto e req.body è undefined. Sposti app.use(express.json()) due righe più su e tutto funziona. È letteralmente questo, nella maggior parte dei casi in cui "Express non legge il body".
Lo stesso vale per l'autenticazione: se registri app.use(autentica) dopo le rotte pubbliche e private, non protegge niente. E per il CORS: registrato dopo, il browser si vede rifiutare le richieste prima che il middleware venga toccato.
L'ordine sano di un'applicazione Express è quasi sempre questo:
- Middleware globali di infrastruttura (log, CORS, intestazioni di sicurezza, parser del corpo)
- File statici
- Middleware di autenticazione, dove serve
- Rotte
- Gestore del 404
- Gestore degli errori
Routing e parametri
Express distingue tre modi di ricevere dati da una richiesta, e confonderli è un classico:
// /prodotti/42?dettagli=true con corpo JSON { "nome": "Tastiera" }
app.put("/prodotti/:id", (req, res) => {
req.params.id; // "42" dal percorso
req.query.dettagli; // "true" dalla query string
req.body.nome; // "Tastiera" dal corpo (serve express.json())
res.json({ ok: true });
});
req.params e req.query sono sempre stringhe. Un if (req.query.pagina === 1) non sarà mai vero: arriva "1". Va convertito, e va validato — sono dati che arrivano dall'esterno e possono contenere qualsiasi cosa.
Per non tenere tutto in un file solo, Express ha i router:
// routes/utenti.js
import { Router } from "express";
const router = Router();
router.get("/", elencaUtenti);
router.get("/:id", dettaglioUtente);
export default router;
// server.js
import utenti from "./routes/utenti.js";
app.use("/api/utenti", utenti);
Il prefisso sta in un posto solo. Se domani l'API diventa /api/v2/utenti, cambi una riga.
La gestione degli errori
Express riconosce un middleware come gestore di errori da un dettaglio preciso: ha quattro argomenti invece di tre.
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({
errore: err.message || "Errore interno",
});
});
Due cose non negoziabili. La prima: va registrato per ultimo, dopo tutte le rotte. Se lo metti in cima, la catena non ci arriva mai, perché i middleware normali non saltano indietro.
La seconda: la firma deve avere esattamente quattro parametri, anche se next non lo usi. Express conta gli argomenti della funzione per capire di che tipo di middleware si tratta. Togli next per fare pulizia e il gestore diventa silenziosamente un middleware normale, che non intercetta più niente.
Per mandare un errore alla catena, lo passi a next():
app.get("/prodotti/:id", (req, res, next) => {
const prodotto = trovaProdotto(req.params.id);
if (!prodotto) {
const err = new Error("Prodotto non trovato");
err.status = 404;
return next(err);
}
res.json(prodotto);
});
La trappola degli handler async
Questa merita una sezione sua, perché è il singolo errore che fa perdere più tempo con Express.
// TRAPPOLA: se il database fallisce, la richiesta resta appesa
app.get("/utenti/:id", async (req, res) => {
const utente = await db.trovaUtente(req.params.id); // lancia
res.json(utente);
});
Un throw dentro una funzione async non è un'eccezione sincrona: è una promessa rifiutata. Express 4 chiama l'handler e non fa nulla con il valore restituito, quindi quel rifiuto non raggiunge mai il gestore degli errori. Risultato: nessuna risposta, nessun 500, il client aspetta il timeout. Nei log vedi al massimo un avviso di unhandled rejection.
La soluzione è avvolgere gli handler async:
const asincrono = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get(
"/utenti/:id",
asincrono(async (req, res) => {
const utente = await db.trovaUtente(req.params.id);
res.json(utente);
})
);
Cinque righe che ti risparmiano una categoria intera di bug. In alternativa metti un try/catch in ogni handler con catch (err) { next(err) }, ma dimenticarlo una volta sola basta.
Express 5 risolve il problema alla radice: le promesse rifiutate negli handler vengono inoltrate automaticamente al gestore degli errori, e il wrapper non serve più. Se stai partendo da zero oggi, è un buon motivo per usare la versione 5. Se lavori su un progetto esistente, quasi certamente sei su Express 4 e il wrapper ti serve.
Errori comuni che fanno perdere ore
| Sintomo | Causa quasi sempre |
|---|---|
req.body è undefined | express.json() manca o è registrato dopo la rotta |
| La richiesta resta appesa | Un middleware non chiama next() né risponde |
ERR_HTTP_HEADERS_SENT | Doppia risposta, o next() dopo aver già risposto |
| Il gestore errori non scatta | Registrato prima delle rotte, o ha 3 parametri invece di 4 |
| Un errore async non viene catturato | Handler async senza wrapper su Express 4 |
| Le rotte rispondono 404 a caso | Una rotta generica registrata prima di una specifica |
| Errori CORS nel browser | Middleware CORS registrato dopo le rotte |
Aggiungo l'ultima della lista, che è subdola: app.get("/utenti/:id") registrata prima di app.get("/utenti/nuovo") cattura anche /utenti/nuovo, con id uguale a "nuovo". Le rotte statiche vanno prima di quelle con parametri.
Un'altra buona abitudine: le chiavi e le password non stanno nel codice ma nelle variabili d'ambiente, e vale la pena leggere cosa significa rendere sicuro un sito prima di esporre la prima API pubblica.
Perché Express è ancora ovunque
Express ha più di quindici anni ed è ancora la scelta predefinita per un'API in Node. Non perché sia il più veloce — non lo è — ma per tre ragioni concrete.
L'ecosistema. Qualunque cosa ti serva esiste come middleware Express, documentato e usato da anni. Autenticazione, caricamento file, limitazione delle richieste, sessioni, validazione.
La stabilità. Il codice Express scritto nel 2017 gira oggi quasi senza modifiche. In un ecosistema che si riscrive ogni diciotto mesi, è un valore che si sottovaluta finché non ti tocca manutenere qualcosa.
La diffusione. È l'API di riferimento: la forma (req, res, next) la trovi in Fastify con un livello di compatibilità, nelle serverless functions di mezzo mondo e in innumerevoli tutorial. Impararla ti serve anche altrove.
Le alternative, onestamente
Fastify è più veloce, ha la validazione degli schemi integrata e un sistema di plugin più rigoroso. Se il throughput è un requisito misurato, ha senso valutarlo.
Hono è più moderno e leggero, scritto attorno alle API web standard, e gira su Node ma anche su Deno, Bun e sui runtime edge come i Cloudflare Workers. Se il tuo target è l'edge, Express non è nemmeno un'opzione: Hono sì.
I framework full-stack come Next.js hanno il loro strato di API integrato, e in quel caso Express è una dipendenza in più che non ti serve.
Detto questo: per un'API in Node che deve andare in produzione e stare in piedi per anni, Express resta la scelta pragmatica. Il divario di prestazioni con Fastify è reale nei benchmark e quasi sempre irrilevante quando il vero collo di bottiglia è il database.
In sintesi
Express è routing più middleware. Tolto questo, è quasi solo qualche utilità comoda sopra il modulo http.
La catena di middleware si esegue nell'ordine di registrazione, e next() è l'unico modo per avanzare. Metà dei bug di chi inizia è un middleware nel posto sbagliato.
Il gestore degli errori ha quattro argomenti e va per ultimo. Tre argomenti e smette di funzionare senza dirtelo.
Gli handler async su Express 4 vanno avvolti, altrimenti un throw scompare nel nulla e la richiesta resta appesa. Express 5 lo gestisce da solo.
È vecchio e va benissimo così: l'ecosistema e la stabilità valgono più dei millisecondi di differenza nei benchmark.
Se il modello a thread singolo su cui tutto questo poggia non ti è ancora chiaro, il punto di partenza è cos'è Node.js. Per capire che tipo di API stai costruendo, vedi cos'è una REST API; per mandarla online, come fare il deploy di un sito.