06. Rendering on-demand: adapter, middleware, actions, sessioni

← 05 - Interattività - island, script e view transitions · Indice · successivo → 07 - Immagini, font, SEO, performance e deploy

Questo modulo serve quando il sito deve fare qualcosa a richiesta: leggere cookie, gestire un form, mostrare contenuti personalizzati, proteggere una pagina. Per una guida o un blog puramente statico puoi saltarlo e tornarci quando serve.

6.1 Adapter

Senza adapter, Astro produce solo file statici. L’adapter è il pezzo che sa come girare su una piattaforma specifica.

npx astro add node        # server tuo, VPS, Docker
npx astro add netlify
npx astro add vercel
npx astro add cloudflare
import node from '@astrojs/node';
 
export default defineConfig({
  adapter: node({ mode: 'standalone' }),   // 'standalone' | 'middleware'
});

Serve un adapter anche a un sito completamente statico se vuoi usare le server island (server:defer) o l’image CDN di alcuni host.

Note pratiche per piattaforma:

  • Node: mode: 'standalone' genera un server avviabile con node ./dist/server/entry.mjs; mode: 'middleware' esporta un handler da montare dentro Express/Fastify. L’adapter non legge .env a runtime: le variabili le passi dall’ambiente.
  • Cloudflare: riscritto in Astro 6: solo Workers (il supporto a Pages è stato rimosso), il dev server gira su workerd reale, e Astro.locals.runtime non esiste più (si usa import { env } from 'cloudflare:workers').
  • Netlify / Vercel: supportano cache CDN, skew protection, e ISR (Vercel).

6.2 Rendere on-demand una singola rotta

---
export const prerender = false;
 
const utente = Astro.cookies.get('sessione')?.value;
if (!utente) return Astro.redirect('/login');
---
<p>Ciao {utente}</p>

Cosa si sblocca in on-demand: Astro.cookies, Astro.request completo (headers, method, body), Astro.response (status e header), Astro.clientAddress, Astro.session, gli endpoint dinamici, e lo streaming HTML.

Conseguenza dello streaming

Astro invia l’HTML man mano che lo produce. Quindi redirect e modifiche agli header vanno fatti a livello di pagina, non dentro un componente figlio o un layout: quando quel codice gira, gli header sono già partiti.

6.3 Middleware

Un file src/middleware.ts (oppure src/middleware/index.ts) che esporta onRequest. Gira prima di ogni pagina, incluse quelle prerenderizzate durante la build.

// src/middleware.ts
import { defineMiddleware, sequence } from 'astro:middleware';
 
const autenticazione = defineMiddleware(async (context, next) => {
  const token = context.cookies.get('token')?.value;
  context.locals.utente = token ? await risolviUtente(token) : null;
 
  if (context.url.pathname.startsWith('/area-riservata') && !context.locals.utente) {
    return context.redirect('/login');
  }
  return next();
});
 
const intestazioni = defineMiddleware(async (_context, next) => {
  const risposta = await next();
  risposta.headers.set('X-Frame-Options', 'DENY');
  return risposta;
});
 
export const onRequest = sequence(autenticazione, intestazioni);

Tipizza locals in src/env.d.ts, altrimenti scrivi alla cieca:

declare namespace App {
  interface Locals {
    utente: { id: string; nome: string } | null;
  }
}

sequence() esegue i middleware in ordine all’andata e in ordine inverso al ritorno: lo schema classico a cipolla.

📖 Middleware

6.4 Actions: form e RPC senza scrivere endpoint

Le Actions sono il modo idiomatico per gestire mutazioni (form, “mi piace”, iscrizione newsletter) con validazione e tipi condivisi tra server e client.

// src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro/zod';
 
export const server = {
  newsletter: defineAction({
    accept: 'form',
    input: z.object({
      email: z.email(),              // Zod 4: NON z.string().email()
      condizioni: z.boolean(),
    }),
    handler: async ({ email, condizioni }, context) => {
      if (!condizioni) {
        throw new ActionError({ code: 'BAD_REQUEST', message: 'Devi accettare le condizioni.' });
      }
      await iscrivi(email);
      return { ok: true };
    },
  }),
};
---
// src/pages/iscriviti.astro
export const prerender = false;
import { actions, isInputError } from 'astro:actions';
 
const risultato = Astro.getActionResult(actions.newsletter);
if (risultato && !risultato.error) return Astro.redirect('/grazie');
const erroriCampo = isInputError(risultato?.error) ? risultato.error.fields : {};
---
<form method="POST" action={actions.newsletter}>
  <input type="email" name="email" required />
  {erroriCampo.email && <p class="errore">{erroriCampo.email.join(', ')}</p>}
  <label><input type="checkbox" name="condizioni" /> Accetto</label>
  <button>Iscrivimi</button>
</form>

Dal client, come RPC tipizzata:

import { actions } from 'astro:actions';
const { data, error } = await actions.newsletter({ email, condizioni: true });

Conversioni automatiche con accept: 'form': number → z.number(), checkbox → z.coerce.boolean(), file → z.instanceof(File) (ricorda enctype="multipart/form-data"), campi ripetuti → z.array(). I campi vuoti diventano null.

Le action sono endpoint pubblici

Ogni action è raggiungibile a /_actions/<nome>. L’autorizzazione va fatta dentro l’handler (o nel middleware), non nascondendo il form.

📖 Actions

6.5 Sessioni

Stabili dalla 5.7. Accesso con Astro.session (o context.session), metodi asincroni: get, set, has, delete, destroy, regenerate.

---
export const prerender = false;
const carrello = (await Astro.session?.get('carrello')) ?? [];
await Astro.session?.set('carrello', [...carrello, prodotto]);
---
// astro.config.mjs   - forma corretta da Astro 6 in poi
import { defineConfig, sessionDrivers } from 'astro/config';
 
export default defineConfig({
  adapter: vercel(),
  session: {
    driver: sessionDrivers.redis({ url: process.env.REDIS_URL }),
  },
});

Cambiato in Astro 6

Il driver non è più una stringa (driver: 'redis') ma un oggetto costruito con sessionDrivers.*. La forma vecchia (session.options) è deprecata. Alcune pagine di documentazione degli adapter mostrano ancora la sintassi vecchia: fidati della configuration reference.

Driver di default per adapter: Node → filesystem, Cloudflare → Workers KV, Netlify → Netlify Blobs. Vercel e altri vanno configurati a mano. Le sessioni non funzionano nell’edge middleware. Da Astro 7.2 puoi disattivarle del tutto con session: false per alleggerire il bundle serverless.

📖 Sessions

6.6 Variabili d’ambiente: astro:env

Due sistemi convivono.

import.meta.env (di Vite): tutto ciò che ha prefisso PUBLIC_ finisce nel bundle client, il resto resta server-side. Nessuna validazione, nessun tipo.

astro:env: schema dichiarato, validato e tipizzato.

import { defineConfig, envField } from 'astro/config';
 
export default defineConfig({
  env: {
    schema: {
      PUBLIC_ANALYTICS_ID: envField.string({ context: 'client', access: 'public', optional: true }),
      PORT:                envField.number({ context: 'server', access: 'public', default: 4321 }),
      API_SECRET:          envField.string({ context: 'server', access: 'secret' }),
    },
  },
});
import { PUBLIC_ANALYTICS_ID } from 'astro:env/client';
import { API_SECRET, getSecret } from 'astro:env/server';

Le tre combinazioni valide sono client+public, server+public, server+secret. client+secret non esiste, per costruzione: se una cosa arriva al browser, non è un segreto.

Limite: astro:env è un modulo virtuale, quindi non funziona dentro astro.config.mjs né in script esterni, lì usi process.env.

📖 Environment variables

6.7 Server island

Il pezzo dinamico dentro una pagina statica. Componente .astro (non di framework) con server:defer, e un adapter installato.

---
import Avatar from '../components/Avatar.astro';
---
<Avatar server:defer>
  <AvatarGenerico slot="fallback" />
</Avatar>

Come funziona: in build il componente viene estratto in una rotta dedicata; la pagina viene servita statica con il fallback; il browser fa una GET verso /_server-islands/<Nome> con le props cifrate nella query string, e sostituisce il contenuto. Ogni isola carica indipendentemente.

Due dettagli che fanno perdere tempo se non li sai:

  • Contesto isolato: dentro una server island, Astro.url vale /_server-islands/Avatar, non l’URL della pagina. Per risalire alla pagina reale leggi l’header Referer.
  • Chiave di cifratura: viene generata a ogni build. Con deploy rolling o multi-region serve una chiave stabile: astro create-key e poi la variabile d’ambiente ASTRO_KEY.

Props piccole: oltre ~2048 byte di URL, Astro passa a POST e la risposta non è più cacheabile.

📖 Server islands

6.8 Le novità di Astro 7 in quest’area

Route caching (stabile): cache dichiarativa per rotta:

import { defineConfig, memoryCache } from 'astro/config';
 
export default defineConfig({
  cache: { provider: memoryCache() },
  routeRules: {
    '/api/[...path]': { swr: 600 },
    '/prodotti/[...slug]': { maxAge: 3600, tags: ['prodotti'] },
  },
});

A runtime: Astro.cache.set({ maxAge: 120, tags: ['home'] }) e Astro.cache.invalidate({ tags: ['prodotti'] }). Esistono provider CDN (sperimentali) per Netlify, Vercel e Cloudflare.

Advanced routing (stabile): src/fetch.ts ti dà il controllo completo della pipeline (trailing slash → redirect → sessioni → actions → middleware → pagine → i18n → cache), con handler componibili da astro/fetch e un wrapper per Hono.

src/fetch.ts è ora un nome file riservato. Se ne avevi uno con

quel nome per altri motivi, rinominalo o imposta fetchFile: null in config.

CSP (da Astro 6), security: { csp: true } inietta una Content Security Policy con gli hash degli script e degli stili. Limiti dichiarati: non funziona in dev (testa con build + preview), non è compatibile con <ClientRouter /> e non supporta Shiki (in quel caso serve <Prism />).

Esercizi

  1. Aggiungi l’adapter Node e trasforma una sola pagina in on-demand. Verifica con astro build che le altre restino file HTML in dist/.
  2. Scrivi un middleware che aggiunge un header di sicurezza e logga il tempo di risposta di ogni richiesta.
  3. Costruisci un form di contatto con un’Action: validazione, errori mostrati per campo, redirect dopo il successo.
  4. Prendi una pagina statica e mettici dentro una server island che mostra l’ora del server. Ricarica: la pagina è cacheabile, il pezzo no.