Przejdź do treści
Wszystkie wpisy
Inżynieria 20 maja 2026

Zero JS frameworka na firmowej stronie: jak postawiłem softpapaya.com na Astro 5 + Cloudflare Pages

Bez Reacta. Bez Next.js. Astro 5 + Cloudflare Pages, 126 KiB total, Lighthouse mobile 97/96/100/100. Stack softpapaya.com bez ściemy.

Marcin Balazy 9 min czytania
Panel z wynikami Lighthouse 97/96/100/100 obok terminalowej listy stacku - Astro 5, Tailwind 4, Cloudflare Pages, zero JS frameworka
Panel z wynikami Lighthouse 97/96/100/100 obok terminalowej listy stacku - Astro 5, Tailwind 4, Cloudflare Pages, zero JS frameworka

softpapaya.com waży 126 KiB na homepage, Lighthouse mobile wbija 97/96/100/100 i nie ma żadnego JS frameworka po stronie klienta.

Sajt jest dwujęzyczny, ma CMS, listing rekrutacyjny, case studies, bloga i standardowe strony prawne. Niżej: stack jaki wybrałem i kawałki które zajęły więcej niż się spodziewałem.

Co znaczy “zero JS frameworka”

Przez “zero” rozumiem brak Reacta, Vue, Svelte i Solida w przeglądarce. Strony piszemy w .astro, które przy buildzie kompiluje się do czystego HTML i CSS. Jak sekcja faktycznie potrzebuje interaktywności, wrzucasz <script> i go piszesz. To jest furtka awaryjna.

W praktyce w całym sajcie poza wyszukiwarką jest dokładnie jeden client-side script: BlogSearchModal.astro emituje inline JS, który podpina skrót klawiszowy i toggle modala. Pagefind ma własny worker, ale ładuje się tylko na /blog/*, leniwie, dopiero po otwarciu modala. Cała reszta routów - homepage, usługi, kariera, case studies, wszystkie dynamiczne routy bloga gdy modal search jest zamknięty - leci z zerem JS frameworka.

inlineStylesheets: "always" w astro.config.ts wpycha CSS prosto do HTML-a, więc nie ma drugiego round tripu na style. Na produkcyjnym homepage: 0 KB nieużywanego JS, 0.0s bootup time, 0 ms total blocking time.

Inna historia jak masz real-time state albo wielokrokowy formularz z walidacją. Tam framework ma sens. Strona contentowa go nie potrzebuje.

Wybór stacku: Astro 5 + Cloudflare Pages

Wymagania:

  • Dwie wersje językowe, polski default, angielski drugi
  • CMS, który da się obsłużyć bez devopsa i bez infrastruktury
  • Sitemapa, OG meta, JSON-LD, podstawa SEO
  • Tanie hostowanie, najlepiej edge
  • Furtka do interaktywności jak kiedyś będzie potrzebna

Astro 5.18.0 ogarnia większość tego out of the box. Pliki .astro server-renderują się do HTML, komponenty są framework-agnostic, a w momencie, w którym naprawdę będziesz potrzebował Reacta albo Svelte, robisz astro add i nic nie przepisujesz. Nie musieliśmy. Całe drzewo komponentów, łącznie z adminowym shellem, to .astro plus inline <script> w dwóch miejscach.

Pod hosting: @astrojs/cloudflare 12.6.12 ze standardowym setupem pages_build_output_dir. Edge SSR przez Cloudflare Workers, free tier obsługuje nasz traffic, Wrangler 4.73 w CI deployuje. Cały wrangler.jsonc:

{
  "name": "pstechnologies",
  "pages_build_output_dir": "./dist",
  "compatibility_date": "2026-03-06",
  "compatibility_flags": ["nodejs_compat"]
}

Tailwind 4.2.1 jest teraz Vite pluginem, nie krokiem PostCSS, co ścięło jedną warstwę konfiguracji. Zod 3.25.76 trzyma 16 content collections (careers, case-studies, services, team, testimonials, blog i reszta), wszystkie walidowane przy buildzie. Jak editor CMS-a wpisze nieprawidłową datę w frontmatter, build wybucha.

i18n: prefixDefaultLocale: false. Polski siedzi na /, angielski na /en/*. Polski rynek jest priorytetem, więc dostaje URL-e bez prefixu.

Nie robiłem benchmarków Next, Remix i SvelteKit obok siebie - wybór był pragmatyczny. Projekt nie potrzebuje routera, runtime’u ani rehydration, a Astro pozwala mi to shipnąć bez nich. Jak kiedyś urośnie w coś stanowego, dorzucenie React islands to jedno astro add.

Pułapka: middleware nie odpala się na prerendered routes

Adapter Cloudflare na prerendered output ugryzł nas na początku. Oryginalny setup: CSP w src/middleware.ts. W astro dev każdy request przechodzi przez middleware, nagłówki były widoczne w network tab dev toolsów. Po pierwszym deployu na proda CSP zniknęło z homepage, /o-nas, /uslugi i reszty statycznych stron.

Adapter generuje dist/_routes.json, który wyklucza prerendered ścieżki z worker pipeline. Worker w ogóle nie widzi tych requestów, więc middleware się nie odpala. Docs Astro i README adaptera to wspominają; nie doczytałem za pierwszym razem.

Fix dwuczęściowy. public/_headers zostaje source of truth dla globalnych nagłówków na produkcji - Cloudflare Pages serwuje to bezpośrednio. Middleware trzyma kopię tego samego CSP dla astro dev i ewentualnych przyszłych SSR-only routów. Żeby pilnować zgodności, jest parity test (src/lib/__tests__/headers-parity.test.ts), który się wywala, jak oba stringi się rozjadą. Komentarz w middleware mówi to wprost:

// PUBLIC_CSP is duplicated by public/_headers, which is the source of truth
// in production - middleware doesn't fire for prerendered routes excluded
// by dist/_routes.json. The middleware copy keeps headers present in
// `astro dev` and on any future SSR-public route.

Jak wybierasz SSR adapter, weryfikuj zachowanie produkcji przez produkcyjny runtime, nie dev server. astro dev to Node, wrangler pages dev ./dist to ten sam Worker co na produkcji.

Sveltia CMS na gicie, bez bazy danych

CMS-em jest Sveltia podpięta do tego samego repo na GitHubie. Editor loguje się przez GitHub OAuth, wybiera kolekcję (blog post, oferta pracy, członek zespołu), edytuje formularz wyrenderowany ze schematu Zod, klika publish. CMS commituje zmianę na branch staging.

Treść siedzi w katalogu src/content/<collection>/{pl,en}/ jako pliki markdown z frontmatterem YAML, walidowane przez Zod gdy Astro buduje. Bazy danych w projekcie nie ma w ogóle.

Schemat wpisu bloga:

const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    lang: z.enum(["pl", "en"]),
    author: z.string(),
    category: z.string(),
    tags: z.array(z.string()).default([])
      .transform((tags) => tags.filter((t) => t.trim().length > 0)),
    cover: z.string(),
    coverAlt: z.string(),
    excerpt: z.string(),
    publishedAt: z.coerce.date(),
    updatedAt: z.coerce.date().optional(),
    draft: z.boolean().default(false),
    translationOf: z.string().optional(),
  }),
});

Ten schemat odpala się przy buildzie i wystawia typowane obiekty przez astro:content do komponentów stron. Nie ma osobnej warstwy danych do synchronizacji z prod DB, bo nie ma prod DB.

Flow editorski end to end:

  1. Editor wchodzi na /admin/cms, edytuje, klika “Publish Changes to Staging”
  2. Sveltia commituje na branch staging
  3. GitHub Action odpala preview build i deployuje na staging.pstechnologies.pages.dev
  4. Editor robi review na stagingu, idzie na /admin/prod, klika publish
  5. Inny GitHub Action mergeuje staging do main, deployuje produkcję, resetuje staging z powrotem do main

Całość lata na darmowych GitHub Actions minutach i free tierze Cloudflare Pages. Brak miesięcznego rachunku za SaaS. Trade-off: nie ma real-time collaboration i jest 2-3 minutowy lag między publish a działającym URL-em. Edytorzy którzy potrzebują live preview tego nie zniosą.

Trzy workflows GitHub Actions, jedna kolejka

CI/CD jest celowo prosty. Trzy workflows:

  • test.yml - typecheck, unit testy (Vitest), npm run build i E2E Playwright na każdy push do main/staging i każdy PR do main
  • deploy.yml - deploy na produkcję na push do main albo na repository_dispatch typu deploy-production (drugie to to, co odpala przycisk /admin/prod przez GitHub API)
  • preview.yml - deploy brancha staging na preview alias na repository_dispatch typu sveltia-cms-publish

Oba deployowe workflows dzielą concurrency group cms-staging. Jeden deploy na raz. Jak editor kliknie publish trzy razy z rzędu, requesty ustawiają się w kolejkę i wygrywa ostatni - to co było w połowie pipeline’u dokończy się przed startem nowego.

Flow CMS-publish-to-prod w deploy.yml jest ciekawszy:

- name: Merge staging into main (local only)
  if: github.event_name == 'repository_dispatch'
  run: |
    git fetch origin staging
    echo "STAGING_SHA_AT_MERGE=$(git rev-parse origin/staging)" >> $GITHUB_ENV
    if git merge origin/staging --ff-only 2>/dev/null; then
      echo "Fast-forward merge succeeded"
    else
      git checkout -B staging-rebase origin/staging
      git rebase origin/main
      git checkout main
      git merge staging-rebase --ff-only
    fi

Fast-forward jeśli się da, rebase staging na main jako fallback. Po deployu reset staging do main przez force-push, ale tylko jeśli staging nie zyskał nowych commitów odkąd zaczął się merge. Jeśli zyskał, leci warning i reset skipuje. Ten guard powstał, bo pierwsza wersja workflow zjadła komuś pending CMS edits, gdy staging dostał commity między mergem a resetem.

Node 22, npm ci, npx astro sync przed typecheckiem, żeby typy się wygenerowały. Cały pipeline produkcyjny przechodzi w minutę lub dwie.

Feature flag opt-out: PUBLIC_BLOG_ENABLED

Blog był największą pojedynczą funkcją w projekcie - Pagefind search, pagination, filtry po kategoriach i tagach, sporo komponentów i page wrapperów. Musiał siedzieć na branchu main dużo wcześniej, niż miał trafić publicznie na softpapaya.com. Wybór był: długo żyjący feature branch (merge hell) albo kill switch.

Poszedłem w kill switch. Pattern jest opt-out: PUBLIC_BLOG_ENABLED nieustawiony albo cokolwiek poza literalnym "false" oznacza widoczny. Tylko "false" ukrywa. Default w dev musi być “widoczny”, bo inaczej devy zapominają o fladze i przypadkowo pushują puste strony. Cały parser:

export function parseBlogFlag(raw: string | undefined | null): boolean {
  if (typeof raw !== "string") return true;
  return raw.trim().toLowerCase() !== "false";
}

Co flaga robi w trybie off:

  • Wyrzuca “Blog” z nawigacji (getNavItems filtruje)
  • Ukrywa sekcję latest-posts na homepage
  • Zwraca [] z getStaticPaths w każdym dynamicznym routcie bloga
  • Emituje Response(404) z dwóch index .astro plików
  • Filtruje URL-e /blog z sitemap
  • Pomija build Pagefind indexu (skrypt npm run build ma guard na istnienie dist/blog)

W trybie off na buildzie produkcyjnym pod /blog/ nic w ogóle nie generuje się. Plików po prostu nie ma w dist/. Side effect: nie da się trafić w draft content zgadując URL, bo nie ma pliku do podania.

Flaga siedziała w deploy.yml jako build-time env var (PUBLIC_BLOG_ENABLED: "false" na kroku budowania proda), aż do launchu. Staging i lokalne dev nigdy jej nie ustawiały, więc edytorzy i deweloperzy zawsze widzieli bloga. Launch był jednolinijkowym PR-em usuwającym tę linię. Cofnięcie byłoby identycznym jednolinijkowym PR-em.

Po kilku tygodniach stabilnego proda helper i jego guardy można skasować.

Liczby z prod i czego bym nie powtórzył

Lighthouse mobile, polska homepage, postdeploy 2026-05-07:

  • Performance 97
  • Accessibility 96
  • Best Practices 100
  • SEO 100
  • LCP 2.1s, FCP 1.5s, TBT 0 ms, CLS 0, Speed Index 3.7s
  • Total page weight 126 KiB, unused JS 0 KB, bootup time 0.0s
  • Bundle SSR workera 5.8 KB

96 na accessibility to brand color: burn #ff5500 nie trafia w WCAG AAA na każdym rozmiarze tekstu i tak go zostawiamy. Brand jest dla nas ważniejszy niż ten ostatni punkt contrast score.

Co bym zrobił inaczej:

  1. Ustawiłbym public/_headers jako source of truth dla CSP od pierwszego dnia, zamiast walczyć tydzień z route exclusion w middleware SSR
  2. Pagefind odłożyłbym na po launchu. To dobry kawałek softu, ale lokalny smoke wymaga npm run build && wrangler pages dev ./dist, bo index searchowy buduje się po Astro przez osobne wywołanie pagefind. astro dev nie poda działającego modala search.

Co zostawiam:

  1. Astro 5 + Cloudflare Pages dla tej klasy sajtu. Kombinacja jest naprawdę dobra
  2. Sveltia CMS na gicie, dla zespołów które tolerują 2-3 minutowy lag na publish
  3. Pattern feature flagi opt-out. Build za flagą, flipnij ją kiedy gotowe, skasuj jak prod się ustabilizuje
  4. concurrency: cms-staging na obu deploy workflows. Jedna kolejka, brak race conditions

Stack pasuje do klasy “strona firmowa z CMS-em”. Pod aplikację z real-time stanem albo ciężką interaktywnością klienta wziąłbym pewnie coś innego, najpewniej Next albo Remix.

Podaj dalej
X LinkedIn
O autorze

Marcin Balazy

SoftPapaya

Czytaj dalej
Kariera

Pracujemy nad ciekawymi rzeczami. Może z tobą?

Szukamy ludzi do zespołu, którzy lubią takie posty czytać i takie posty pisać. Engineering, design, devrel - sprawdź otwarte stanowiska.