Content Layer API, Server Islands e View Transitions in Astro 5
La Content Layer API: Pipeline Dati Basata su Loader
Le content collections in Astro 4 funzionavano adeguatamente per i casi d'uso base. I file Markdown andavano in src/content/, si definiva uno schema in config.ts, e Astro gestiva il resto. La limitazione era che "il resto" risultava opaco. Le sorgenti personalizzate richiedevano uno sforzo significativo. Il contenuto remoto dipendeva da soluzioni provvisorie. Le sorgenti miste non erano ben supportate.
La content layer API di Astro 5 ripensa tutto completamente. Invece di un sistema rigido basato su directory, definisci loader — funzioni che possono tirare contenuto da qualsiasi parte:
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
import { createNotionLoader } from './loaders/notion';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
const changelog = defineCollection({
loader: createNotionLoader({
databaseId: process.env.NOTION_DB_ID,
filter: { property: 'Status', select: { equals: 'Published' } },
}),
schema: z.object({
title: z.string(),
version: z.string(),
body: z.string(),
}),
});
export const collections = { blog, changelog };
Il loader glob sostituisce il vecchio scanning implicito dei file. Ma il vero potere sta nello scrivere loader custom. Ecco un esempio di loader Notion per un changelog:
// src/loaders/notion.ts
import { Client } from '@notionhq/client';
export function createNotionLoader(opts) {
const notion = new Client({ auth: process.env.NOTION_TOKEN });
return {
name: 'notion-loader',
async load({ store, logger }) {
logger.info('Fetching from Notion...');
const { results } = await notion.databases.query({
database_id: opts.databaseId,
filter: opts.filter,
});
store.clear();
for (const page of results) {
const blocks = await notion.blocks.children.list({
block_id: page.id,
});
const body = blocksToMarkdown(blocks.results);
const props = page.properties;
store.set({
id: page.id,
data: {
title: props.Name.title[0]?.plain_text ?? '',
version: props.Version.rich_text[0]?.plain_text ?? '',
body,
},
});
}
},
};
}
L'oggetto store è l'astrazione chiave. È uno store di contenuto persistente che Astro gestisce tra i build. In modalità dev, la funzione load() del loader viene chiamata sui cambiamenti dei file (per loader basati su file) o al riavvio del server (per loader remoti). Durante il build, gira una volta e fa cache. Questo significa che i build incrementali funzionano davvero — se il tuo contenuto Notion non è cambiato, Astro può saltare il fetch completamente usando il meccanismo di digest dello store.
Profilando su un sito di documentazione con 2.400 pagine che tira da file Markdown e un headless CMS, i tempi di build scendono da 47 secondi (Astro 4, refetch di tutto) a 12 secondi con cache hit. Non è un errore di battitura.
Server Islands: L'Endgame dell'Idratazione Parziale
Astro fa idratazione parziale dalla v1 con client:load, client:idle, client:visible. Quello riguardava quando idratare un componente. I server islands riguardano dove eseguirlo.
Il concetto: la tua pagina è una shell statica renderizzata al build time (o all'edge), con buchi ritagliati per contenuto dinamico che viene riempito da HTML renderizzato dal server al momento della richiesta. Nessun JavaScript lato client necessario per quelle parti dinamiche.
---
// src/pages/product/[slug].astro
import ProductDetails from '../components/ProductDetails.astro';
import PricingPanel from '../components/PricingPanel.astro';
import ReviewSummary from '../components/ReviewSummary.astro';
---
<ProductDetails product={product} />
<!-- Questo viene renderizzato sul server al momento della richiesta -->
<PricingPanel server:defer productId={product.id}>
<div slot="fallback" class="skeleton-pricing">
<div class="skeleton-line w-32 h-8"></div>
<div class="skeleton-line w-24 h-6"></div>
</div>
</PricingPanel>
<ReviewSummary server:defer productId={product.id}>
<p slot="fallback">Caricamento recensioni...</p>
</ReviewSummary>
Quando la pagina si carica, la shell statica appare istantaneamente dalla CDN. Poi il browser fa richieste fetch parallele per ogni island server:defer. Il server renderizza il componente in HTML e lo rimanda indietro. Il browser sostituisce il contenuto fallback con quello reale. Nessuna idratazione. Nessun runtime di framework JavaScript. Solo sostituzione HTML.
Consideriamo un catalogo e-commerce con 15.000 SKU. Le pagine di dettaglio prodotto sono completamente statiche (immagini, descrizioni, specifiche) mentre pricing e inventario sono server islands che interrogano il database live. L'architettura appare così:
CDN (shell statica) ──► Browser ──► Edge Function (island pricing)
├──► Edge Function (island inventario)
└──► Edge Function (island recensioni)
I risultati sono stati notevoli. TTFB sceso a 28ms per la shell statica (CDN hit). Gli island dinamici risolvevano in 80-120ms. LCP totale sotto 200ms per il contenuto statico, con il pricing che appariva intorno ai 300ms. Prima, con full SSR su ogni richiesta, il TTFB era 400-600ms a seconda del carico del database.
C'è una sottigliezza che la documentazione non enfatizza abbastanza: i server islands possono avere policy di cache indipendenti. L'island del pricing può essere cache-busted ad ogni richiesta mentre il sommario delle recensioni può essere cachato per 5 minuti:
// Nel server endpoint del tuo island
export const config = {
cache: {
pricing: 'no-store',
reviews: 'public, max-age=300, stale-while-revalidate=60',
},
};
Questa granularità è qualcosa che non puoi ottenere facilmente con full-page SSR o ISR.
View Transitions: Persistenza dello Stato e Navigazione Nativa
Le view transitions in Astro 5 sono costruite sulla API nativa View Transitions (con fallback per Firefox/Safari), e cambiano fondamentalmente come si sentono le MPA. Un sito statico naviga come una SPA senza spedire un router lato client.
---
// src/layouts/Base.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<nav transition:persist="main-nav">
<!-- La navigazione persiste tra le transizioni di pagina -->
</nav>
<main transition:animate="slide">
<slot />
</main>
</body>
</html>
La direttiva transition:persist è dove la cosa diventa genuinamente utile. Consideriamo un componente lettore musicale su un portfolio che deve continuare a suonare tra le navigazioni. In una MPA tradizionale, ogni navigazione uccide l'elemento audio. Con transition:persist, il nodo DOM del componente sopravvive alla transizione:
<AudioPlayer
transition:persist="audio-player"
client:load
currentTrack={track}
/>
Il lettore continua a suonare. Il contesto audio non viene interrotto. L'animazione del visualizzatore continua. Semplicemente funziona. Questo prima era possibile solo con un'architettura SPA, che significava spedire un router, un runtime di framework, e gestire stato lato client.
Ho misurato le performance di navigazione su un sito di documentazione da 200 pagine. Navigazione MPA tradizionale (caricamento completo pagina): 180-350ms a seconda del peso della pagina. Con view transitions: 60-90ms di tempo di navigazione percepito, perché Astro prefetcha la pagina successiva sull'hover e trasforma il DOM.
Rendering Ibrido: Selezione della Modalità per Route
Astro 5 ti permette di mescolare modalità di rendering a livello di route. Le tue pagine marketing possono essere completamente statiche, la tua dashboard può essere server-rendered, e i tuoi endpoint API possono girare sull'edge:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';
export default defineConfig({
output: 'static',
adapter: vercel({
imageService: true,
}),
});
Poi per pagina:
---
// src/pages/dashboard.astro
export const prerender = false;
const session = await getSession(Astro.cookies);
if (!session) return Astro.redirect('/login');
---
Tutto è statico di default. Opta singole route in SSR con export const prerender = false. Questo è l'inverso dell'approccio di Next.js dove sei in SSR-land di default e opti per la generazione statica.
Confronto Prestazionale con Next.js
I seguenti dati provengono da siti di contenuto comparabili in produzione:
| Metrica | Astro 5 (sito docs, 800 pagine) | Next.js 15 (sito docs, 650 pagine) | |---------|----------------------------------|-------------------------------------| | Tempo di build | 18s | 94s | | JS spedito (homepage) | 12KB | 87KB | | TTFB (CDN) | 22ms | 31ms | | LCP (mobile 3G) | 1.2s | 2.1s | | TTI (mobile 3G) | 1.4s | 3.8s |
La differenza nel tempo di build è in gran parte perché Astro non ha bisogno di bundlare un runtime React lato client per ogni pagina. La differenza JS è quella grande — 12KB vs 87KB conta enormemente su reti limitate.
Ma questo confronto è ingiusto se il tuo sito è molto interattivo. Il sito Next.js che sto confrontando aveva ricerca interattiva, filtraggio lato client, e un widget di feedback su ogni pagina. Astro può fare tutto questo (con island React), ma una volta che hai 5+ componenti interattivi per pagina, l'architettura island inizia a perdere il suo vantaggio perché stai spedendo React comunque.
La mia regola dopo aver costruito con entrambi: se più del 30% della superficie della tua pagina è interattiva, resta con Next.js. Sotto quella soglia, l'architettura island di Astro ti dà performance misurabilmente migliori.
Migrazione da Next.js: Problemi Comuni
La migrazione di un blog Next.js 14 (340 post, MDX, componenti personalizzati) ad Astro 5 evidenzia diversi problemi ricorrenti:
Import componenti MDX: Next.js auto-importa componenti via mdx-components.tsx. Astro necessita di import espliciti in ogni file MDX o un plugin remark per iniettarli. Il seguente plugin remark risolve il problema:
// remark-auto-import.mjs
export function remarkAutoImport() {
return (tree) => {
const imports = [
"import CodeBlock from '../../components/CodeBlock.astro';",
"import Callout from '../../components/Callout.astro';",
];
tree.children.unshift({
type: 'mdxjsEsm',
value: imports.join('\n'),
data: {
estree: {
type: 'Program',
body: imports.map((imp) => ({
type: 'ImportDeclaration',
specifiers: [],
source: { type: 'Literal', value: imp.match(/from '(.+)'/)[1] },
})),
sourceType: 'module',
},
},
});
};
}
Ottimizzazione immagini: Il componente <Image> di Next.js non si traduce 1:1. Il <Image> di Astro da astro:assets gestisce bene le immagini locali ma le immagini remote necessitano di configurazione esplicita domains in astro.config.mjs. Avevo 200+ post con immagini remote che necessitavano della allowlist domains popolata.
Feed RSS: Next.js non ha RSS built-in. Il pacchetto @astrojs/rss di Astro è eccellente ma la migrazione del content layer ha significato che il mio script di generazione RSS doveva essere riscritto per usare la nuova API getCollection().
Il tempo di build è passato da 94 secondi a 14 secondi. La dimensione del bundle per pagina è scesa da ~90KB a ~8KB (solo le pagine con componenti interattivi spediscono JS). I Core Web Vitals sono migliorati su tutti i fronti — la mediana LCP è scesa da 1.8s a 0.9s su mobile.
Cosa Manca Ancora
Astro 5 non è perfetto. L'HMR del dev server è più lento di Next.js per siti grandi (500+ pagine). L'ecosistema di componenti pre-costruiti è più piccolo. Se hai bisogno di pattern pesanti di middleware (auth, A/B testing, feature flag su ogni richiesta), il modello server island è meno ergonomico del middleware di Next.js.
L'esperienza TypeScript è anche leggermente indietro. L'inferenza di tipo di Next.js per getStaticProps e server components è più stretta. Il supporto TypeScript di Astro è migliorato massicciamente, ma occasionalmente troverai tipi any nella content layer API che Next.js catturerebbe.
Tuttavia, per siti di contenuto — siti genuinamente content-driven dove il lavoro principale consiste nel renderizzare testo, immagini e dati strutturati — Astro 5 rappresenta lo strumento più efficace nella categoria. La content layer API fornisce la pipeline dati necessaria per i siti di contenuto, i server islands risolvono elegantemente il problema del contenuto dinamico in pagine statiche, e le view transitions conferiscono all'insieme le caratteristiche di una SPA moderna senza il costo del JavaScript.
Costruire una pagina con il nuovo content layer, misurare i tempi di build e controllare i punteggi Lighthouse fornisce dati sufficienti per una decisione informata.