Z PayloadCMS na Astro Content Collections


Po dwóch latach na PayloadCMS przeniosłem całą treść tej strony, którą właśnie czytasz, do plików MDX w repo. Deploy spadł z 4 minut do 1, build skrócił się o ok. 60%, Lighthouse Performance podskoczył o 5 punktów. Straciłem szybką edycję literówki z telefonu i wizualny edytor. W sumie nie żałuję.
To raport z migracji, którą zrobiłem w weekend, i z tego, dlaczego w ogóle ją zaczynałem.
Co mnie wkurzało w Payloadzie
Sam Payload to dobry framework. Miałem na nim pełne portfolio z osobnymi kolekcjami: Pages, Posts, Tags, Projects, Skills, Experiences, Testimonials, Courses, Redirects, Media, Users i API Keys. Działało.

Tylko że każda kolejna sesja edycji kończyła się małym przekleństwem. Cztery rzeczy bolały na tyle, żeby zacząć się rozglądać.
Podnoszenie wersji Payloada. Każdy major to przejście przez breaking changes w schemacie, dostosowanie blocks, sprawdzenie czy migracje bazy nie wywaliły się przy custom fields. Robiłem to z duszą na ramieniu, bo treść siedzi w Postgresie i jak coś się rozsypie, to nie wracam ctrl+z.
Przełączanie języków w edycji. Mam dwa locale, pl i en. Edycja strony to ciągłe klikanie w przełącznik na górze, przejście do tego samego bloku w drugim języku, sprawdzenie czy nie zapomniałem dodać po drodze GridBlocka, którego dorzuciłem chwilę temu w polskiej wersji. Robiłem to setki razy i za każdym razem zapomniałem o czymś przynajmniej raz.

Lexical i jego format wyjściowy. Lexical to domyślny edytor w Payloadzie 3, więc nie kombinowałem i wziąłem co było. W praktyce jego output to drzewo JSON z root, children, węzłami typu paragraph, heading, linebreak i custom nodes, które trzeba renderować po stronie frontu własnym walkerem. Wklejenie kawałka treści z innego źródła zawsze kończyło się ręcznym czyszczeniem. Inline kod w środku zdania to było małe święto.

Backupy i migracje bazy. Co tydzień skrypt dumpował bazę na obiektówkę i czasem restartowałem obraz. Powtarzalne, ale zawsze coś, o czym muszę pamiętać i co może paść w najgorszym momencie.
Iskra: chcę GitOps i Claude Code
Decyzja nie wzięła się z jednego momentu. Stopniowo dochodziło do mnie, że treść tego projektu w stu procentach pasuje do tego, co planuję dla reszty infrastruktury. Część rzeczy chcę przeprowadzić na model GitOps, w którym repo jest źródłem prawdy, a pipeline robi resztę. Skoro tak, to po co treść siedzi w bazie, do której logujesz się przez panel, a nie w plikach obok kodu, które wersjonują się sensownie z diffem.
Drugi powód to Claude Code. Przez ostatnie miesiące zauważyłem, że edytuję treść tej strony coraz częściej z poziomu agenta (testowałem nawet T3 Code jako GUI nad Claude Code na tym samym repo). Poprawiam literówki, dorzucam akapit, zmieniam meta opis. W Payloadzie znaczyło to: zaloguj się do panelu, przeklikaj przez dwa locale, kliknij save. W repo to po prostu edit na pliku MDX i commit. Różnica robi się znacząca, jak robisz to codziennie.
Trzeci powód to wersjonowanie. Diff na zmianie tekstu w PR jest po prostu czytelny. W Postgresie miałem historię wersji w Payloadzie, ale nigdy do niej nie zaglądałem, bo UI tego nie ułatwiał.
Strapi, Sanity, Directus: dlaczego odpadły
Nie skakałem od razu na Astro. Porównałem trzy alternatywy headless, każdą miałem na warsztacie wcześniej.
Strapi. Brakowało mi rekurencyjnie zagnieżdżonych bloków. Nie udało mi się zbudować struktury, w której blok zawiera samego siebie, np. GridBlock → ColumnBlock → GridBlock → ColumnBlock. Bez tego cały design system pada, bo nie składasz layoutów z prymitywów. Może da się to obejść, nie znalazłem czystego sposobu.
Sanity. Za enterprise pod moje potrzeby. Świetne narzędzie do redakcji z procesem akceptacji, draftami i schedulingiem, ale dla portfolio jednoosobowego to przerost. Plus własny język schematu (GROQ + schema.js), w który nie chciało mi się inwestować na nowo.
Directus. Database-first, niby wszystko klikalne w UI, ale brakowało mi spójności typów z frontem. W Payloadzie generowałem typy automatycznie i konsumowałem je w Vue. W Directusie skończyłbym pisząc warstwę pośrednią, która robi to samo. Bez sensu.
Astro Content Collections wygrały dlatego, że w jednym miejscu mam: pliki w repo, walidację Zoda na schemacie kolekcji, automatyczne typy, MDX z komponentami Vue jako wyspami i build statyczny przy deployu.
Jak wygląda struktura po migracji
Wszystko siedzi w katalogu projektu. Posty bloga to src/content/blog-posts/{slug}/index.{pl,en}.mdx, tagi to src/content/blog-tags/{slug}.yaml z tłumaczeniami w środku, projekty portfolio mają własną kolekcję z folderami na zdjęcia, a settings strony to dwa pliki settings.{en,pl}.yaml. Cała schema żyje w src/content.config.ts, więc jak dodam pole, Zod od razu krzyczy w buildzie, jeśli któryś plik nie ma kompletu.
const blogPostSchema = ({ image }: SchemaContext) => z.object({ locale: z.enum(['en', 'pl']), slug: z.string(), title: z.string(), publishedAt: z.coerce.date(), excerpt: z.string().optional(), image: z.object({ src: image(), alt: z.string() }).optional(), tags: z.array(reference('blog-tags')).optional(), faq: z.array(z.object({ question: z.string(), answer: z.string() })).optional(), });Dodanie pola faq to jedna linia w schemie. Od następnego builda Zod sprawdzi każdy plik MDX i zwróci błąd, jeśli któryś ma faq w złym kształcie.
Dla treści, która ma własny folder i zdjęcia (posty, projekty), używam folderu z index.{lang}.mdx i assetami obok. Dla list typu tagi czy redirecty wystarczy płaski yaml. Dla bardziej rozbudowanych encji typu skills, gdzie każdy ma listę technologii i ikon, folder z yamlem.
Najprzyjemniejsze jest to, że jak teraz edytuję post, mam wszystkie warianty językowe i wszystkie zdjęcia w jednym miejscu w eksploratorze plików. W Payloadzie musiałem trzymać Media osobno i pamiętać, do którego posta podpiąłem które zdjęcie.
Migracja: skrypt przez REST i kilka godzin poprawek
Zleciłem Claude Code napisanie skryptu migracyjnego, mając w ręku gotowe definicje collections i globals z Payloada. Format wyjściowy znałem, schema docelowa też była już zdefiniowana, więc skrypt miał głównie mapować pola i pisać MDX z odpowiednimi importami komponentów.
Problemem okazały się media. PayloadCMS trzyma każde zdjęcie w wielu wariantach pod srcset (thumbnail, card, tablet, desktop, każdy w kilku formatach). To kilkaset plików dla kilkudziesięciu zdjęć.

Migracja poszła przez REST API Payloada: pobieram listę mediów, dla każdego ściągam tylko wariant w najwyższej jakości, resztę ogarnia Astro Image przy buildzie. Pojechałem JSON-em z bazy do MDX-a w jednym przebiegu.
const res = await fetch(`${PAYLOAD_URL}/api/media?limit=500`, { headers: { Authorization: `users API-Key ${API_KEY}` },});const { docs } = await res.json();
for (const doc of docs) { const name = doc.filename.replace(/\.[^.]+$/, ''); const ext = doc.mimeType.split('/')[1]; const buf = await fetch(`${PAYLOAD_URL}${doc.url}`).then((r) => r.arrayBuffer()); await writeFile(`./media/${name}.${ext}`, Buffer.from(buf));}doc.url wskazuje na oryginał, ignoruję całą strukturę sizes z miniaturkami. Każdy <Image> po stronie Astro sam wygeneruje srcset i warianty przy buildzie, więc to co Payload trzymał osobno odtwarzam za darmo.
Najtrudniejsze było tłumaczenie outputu z Lexicala na markdown. Konwerter robi obejście dla custom nodes, których nie ma sensu utrzymywać, np. callout blocki z Payloada zamieniłem na komponent <Callout> po stronie frontu.
Ręczne poprawki po migracji zajęły mi może dwie godziny. Głównie linki wewnętrzne (slug się zmienił dla części stron), kilka literówek w aliasach obrazów i jeden post, który miał nieobsługiwany custom block.
Cyferki przed i po
Suchy stan:
- Deploy time: z ok. 4 min do 1 min. Wcześniej build obrazu Dockera z PayloadCMS, push i restart kontenera. Teraz to czysty
astro buildi synchronizacja statyków na hosting. - Build time w Astro: redukcja o ok. 60% względem buildu Payloada. Astro buduje stronę statyczną z wyspami, nie ma overheadu inicjalizacji Express, połączenia z bazą i hot reloadu panelu.
- Lighthouse Performance: skok o ok. 5 punktów. Wynik z tego, że nie ma już serwera Node, który dostaje request, leci do bazy i buduje response. Statyk z CDN i tyle.
Mniej spektakularne, ale realne: koszt utrzymania spadł, bo nie utrzymuję bazy ani osobnej maszyny pod admin panel. Repo, GitHub Actions, hosting statyków.
Czego mi brakuje
Trzeba być uczciwym.
Edycja z telefonu zniknęła. Wcześniej widziałem literówkę w autobusie, otwierałem panel Payloada, poprawiałem i klik. Teraz literówka czeka, aż dojdę do kompa, otworzę IDE, zrobię commit i poczekam na deploy. Czas reakcji wydłużył się z minuty do może godziny.
Wizualny edytor padł. Lexical był denerwujący, ale jednak widać było na bieżąco, jak coś wygląda. MDX w VSCode to surowy tekst. Dla mnie OK, ale jak składam dłuższy artykuł z wieloma obrazkami i komponentami, muszę regularnie patrzeć w preview, żeby zobaczyć rytm. W panelu robiłem to od razu.
Brak panelu dla nie-technicznych. Dla mnie to nie problem, ale gdybym chciał komuś dać prawo edycji, to teraz nie ma jak. W Payloadzie wystarczyło dodać użytkownika.
Dla kogo to ma sens
Plikowy CMS w stylu Astro Content Collections ma sens dla osoby technicznej, która jest swobodna w markdownie i ma jakikolwiek pipeline deploya. GitHub Actions, Ansible, Vercel, Coolify, cokolwiek, co potrafi przebudować stronę po pushu. Jeśli to grasz, zyskujesz spójność z resztą kodu, czyste wersjonowanie i radykalnie prostszy stack.
Odradzam, jeśli treść mają edytować klienci albo osoby, dla których markdown jest barierą. Wtedy każda literówka wraca do ciebie jako zgłoszenie i zysk z prostoty zamienia się w utrzymywanie cudzych wpisów. PayloadCMS jest tu sensowniejszy, a panel daje pewność, że klient nie potrzebuje twojego czasu na korektę.
Drugi case przeciw: duże blogi. Przy kilkuset wpisach w plikach robi się chaos. Sortowanie, wyszukiwanie po metadanych, batch updates, indeksowanie, to wszystko będzie po stronie kodu, którego musisz pilnować. PayloadCMS z wydajnym Postgresem i panelem ogarnia to bez wysiłku.
Dla portfolio, dokumentacji projektu i bloga technicznego do kilkudziesięciu wpisów Astro Content Collections wygrywają. Dla stron klienckich, sklepu czy redakcji zostań przy panelu.
FAQ
Ile dokładnie trwała migracja?
Sam skrypt migracyjny i import treści: jeden weekend, plus dwie godziny ręcznych poprawek w poniedziałek. Wcześniej tydzień rozkminiałem schema kolekcji w Astro, żeby pokryć wszystko co miałem w Payloadzie.
Straciłeś jakąś treść po drodze?
Nie. Dump bazy + REST eksport mediów leżą w archiwum. Skrypt jest deterministyczny, więc gdyby coś padło, mogę go odpalić jeszcze raz. Przez tydzień po migracji trzymałem starego Payloada w trybie tylko do odczytu jako backup.
Jak rozwiązałeś i18n bez panelu?
Każda kolekcja ma osobne pliki per locale (index.pl.mdx, index.en.mdx) albo strukturę translations: { en, pl } w yamlach. Resolver dobiera plik po segmencie [lang] w route. Brakujące tłumaczenia fallbackują na język domyślny, więc dodanie nowego języka to ręczne kopiowanie plików, nie zmiana schematu.
Czy podgląd draftu nadal działa?
W Payloadzie miałem osobny status draft/published. W Astro robię to przez branch w gicie: piszę post na branchu draft/post-name, mam preview deploy z hostingu, po merge'u na main idzie produkcyjnie. Nie ma stanu „draft w bazie”, jest stan „w PR”.
A jeśli będę chciał wrócić do PayloadCMS?
Drogą powrotną byłby skrypt w drugą stronę: czytasz MDX-y, mapujesz na Lexical JSON, pchasz przez REST do Payloada. Schemat Payloada zachowałem w archiwum repo. Nie planuję wracać, ale exit ramp istnieje.
Czemu nie statyczny generator typu Hugo albo Jekyll?
Bo używam Vue jako wysp dla komponentów interaktywnych (formularz kontaktu, viewer 3D, langswitcher) i nie chcę dwóch systemów. Astro 6 daje mi MDX, Vue islands, image optimization i SSR fallback w jednym, więc nie ma powodu schodzić niżej.