React

Content collections in Astro: schema, tipi e query

Una collection è uno schema Zod più un loader: Astro valida i file alla build, genera i tipi e ti dà getCollection. Ecco dove mettere i vincoli.

In sintesi

  • Una collection è uno schema più un loader, non una cartella speciale.
  • Il file di configurazione in Astro 5 è src/content.config.ts.
  • render(entry) sostituisce entry.render() e id sostituisce slug.

Una content collection in Astro è una cartella di file più uno schema Zod: il loader legge i file, lo schema li valida alla build e getCollection() restituisce oggetti tipizzati. Niente frontmatter lasciato al caso.

Un file di configurazione, non una cartella magica

// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const blog = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    publishedAt: z.coerce.date(),
    draft: z.boolean().default(false),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { blog };

Il file va in src/content.config.ts. Astro 4 usava src/content/config.ts: la posizione nuova serve perché una collection può leggere file anche fuori da src/content.

Leggere una collection in una pagina

---
import { getCollection, render } from "astro:content";

const posts = (await getCollection("blog", ({ data }) => !data.draft))
  .sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf());
---


  {posts.map(async (post) => {
    const { Content } = await render(post);
    return {post.data.title};
  })}

Il secondo argomento di getCollection è un filtro in memoria, non una query: la collection resta tipizzata e il filtro riceve già i dati validati. Per un solo elemento usa getEntry("blog", id).

Una route dinamica per ogni entry

---
import type { GetStaticPaths } from "astro";
import { getCollection, render } from "astro:content";

export const getStaticPaths = (async () => {
  const posts = await getCollection("blog", ({ data }) => !data.draft);
  return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}) satisfies GetStaticPaths;

const { post } = Astro.props;
const { Content } = await render(post);
---

Il file [id].astro chiama getStaticPaths e restituisce una entry per ogni post pubblicato: la build scrive una pagina per ciascuna, senza query a runtime. Una collection da 638 file produce 638 pagine, e ogni pagina riceve l’entry già validata in Astro.props.

Dove metti i vincoli

CampoSchemaCosa impedisce
publishedAtz.coerce.date()Una data scritta come stringa che resta stringa
draftz.boolean().default(false)Un articolo senza il campo che rompe la build
coverz.string().startsWith('/')Un percorso esterno che rompe le immagini
authorreference('authors')Un riferimento a un autore inesistente

La validazione avviene quando il loader legge i file: build e dev server. Non è un controllo a runtime, quindi una risposta presa da una API non passa di qui.

node -e "const fs=require('fs');const d='data/blog';let n=0,b=0;for(const f of fs.readdirSync(d)){if(!f.endsWith('.json')||f[0]==='.')continue;n++;b+=JSON.parse(fs.readFileSync(d+'/'+f,'utf8')).body.length;}console.log(n+' files, '+(b/n).toFixed(2)+' blocks each')"

Gli errori che vedi davvero

  • astro sync dimenticato: l’editor non conosce i tipi e segna ogni entry.data come any.
  • Chiave slug usata dopo l’aggiornamento ad Astro 5: il campo ora si chiama id.
  • entry.render() in una pagina Astro 5: la funzione è render(entry), importata da astro:content.
  • Schema lasciato in src/content/config.ts con il loader nuovo: il file non viene letto.
  • Leggere i file con fs dentro la pagina: funziona sul portatile e rompe sull’hosting, dove src/ non esiste a runtime.

La regola di decisione

Regola: se il contenuto vive nel repository e cambia con un commit, glob() è la scelta; se cambia senza deploy, come un prezzo o uno stock, serve un loader che legga da una API e la collection resta il posto dello schema. Il segnale è la frequenza: più aggiornamenti al giorno che deploy al giorno sposta il confine.

Lo schema cresce con il contenuto. Quando i campi superano la decina, estrai gli oggetti condivisi, seo, cover, author, in variabili Zod e riusali fra collection: una modifica al campo alt tocca un punto solo invece di quattro.

Il limite noto

Una collection non è un CMS. Il loader glob() legge file dentro il repository, quindi ogni modifica di contenuto richiede un deploy. Per contenuti che cambiano ogni ora serve un loader remoto o una route API; la collection resta il posto dello schema, non della scrittura.

Comandi

npx astro sync
npx astro check

Dal catalogo: i componenti Astro Association, il blocco logo Astro e glass cards. Le guide: la direttiva client mancante e view transitions in Astro; il confronto: Astro contro Next.js sulla documentazione.

Domande frequenti

Dove va il file di configurazione in Astro 5?

In src/content.config.ts. Astro 4 usava src/content/config.ts: quel percorso funziona ancora, ma il nuovo è quello documentato.

Posso usare le collection senza Zod?

Sì, lo schema è facoltativo. Senza schema perdi la validazione e i tipi generati, quindi tieni almeno i campi che usi nel template.

Le collection funzionano con MDX?

Sì. Il pattern del loader glob deve includere mdx, altrimenti quei file restano fuori dalla collection.

Come filtro gli articoli non pubblicati?

Aggiungi un campo booleano allo schema, per esempio draft con valore predefinito false, e filtra dentro getCollection.

Meglio una collection o una route API?

Una collection per file versionati nel repository, una route API per contenuti che cambiano senza deploy. Lo schema serve in entrambi i casi.