Przejdź do treści

Astro Content Collections — typowany blog z walidacją Zod od podstaw

Błędny frontmatter w Astro wykryty dopiero na produkcji? Content Collections z Zod sprawdzają schematy już w edytorze. Jak to skonfigurować?

Maciej Sala

Founder StriveLab

7 min czytaniaOpublikowano 24 kwietnia 2026 (Aktualizacja 9 czerwca 2026)

W tym artykule buduję system treści w Astro od pustej kolekcji do setupu, który nadaje się już do normalnej pracy. Będzie tutaj Zod, referencje między kolekcjami, typowane zapytania i błędy wyłapywane przed deployem.

Czym są Astro Content Collections?

Content Collection to uporządkowana grupa treści: wpisy blogowe, dokumentacja, strony produktowe, profile autorów. Astro ładuje je z plików albo zewnętrznych źródeł, przepuszcza przez schemat Zod i dopiero wtedy oddaje do szablonów. Błędny frontmatter nie przecieka na produkcję. Build pada wcześniej, z komunikatem, który da się szybko i łatwo poprawić.

W Astro 6 Content Collections są podzielone na dwa tryby: build-time (klasyczny, domyślny — dla blogów, dokumentacji) i live (runtime, dla danych zmieniających się w czasie rzeczywistym). W tym artykule skupimy się głównie na build-time, a Live Content Collections omówię w osobnej sekcji.

Cały mechanizm sprowadza się do jednego przepływu: źródło danych przechodzi przez loader, potem przez schemat Zod, a dopiero zwalidowane wpisy trafiają do typowanego store, z którego korzystają szablony. Jeśli walidacja nie przejdzie, build się zatrzymuje — błędne metadane nie mają jak dotrzeć do produkcji.

Diagram
Przepływ danych w Content Collections: walidacja Zod działa jak bramka przed buildem.

Konfiguracja kolekcji w Astro Content Collections

Wszystkie kolekcje definiujesz w jednym pliku: src/content.config.ts. Poniżej minimalny przykład dla bloga:

Code
// src/content.config.ts
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'
 
const blog = defineCollection({
  loader: glob({
    pattern: '**/*.mdx',
    base: './src/content/blog',
  }),
  schema: z.object({
    title: z.string().max(80),
    description: z.string().min(50).max(160),
    date: z.coerce.date(),
    author: z.string().default('Maciej Sala'),
    tags: z.array(z.string()),
    image: z.string().optional(),
    draft: z.boolean().default(false),
    seo_aeo_geo: z.boolean().default(false),
  }),
})
 
export const collections = { blog }

Co tu się dzieje:

  • glob, który przechodzi po plikach .mdx w src/content/blog i traktuje każdy jako osobny entry w kolekcji.
  • schema: z.object({...}) — definicja pól, jakie musi mieć frontmatter każdego pliku. Zod waliduje typy i wartości.
  • z.coerce.date() — Zod weźmie "2026-04-24" jako string i skonwertuje do obiektu Date. W szablonie masz gotowy obiekt do formatowania.
  • z.string().default('Maciej Sala') — jeśli autor nie jest podany, wtedy Astro użyje default.
  • draft: z.boolean().default(false) — pole do filtrowania szkiców.

Plik MDX bez title albo z description krótszym niż 50 znaków? Build zatrzymuje się z czytelnym komunikatem. W CI taki artykuł nie trafia na produkcję.

Struktura plików dla bloga w Astro

Dla powyższej konfiguracji struktura wygląda tak:

Code
src/
├── content/
│   └── blog/
│       ├── astro-6-przewodnik.mdx
│       ├── islands-architecture.mdx
│       └── content-collections-tutorial.mdx
├── content.config.ts
└── pages/
    └── blog/
        ├── index.astro         ← lista artykułów
        └── [...id].astro       ← pojedynczy artykuł

Każdy plik MDX zaczyna się od frontmatteru YAML:

Code
---
title: 'Mój pierwszy wpis'
description: 'Krótki opis dla SEO i OG tags.'
date: 2026-04-24
tags: ['astro', 'poradnik']
---
 
# Treść wpisu
 
Tutaj normalna treść Markdown/MDX.

Generowanie stron z Content Collections

Kolekcja sama w sobie nie tworzy żadnych URL-i, ponieważ są to tylko typowane dane. Strony powstają w getStaticPaths, gdzie z każdego wpisu robisz osobną trasę. Astro buduje je statycznie w czasie builda, więc czytelnik dostaje gotowy HTML bez ani jednego zapytania w runtime:

Code
---
// src/pages/blog/[...id].astro
import { getCollection, render } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
 
export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) => !data.draft);
  return posts.map((post) => ({
    params: { id: post.id },
    props: { post },
  }));
}
 
const { post } = Astro.props;
const { Content } = await render(post);
---
 
<Layout title={post.data.title} description={post.data.description}>
  <article>
    <header>
      <h1>{post.data.title}</h1>
      <time datetime={post.data.date.toISOString()}>
        {post.data.date.toLocaleDateString('pl-PL', {
          year: 'numeric',
          month: 'long',
          day: 'numeric',
        })}
      </time>
    </header>
 
    <Content />
  </article>
</Layout>

Kilka rzeczy wartych uwagi:

  • getCollection('blog', filterFn) — zwraca tablicę wszystkich entries z kolekcji, z opcjonalnym filtrem. Tutaj filtrujemy szkice.
  • post.id — Astro automatycznie generuje z nazwy pliku. astro-6-przewodnik.mdx → slug astro-6-przewodnik.
  • await render(post) — kompiluje MDX do komponentu Astro z nazwą Content, który renderuje treść.
  • Wszystkie pola (post.data.title, post.data.date) są typowane — TypeScript wie, co Zod zwalidował.

Lista artykułów z Astro Content Collections

Strona z listą to ten sam getCollection, tylko zamiast budować trasy, po prostu sortujesz wpisy i renderujesz je w pętli. Filtr szkiców działa identycznie, a post.data jest cały czas typowane. IDE podpowie Ci każde pole:

Code
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
 
const posts = await getCollection('blog', ({ data }) => !data.draft);
const sorted = posts.sort(
  (a, b) => b.data.date.getTime() - a.data.date.getTime()
);
---
 
<Layout title="Blog — StriveLab">
  <h1>Blog</h1>
  <ul>
    {sorted.map((post) => (
      <li>
        <article>
          <a href={`/blog/${post.id}/`}>
            <h2>{post.data.title}</h2>
            <p>{post.data.description}</p>
            <time>{post.data.date.toLocaleDateString('pl-PL')}</time>
            <ul class="tags">
              {post.data.tags.map((tag) => <li>{tag}</li>)}
            </ul>
          </a>
        </article>
      </li>
    ))}
  </ul>
</Layout>

Rozwiązanie jest proste i w pełni typowane, a przy tym nie wymaga zewnętrznych bibliotek do parsowania Markdown ani ręcznego budowania pipeline'u. Wszystko, czego potrzeba, jest już wbudowane w Astro.

Referencje między kolekcjami w Astro

Chcesz, żeby artykuły miały autora, a autorzy byli osobną kolekcją? Zod + Astro mają na to wbudowany mechanizm, zwany reference().

Code
// src/content.config.ts
import { defineCollection, reference } from 'astro:content'
import { glob, file } from 'astro/loaders'
import { z } from 'astro/zod'
 
const authors = defineCollection({
  loader: file('src/content/authors.json'),
  schema: z.object({
    id: z.string(),
    name: z.string(),
    bio: z.string(),
    avatar: z.string(),
    linkedin: z.string().url().optional(),
  }),
})
 
const blog = defineCollection({
  loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    author: reference('authors'), // ← referencja
    relatedPosts: z.array(reference('blog')).optional(),
    tags: z.array(z.string()),
  }),
})
 
export const collections = { authors, blog }

I src/content/authors.json:

Code
[
  {
    "id": "maciej-sala",
    "name": "Maciej Sala",
    "bio": "Founder StriveLab, frontend dev specjalizujący się w Astro i Next.js.",
    "avatar": "/images/authors/maciej.jpg",
    "linkedin": "https://linkedin.com/in/maciej-sala"
  }
]

Teraz w artykule w frontmatterze wpisujesz tylko ID autora:

Code
---
title: 'Mój wpis'
author: 'maciej-sala'
---

A w szablonie:

Code
---
import { getCollection, getEntry, render } from 'astro:content';
 
const post = // ... pobierasz post
const author = await getEntry(post.data.author);
---
 
<article>
  <h1>{post.data.title}</h1>
 
  <div class="author">
    <img src={author.data.avatar} alt={author.data.name} />
    <div>
      <p>{author.data.name}</p>
      <p>{author.data.bio}</p>
    </div>
  </div>
</article>

Referencje działają również dla tablic — możesz zdefiniować relatedPosts: z.array(reference('blog')) i trzymać listę powiązanych artykułów. Zod waliduje, że ID naprawdę istnieją w kolekcji, a literówkę w referencji wyłapuje się w build time.

Zaawansowana walidacja Zod w Content Collections

Zod daje Ci dużo więcej niż z.string(), a oto wzorce, których używam:

Code
const blog = defineCollection({
  loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
  schema: z.object({
    // Długość tytułu idealna dla SEO
    title: z.string().min(30).max(80),
 
    // Meta description w granicach, które Google wyświetla
    description: z.string().min(50).max(160),
 
    // Data w przeszłości
    date: z.coerce
      .date()
      .refine(
        (d) => d <= new Date(),
        'Data publikacji nie może być w przyszłości',
      ),
 
    // Enum tagów — błąd w build, jeśli ktoś wpisze nieistniejący tag
    tags: z
      .array(
        z.enum(['astro', 'next-js', 'react', 'seo', 'poradnik', 'javascript']),
      )
      .min(1)
      .max(5),
 
    // URL obrazu musi być lokalny lub HTTPS
    image: z
      .string()
      .refine(
        (url) => url.startsWith('/') || url.startsWith('https://'),
        'Obraz musi być lokalny lub HTTPS',
      )
      .optional(),
 
    // Czas czytania w minutach
    readingTime: z.number().int().positive().optional(),
 
    // Opcjonalny tytuł na social media (może być inny niż główny)
    ogTitle: z.string().max(60).optional(),
  }),
})

Każde pole wyłapuje inną klasę błędów: Enum tagów jest istotny przy większej liczbie artykułów, a bez niego mogą pojawiać się duplikaty w stylu „Next.js", „NextJS", „nextjs", które rozjeżdżają filtry i kategorie tagów.

Live Content Collections w Astro 6

Astro 6 wprowadza stabilne Live Content Collections dla danych, które zawsze muszą być świeże. Konfiguracja jest w osobnym pliku src/live.config.ts:

Code
// src/live.config.ts
import { defineLiveCollection } from 'astro:content'
import { z } from 'astro/zod'
 
const products = defineLiveCollection({
  loader: {
    name: 'products-api-loader',
    loadCollection: async () => {
      const response = await fetch('https://api.example.com/products')
      const products = await response.json()
      // Każdy entry musi mieć kształt { id, data }
      return {
        entries: products.map((product) => ({
          id: String(product.id),
          data: product,
        })),
      }
    },
    loadEntry: async ({ filter }) => {
      const response = await fetch(
        `https://api.example.com/products/${filter.id}`,
      )
      const product = await response.json()
      return { id: String(product.id), data: product }
    },
  },
  schema: z.object({
    id: z.string(),
    name: z.string(),
    price: z.number(),
    inventory: z.number(),
    updatedAt: z.coerce.date(),
  }),
})
 
export const liveCollections = { products }

W komponentach używasz tego przez getLiveCollection i getLiveEntry:

Code
---
import { getLiveCollection } from 'astro:content';
 
const { entries, error } = await getLiveCollection('products');
 
if (error) {
  console.error('Nie udało się pobrać produktów:', error);
}
---
 
{entries?.map((product) => (
  <div>
    <h3>{product.data.name}</h3>
    <p>{product.data.price} zł</p>
    <p>W magazynie: {product.data.inventory}</p>
  </div>
))}

W odróżnieniu od build-time collections, dane pobierane są w runtime i dlatego strona musi renderować się on-demand (output: 'server' albo trasa z prerender = false). W innym wypadku zapytanie wykona się raz, podczas builda. Używaj tego do rzeczy, które realnie muszą być aktualne (stan magazynu, ceny, oferta w czasie rzeczywistym).

Integracja Content Collections z istniejącym CMS

Community zbudowało loadery dla popularnych : Sanity, Contentful, Storyblok, Strapi, Notion. Instalujesz pakiet, podajesz klucze , dostajesz typowaną kolekcję.

Code
// src/content.config.ts — z loaderem Storyblok
import { defineCollection } from 'astro:content'
import { storyblokLoader } from '@storyblok/astro'
import { z } from 'astro/zod'
 
const articles = defineCollection({
  loader: storyblokLoader({
    accessToken: process.env.STORYBLOK_TOKEN,
    contentTypes: ['article'],
    version: 'published',
  }),
  schema: z.object({
    title: z.string(),
    body: z.string(),
    seo: z.object({
      title: z.string(),
      description: z.string(),
    }),
  }),
})

W praktyce dla małych i średnich blogów rekomenduję pozostanie przy MDX w repo. Oznacza to wersjonowanie w Git, brak zewnętrznych zależności i pełną kontrolę. Z kolei dla zespołów z edytorami/copywriterami, którzy nie mają dostępu do repo, można użyć CMS.

SEO i structured data z Astro Content Collections

Content Collections świetnie integrują się z generowaniem . W layoucie dla artykułu:

Code
---
// src/layouts/BlogPost.astro
const { post, author } = Astro.props;
 
const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.data.title,
  description: post.data.description,
  datePublished: post.data.date.toISOString(),
  author: {
    '@type': 'Person',
    name: author.data.name,
    url: author.data.linkedin,
  },
  image: post.data.image ? `https://example.com${post.data.image}` : undefined,
};
---
 
<script
  type="application/ld+json"
  set:html={JSON.stringify(jsonLd)}
/>

Ponieważ wszystko jest typowane, TypeScript łapie wszelakie literówki i brakujące pola zanim strona pojawi się na produkcji. To element, który opisuję szerzej w artykule o SEO w Astro.

Pułapki Astro Content Collections, na które warto uważać

Kilka błędów wraca w niemal każdym projekcie. Większość kosztuje godzinę debugowania, choć każdy z nich da się uprzedzić jednym zdaniem.

Mieszanie id i slug w routingu. Plik trasy [...id].astro musi mieć params: { id: post.id }. Jeśli nazwiesz parametr slug, a w pliku użyjesz [...id] (albo odwrotnie), Astro nie znajdzie dopasowania i strony po prostu się nie wygenerują. Trzymaj jedną konwencję w całym projekcie — id jest tą zalecaną przez dokumentację.

Brak prerender = false przy Live Collections. Live Collections pobierają dane w runtime, więc strona, która z nich korzysta, musi renderować się na żądanie. Bez output: 'server' albo export const prerender = false zapytanie wykona się raz, podczas builda, i „świeże" dane zamrożą się na moment deployu, czyli dokładnie to, czego chciałeś uniknąć.

z.coerce.date() a strefy czasowe. Zapis date: 2026-04-24 w YAML parsuje się jako północ UTC. Jeśli formatujesz datę lokalnie w strefie ujemnej względem UTC, wpis potrafi „cofnąć się" o jeden dzień. Przy datach publikacji nie jest to jakiś problem, ale przy harmonogramach czy embargach warto zapisywać pełny timestamp ze strefą.

Zbyt restrykcyjny enum tagów na starcie. Enum świetnie eliminuje duplikaty, ale jeśli zamkniesz listę zbyt wcześnie, każdy nowy temat wymaga zmiany w content.config.ts i ponownego builda. Na małym blogu to akceptowalne, ale przy większym zespole rozważ osobną kolekcję tagów z reference(), żeby dodanie tagu nie było zmianą w kodzie.

Kontrakt redakcyjny dla typowanego bloga w Astro

Prawdziwa moc Content Collections ujawnia się, gdy ich schemat staje się umową z autorem treści.

  • title i description ustaw jako wymagane, ponieważ bez nich i podglądy w social mediach zaczynają się sypać.

  • date waliduj przez z.coerce.date(), żeby zwykły tekst nie przeszedł jako data.

  • tags trzymaj w ograniczonej liście, bo inaczej szybko dostaniesz trzy wersje tego samego tagu: „Next.js", „NextJS" i „nextjs".

  • canonical oraz redirectFrom dopisz do schematu, jeśli zmieniasz slugi albo migrujesz treści.

  • pola robocze typu featured czy draft też trzymaj w schemacie, ponieważ wtedy wyróżnianie wpisów i ukrywanie szkiców jest typowane, co przekłada się na większy porządek.

Ultraszybkie projekty, łączące lekkość ze skalowalnością.
Astro

Często zadawane pytania

Czy muszę używać TypeScript, żeby korzystać z Content Collections?

Nie, ponieważ Content Collections działają też w JavaScript, ale tracisz autocomplete i inferencję typów. TypeScript to zero kosztu wejścia przy ogromnym zysku - wyłapujesz błędy w metadanych w IDE, więc produkcja jest bezpieczna.

Czy Content Collections wspierają zwykły Markdown, czy tylko MDX?

Wspierają oba formaty. MDX pozwala osadzać komponenty w treści, a Markdown jest lżejszy i prostszy. Dla bloga technicznego z interaktywnymi elementami najlepszy będzie MDX. Dla czystego contentu z kolei Markdown. Temat MDX omawiam szerzej w osobnym artykule.

Co się dzieje, jeśli plik MDX nie przechodzi walidacji Zod?

Build zatrzymuje się z czytelnym komunikatem: wskazuje plik, pole, jaki błąd. Przykład: „Error in src/content/blog/post.mdx: Expected string, received number at 'title'". Pipeline CI blokuje deployment.

Jak często walidacja kolekcji jest uruchamiana?

Podczas każdego astro build i astro dev. W trybie dev walidacja odpala się przy każdej zmianie pliku kolekcji, w wyniku czego błędy pojawiają się natychmiast w terminalu i przeglądarce.

Czy mogę mieszać kolekcje z różnych źródeł (pliki + API)?

Tak. Każda kolekcja ma własny loader. Jedna ładuje z MDX, druga z JSON, trzecia z API przez custom loader. W szablonach traktujesz je identycznie: getCollection(name) zwraca typowaną tablicę niezależnie od źródła.

Dlaczego moje strony z kolekcji się nie generują?

Najczęstsza przyczyna to niezgodność nazwy parametru trasy z kluczem w getStaticPaths. Plik [...id].astro wymaga params: { id: post.id }, a jeśli plik nazywa się [...slug].astro, a zwracasz id (lub odwrotnie), Astro nie dopasuje tras. Dokumentacja zaleca id, więc trzymaj się tej konwencji.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Astro

Czytaj dalej

Zobacz więcej wpisów
Astro 6 — przewodnik po nowościach: Cloudflare Workers, Live Content Collections, Fonts API i CSP

Rynek frameworków podzielił się wyraźnie: Vercel + Next.js kontra Cloudflare + Astro. Astro 6 wprowadza dużo wartościowych nowości, które wyraźnie zmieniają model pracy. W tym wpisie pokazuję, co realnie zmieniło się z perspektywy technicznej i co musisz sprawdzić przed migracją.

Maciej Sala

Maciej Sala

Founder StriveLab

Astro i Headless CMS: Integracja Sanity, Storyblok, Strapi

Markdown w plikach projektu działa sprawnie, dopóki treść piszą osoby, które swobodnie pracują w Git. W sytuacji, kiedy klient chce sam zmienić cennik, a redaktor poprawia nagłówek w piątek po południu, repozytorium przestaje być wygodnym CMS-em. Wtedy najlepszy będzie headless CMS, czyli panel dla redakcji i API dla Astro.

Maciej Sala

Maciej Sala

Founder StriveLab

SEO w Astro — Core Web Vitals, dane uporządkowane i techniczny fundament rankingu w 2026

Astro dostarcza przyjazność SEO już na poziomie architektury, gwarantując świetne wyniki w Lighthouse dzięki brakowi domyślnego JavaScriptu i statycznemu renderowaniu HTML. Jednak prawdziwa widoczność w Google oraz systemach AI to efekt świadomych decyzji inżynieryjnych, a nie tylko wyboru frameworka. W tym artykule rozbieram na części pierwsze każdą z nich: od Core Web Vitals, przez metadane i schema.org , aż po optymalizację GEO / AEO .

Maciej Sala

Maciej Sala

Founder StriveLab