Skip to content
All posts
Engineering May 20, 2026

Zero JS Framework on a Company Site: Shipping softpapaya.com on Astro 5 + Cloudflare Pages

No React. No Next.js. Astro 5 + Cloudflare Pages, 126 KiB total, Lighthouse mobile 97/96/100/100. The softpapaya.com stack with no bullshit.

Marcin Balazy 10 min read
Lighthouse score panel showing 97/96/100/100 next to a terminal-style stack list - Astro 5, Tailwind 4, Cloudflare Pages, zero JS framework
Lighthouse score panel showing 97/96/100/100 next to a terminal-style stack list - Astro 5, Tailwind 4, Cloudflare Pages, zero JS framework

softpapaya.com weighs 126 KiB on the homepage, Lighthouse mobile reads 97/96/100/100, and there is no JavaScript framework running on the client.

The site is bilingual, has a CMS, careers listings, case studies, a blog and the standard legal pages. Below: the stack I went with and the bits that took longer than expected.

What “zero JS framework” actually means

By “zero” I mean no React, no Vue, no Svelte, no Solid in the browser. Pages are authored as .astro files that compile to HTML and CSS at build time. If a section actually needs interactivity, you drop in a <script> tag and write it. That is the escape hatch.

In practice the site has exactly one client-side script outside the search subsystem: BlogSearchModal.astro emits an inline script that wires the keyboard shortcut and modal toggle. Pagefind ships its own worker but only on /blog/*, lazily loaded after the modal opens. Every other route - homepage, services, careers, case studies, all the dynamic blog routes when the search modal is closed - runs zero JavaScript framework.

inlineStylesheets: "always" in astro.config.ts folds the CSS straight into the HTML, so there is no second round trip for styles either. On the production homepage: 0 KB unused JS, 0.0s bootup time, 0 ms total blocking time.

Different story for an app with real-time state or a complex multi-step form. There you want a framework. A content site does not need one.

The stack pick: Astro 5 + Cloudflare Pages

Requirements:

  • Two locales, Polish as default, English secondary
  • A CMS that a non-developer can use without infrastructure
  • Sitemap, OG meta, JSON-LD, the SEO baseline
  • Cheap hosting, ideally edge-distributed
  • An escape hatch to interactivity if and when we ever need it

Astro 5.18.0 covers most of that out of the box. .astro files server-render to HTML, components are framework-agnostic, and the moment you actually need React or Svelte you can astro add it without rewriting anything. We have not had to. The whole component tree, including the admin shell, is .astro plus inline <script> for the two places that need it.

For hosting, @astrojs/cloudflare 12.6.12 with the standard pages_build_output_dir setup. Edge SSR via Cloudflare Workers, free tier covers our traffic, and Wrangler 4.73 in CI handles the deploy. The whole wrangler.jsonc:

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

Tailwind 4.2.1 ships as a Vite plugin now, not a PostCSS step, which removed one layer of config. Zod 3.25.76 powers 16 content collections (careers, case-studies, services, team, testimonials, blog and the rest), all validated at build time. If a CMS editor types a malformed frontmatter date the build fails.

For i18n: prefixDefaultLocale: false. Polish lives at /, English at /en/*. Polish-speaking market is the primary audience, so they get the unprefixed URLs.

I did not benchmark Next, Remix or SvelteKit head to head - the choice was pragmatic. The project does not need a router, a runtime, or rehydration, and Astro lets me ship without them. If it ever turns into something stateful, adding React islands is one astro add away.

The gotcha: middleware does not run on prerendered routes

The Cloudflare adapter on prerendered output bit us early. The original CSP setup lived in src/middleware.ts. Every request through astro dev runs the middleware, headers were present in the dev tools network tab, looked fine. First prod deploy and the CSP header was missing on the homepage, /about, /services, every static page.

The adapter writes a dist/_routes.json that excludes prerendered paths from the Worker pipeline. The Worker never sees those requests, so middleware never fires. The Astro docs and adapter README both mention this; I did not read carefully enough first time around.

The fix is two-part. public/_headers becomes the source of truth for site-wide headers in production - Cloudflare Pages serves it directly. The middleware keeps a copy of the same CSP for astro dev and any future SSR-only route. To prevent drift, there is a parity test (src/lib/__tests__/headers-parity.test.ts) that fails if the two strings diverge. The middleware comment block makes the contract explicit:

// 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.

When you pick an SSR adapter, verify production behaviour through the actual production runtime, not the dev server. astro dev is a Node server, wrangler pages dev ./dist is the actual Worker.

Sveltia CMS on git, no database

The CMS is Sveltia, configured against the same GitHub repo. An editor logs in via GitHub OAuth, picks a collection (blog post, career listing, team member), edits a form rendered from the Zod schema, and clicks publish. The CMS commits the change to the staging branch.

Content sits in src/content/<collection>/{pl,en}/ as markdown files with YAML frontmatter, validated by Zod when Astro builds. There is no database anywhere in the project.

Blog entry schema:

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(),
  }),
});

The schema runs at build time and surfaces typed objects through astro:content to page components. No separate data layer to keep in sync with the prod DB; there is no prod DB.

Editor flow end to end:

  1. Editor goes to /admin/cms, edits, clicks “Publish Changes to Staging”
  2. Sveltia commits to staging branch
  3. GitHub Action fires the preview build, deploys to staging.pstechnologies.pages.dev
  4. Editor reviews on staging, goes to /admin/prod, clicks the publish button
  5. Another GitHub Action merges staging into main, deploys production, resets staging back to main

The whole thing runs on free GitHub Actions minutes and the Cloudflare Pages free tier. No monthly SaaS bill. Trade-off: no real-time collaboration and a 2-3 minute lag between the publish click and the live URL. Editors who need a live preview workflow will hate this.

Three GitHub Actions workflows, one queue

The CI/CD is intentionally simple. Three workflows:

  • test.yml runs typecheck, unit tests (Vitest), npm run build, and Playwright E2E on every push to main/staging and every PR to main
  • deploy.yml deploys to production on push to main or on a repository_dispatch of type deploy-production (the latter is what the /admin/prod button triggers via the GitHub API)
  • preview.yml deploys the staging branch to the preview alias on a repository_dispatch of type sveltia-cms-publish

Both deploy workflows share a concurrency group cms-staging. Only one deploy can run at a time. If an editor clicks publish three times in a row the requests queue and the latest one wins; whatever was halfway through finishes first.

The CMS-publish-to-prod flow inside deploy.yml is the more interesting bit:

- 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 if possible, rebase staging onto main as fallback. After deploy, reset staging back to main with a force-push - but only if staging has not gained new commits since the merge started. If it has, log a warning and skip the reset. That guard exists because the first version of this workflow ate someone’s pending CMS edits when staging gained commits between merge and reset.

Node 22, npm ci, npx astro sync before typecheck so generated types are in place. The whole prod pipeline takes a minute or two.

Feature flag opt-out: PUBLIC_BLOG_ENABLED

Blog was the largest single feature in the project - Pagefind search, pagination, category and tag filters, plenty of components and page wrappers. It needed to live on the main branch long before it should go live on softpapaya.com. The choice was: long-running feature branch (merge hell) or a kill switch.

I went with the kill switch. The pattern is opt-out: PUBLIC_BLOG_ENABLED unset or anything other than the literal string "false" means visible. Only "false" hides. Default-visible matters during development, because anything else means devs forget the flag and ship blank pages by accident. The whole parser:

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

What the flag does when off:

  • Strips “Blog” from the nav (getNavItems filters it out)
  • Hides the homepage latest-posts section
  • Returns [] from getStaticPaths in every dynamic blog route
  • Emits Response(404) from the two index .astro files
  • Filters /blog URLs out of the sitemap
  • Skips the Pagefind index build (the npm run build script guards on dist/blog existence)

Off-state on the prod build means nothing under /blog/ is generated at all. The files just do not exist in dist/. Side effect: you cannot find draft content by guessing a URL, because there is no file to serve.

It lived in deploy.yml as a build-time env var (PUBLIC_BLOG_ENABLED: "false" on the prod build step) until launch. Staging and local dev never set it, so editors and developers always saw the blog. Launch was a one-line PR removing the env var. Reverting would be the same one-line PR.

After a few weeks of stable prod, the helper and its guards can be deleted.

What the numbers look like, and what I would not do again

Lighthouse mobile, Polish homepage, post-deploy 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
  • SSR worker bundle 5.8 KB

The 96 on accessibility is the brand colour: burn #ff5500 does not hit WCAG AAA on every text size and we are keeping it anyway. Brand matters more to us than the last point of contrast score.

What I would do differently:

  1. Set up public/_headers as the CSP source on day one instead of fighting the SSR middleware route exclusion for a week
  2. Defer Pagefind until after launch. It is a fine piece of software, but local smoke testing requires npm run build && wrangler pages dev ./dist because the search index is built post-Astro by a separate pagefind invocation. astro dev will not serve a working search modal.

What I am keeping:

  1. Astro 5 + Cloudflare Pages for this class of site. The combination is genuinely good
  2. Sveltia CMS on git, for teams that can tolerate the 2-3 minute publish lag
  3. The opt-out feature flag pattern. Build behind the flag, flip it when ready, delete it once prod stabilises
  4. concurrency: cms-staging on both deploy workflows. One queue, no races

The stack fits the company-site-with-a-CMS class of problem. For an app with real-time state or heavy client interactivity I would pick something else, probably Next or Remix.

Share
X LinkedIn
About the author

Marcin Balazy

SoftPapaya

Read on
Careers

We work on interesting things. Maybe with you?

We're looking for people who like reading posts like this - and writing them. Engineering, design, devrel - check open roles.