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ą 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
Wszystkie kolekcje definiujesz w jednym pliku: src/content.config.ts. Poniżej minimalny przykład dla bloga:
Code
// src/content.config.tsimport { 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ę.
---title: 'Mój pierwszy wpis'description: 'Krótki opis dla SEO i OG tags.'date: 2026-04-24tags: ['astro', 'poradnik']---# Treść wpisuTutaj normalna treść Markdown/MDX.
Generowanie stron z kolekcji
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:
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
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:
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
Chcesz, żeby artykuły miały autora, a autorzy byli osobną kolekcją? Zod + Astro mają na to wbudowany mechanizm, zwany reference().
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
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:
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 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 Storyblokimport { 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 Content Collections
Content Collections świetnie integrują się z generowaniem . W layoucie dla artykułu:
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, 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
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ą.
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 rozwija własne projekty side-projecty.