Salta al contenuto principale

Middleware Next.js su Vercel: perché ho tolto proxy.ts

Il middleware del mio sito partiva a ogni visita, prima della cache, solo per scrivere header fissi. Come l'ho spostato in vercel.json e cosa controllare.

Una piccola biglietteria di legno bianco su un pontile, con una persona dietro il vetro e barche ormeggiate sullo sfondo
Indice dei contenuti

Fino ad agosto argentodev.com aveva un middleware Next.js, proxy.ts, che a ogni visita apriva la risposta, ci appiccicava sette header di sicurezza sempre identici e la lasciava uscire. Le pagine erano già prerenderizzate e pronte nella cache, eppure quel frammento di codice partiva comunque a ogni richiesta di pagina. Il 28 agosto 2026 l’ho cancellato e ho trasferito tutto in vercel.json.

Racconto la vicenda con i commit alla mano, poi ti dico come controllare il tuo progetto. Versioni di riferimento: Next 16.4 e Vercel com’è documentata adesso.

Cos’è il middleware in Next.js 16 (e perché si chiama proxy)

Dalla versione 16 il vecchio middleware.ts ha cambiato nome in proxy.ts. Parliamo di una funzione eseguita sul server mentre la richiesta viaggia verso la route, capace di reindirizzare, riscrivere l’indirizzo o cambiare intestazioni. La documentazione ufficiale è esplicita sul perimetro: viene invocata per tutte le route del progetto, e il matcher esiste apposta per restringerlo.

Su Vercel diventa Routing Middleware, e qui c’entrano i soldi. La pagina dedicata spiega che lavora “globalmente prima della cache” e che viene fatturato col modello fluid compute, cioè in proporzione alle risorse di calcolo consumate. Una risorsa statica consegnata dalla CDN (la rete di server sparsi nel mondo che tiene copie vicino a chi naviga) non invoca nessuna funzione. Mettile davanti un middleware e ogni richiesta che passa dal matcher la invoca.

La storia di proxy.ts su questo sito

Il file è diventato inutile un pezzo alla volta, come mostra il git log:

  1. 12 novembre 2025: il proxy genera un nonce per la CSP e manda il sito in errore 500 (MIDDLEWARE_INVOCATION_FAILED). Prima provo a semplificare il matcher, quattro minuti dopo tolgo il file e metto una CSP statica in next.config.ts (commit 38ae81c).
  2. 14 novembre 2025: il proxy torna, come app/proxy.ts, di nuovo con il nonce (commit aa45fa0).
  3. 16 marzo 2026, 00:26: lo sposto nella radice del progetto, dove Next.js 16 vuole questo file (6e99fae).
  4. 16 marzo 2026, 00:41: quindici minuti dopo il nonce sparisce e la CSP passa a 'unsafe-inline', con il messaggio “simplify CSP for static rendering” (a295ec9).
  5. 28 agosto 2026: il proxy viene eliminato del tutto (5cf5016).

La svolta sta al quarto punto. La CSP (Content Security Policy) è l’header che dice al browser da dove può caricare script, stili e immagini. Il nonce è un codice casuale, diverso a ogni richiesta, che autorizza solo gli script che lo portano. Ma la guida di Next.js sulla CSP mette una condizione: per usare i nonce serve il rendering dinamico. Un documento HTML generato durante la build non sa nulla del visitatore che lo riceverà, quindi non può contenere il nonce giusto.

Questo sito ha cacheComponents: true in next.config.ts e prerenderizza tutto a build time. Leggere il nonce nel layout avrebbe reso dinamiche tutte le pagine, e un commento nel vecchio file lo diceva chiaro: “Non ne vale il costo per un sito quasi interamente statico”. Da marzo, quindi, il proxy non generava più niente di variabile. Questo è quello che faceva fino al giorno in cui l’ho cancellato (estratto da proxy.ts prima di 5cf5016):

export function proxy(_request: NextRequest) {
  const response = NextResponse.next();
  const isDev = process.env.NODE_ENV === 'development';
  // ...costruisce la stessa stringa CSP a ogni richiesta
  response.headers.set('Content-Security-Policy', cspDirectives.join('; '));
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('X-Frame-Options', 'DENY');
  // ...
  return response;
}

Il parametro si chiama _request, con il trattino basso che per convenzione segnala un argomento non usato. Il codice non leggeva nemmeno ciò che arrivava. Per cinque mesi una funzione si è svegliata a ogni visita per scrivere testo fisso.

Dove sono finiti gli header

La stessa pagina di Vercel lo dice: se la logica non ha bisogno di codice, meglio la configurazione statica, perché le regole statiche girano direttamente sulla CDN senza invocare il tuo codice a ogni richiesta. Gli header fissi sono il caso da manuale.

Nel commit 5cf5016 CSP e COOP (Cross-Origin-Opener-Policy) raggiungono in vercel.json gli altri header che erano già lì. Il blocco oggi è questo (accorciato, il file completo è nel repo):

"headers": [
  {
    "source": "/(.*)",
    "headers": [
      { "key": "X-Frame-Options", "value": "DENY" },
      { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
      { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
      { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline'; ..." }
    ]
  }
]

Una cosa è andata persa nel trasloco: il proxy distingueva sviluppo e produzione con isDev, per non mandare HSTS su localhost e per aggiungere 'unsafe-eval' in sviluppo. In un JSON non c’è logica. Per questo sito non è stato un problema, perché in produzione i due rami davano lo stesso risultato, ma in un progetto dove la CSP cambia tra ambienti va verificato prima di copiare.

Se ti interessa cosa fa ciascun header e perché un sito aziendale dovrebbe averli, li ho spiegati nella guida alla sicurezza di un sito web.

La cache dell’HTML, nello stesso commit

Eliminato il proxy, aveva senso rivedere anche la durata della cache. Prima l’HTML aveva max-age=3600, s-maxage=3600: un’ora sia per il browser sia per la CDN. Ora, in next.config.ts:

{
  key: 'Cache-Control',
  value: 'public, max-age=0, must-revalidate, s-maxage=86400, stale-while-revalidate=604800'
},

max-age=0 dice al browser di ricontrollare sempre, s-maxage=86400 tiene la pagina un giorno nella CDN, stale-while-revalidate serve la copia vecchia mentre ne prepara una nuova in background. Il ragionamento sta scritto nel commento accanto: il contenuto cambia solo al deploy, e ogni pubblicazione svuota la cache. La documentazione di Vercel sui Cache-Control suggerisce proprio max-age=0, s-maxage=86400 come punto di partenza per le pagine uguali per tutti, perché permette alla CDN di invalidare le risposte al deploy mentre il browser riceve sempre l’ultima versione.

Un’ammissione: non ho un grafico prima/dopo da mostrarti. Il messaggio del commit dice che il proxy pesava su quasi tutte le invocazioni di funzione e sul consumo di CPU del progetto, ed è il motivo per cui l’ho tolto, ma non ho salvato i numeri del pannello di Vercel di quei giorni, quindi non te li do.

Come capire se il tuo middleware è inutile

Apri il tuo proxy.ts (o middleware.ts, se sei ancora su Next.js 15) e fatti qualche domanda.

  • Usa la richiesta? Se legge cookie, header, IP o percorso per decidere qualcosa, ha un motivo di esistere. Se il parametro è ignorato come il mio _request, il risultato è sempre lo stesso e può diventare configurazione.
  • Fa solo redirect fissi o aggiunge header fissi? Entrambi stanno in vercel.json (o in headers() e redirects() di next.config.ts).
  • Genera un nonce su pagine statiche? Le pagine prerenderizzate non possono riceverlo, perché durante la build non esiste nessuna richiesta. Se hai ripiegato su 'unsafe-inline' come me, generarlo a ogni visita non serve a niente.

Finito il trasloco, accertati che gli header arrivino davvero al browser. Un comando basta:

curl -sI https://www.example.com/ | grep -i -E 'content-security-policy|strict-transport|x-vercel-cache'

Nelle risposte di Vercel x-vercel-cache vale HIT se la pagina è uscita dalla cache della CDN, PRERENDER se arriva dallo storage statico, MISS se è stata generata in quel momento. Quell’header però non dice se il middleware gira ancora: per saperlo guarda le invocazioni nella dashboard Observability di Vercel. Controlla anche una pagina interna e un asset in /_next/static: la regola "source": "/(.*)" li copre tutti.

Il middleware resta l’attrezzo giusto quando la risposta dipende da chi bussa: autenticazione, test A/B, lingua scelta in base al paese. In una web app con login e area riservata, un proxy che controlla la sessione ha senso. Su un sito vetrina che cambia solo quando lo ridistribuisci, quasi mai. Se vuoi un’idea di quanto pesa l’hosting sul costo complessivo di un progetto, ne parlo in quanto costa un sito web, e il resto del lavoro sulle prestazioni di questo sito è nel case study Lighthouse 100.

Se hai un progetto Next.js su Vercel con una bolletta che cresce e non sai da dove, scrivimi: la prima cosa che guardo è proprio cosa gira prima della cache.

Prossimo passo

Hai un progetto in mente? Parliamone.

Continua a leggere