Back to writing

Astro 5 + Cloudflare Pages bilingual site — 30-day retrospective

One domain, bilingual, full stack (Stripe + Creem + Cal + Resend + OAuth) shipped in 6 weeks. Astro 5 + Cloudflare Pages — zero regrets so far.

The cost of an indie site isn’t the coding. It’s trying to migrate 6 months in because you picked the wrong architecture. I didn’t this time.

Starting point

Goal: one domain running work + consulting + digital products + articles + tools + email list, bilingual zh/en, fewer than 3 clicks from the home page to a Stripe or Creem checkout.

Sounds plain. No off-the-shelf tool actually does this — Framer / Webflow can’t write the post-purchase backend, Next.js Commerce assumes Shopify, Ghost is a blog, not a storefront.

Why Astro 5

Three reasons, in order:

  1. Static by default. prerender = true is opt-out, the default is plain HTML. Performance, SEO, CDN caching — all free.
  2. MDX content collections. Content lives in MDX files, validated by Zod schemas. Rename a field, the build errors. Git-tracked, zero-cost export.
  3. First-class Cloudflare Pages. The @astrojs/cloudflare adapter outputs a workerd bundle, static + Functions in one deploy, free tier covers it.

How bilingual is wired

/zh/* and /en/* double-prefix, getStaticPaths emits both locales in one go. src/lib/i18n.ts exposes a t(lang, key) function for any UI string.

I skipped cookie-based auto-locale-detect — query-param ?lang=en is too aggressive for a first visit, IP sniffing is unreliable. Final setup:

  • Default zh (primary audience)
  • <html lang> + per-page currentPath feeds a LocaleSwitcher that computes the counterpart route
  • LocaleSwitcher renders the active language as <span aria-current>, only the other language is a real <a> (cf6f868)
  • Reverse direction: the same slug must exist in both languages, otherwise the switcher falls back to that locale’s home (no 404s)

Three months in, the setup works. No “I clicked EN, why is it still Chinese?” reports.

Content collections

Five collections: products / services / testimonials / articles / now. Each MDX file goes through a Zod schema — get a field wrong (e.g. publishedAt as a string) and the build fails.

Biggest upside: content IS git. Rename a product, delete a testimonial, merge two collections — it’s a one-line commit. Recover a deletion, diff a version. Framer / Webflow give you “export JSON”; I skip the export step entirely.

Real pitfalls I hit

Pitfall 1: scoped CSS leaking. Tool pages inject theme CSS via document.head.appendChild. Astro’s default compile scopes .classname to body[data-astro-cid-xxx] .classname — but runtime-injected CSS doesn’t go through that scope, so it widens the entire page body. Fix: scope + wrap the tool preview frame in its own container.

Pitfall 2: Locale switcher dead link. The Chinese button had href={currentPath}, which meant clicking it jumped to itself — looked like “nothing happens.” Fix: the active locale renders as <span>, only the other locale is <a>.

Pitfall 3: testimonials were all placeholder. I wrote 6 fake reviews (alice / bob / carol) to make the cards render. Code worked; humans could tell it was fiction. Added a sample: true frontmatter field, badge “SAMPLE / 样本” in the top-right of each placeholder card.

Each pitfall was a 5-30 minute patch. None of them forced a “rebuild the base.”

Deployment

Cloudflare Pages hooks the GitHub repo. Push to feat/storefront-mvp, production updates in 30 seconds. Secrets go in via wrangler pages secret put or the scripts/upload-secrets.sh batch uploader. KV ×2 + R2 ×1 declared in wrangler.toml, the build hands Astro.locals.runtime.env to the worker.

30 days of production data: zero downtime, zero migration, zero “let’s redo the base.”

Closing

If you want to ship a single-domain storefront, Astro 5 + Cloudflare Pages is the most practical combo right now — static + SSR in one deploy, bilingual, content collections, auth, payments, email — all in one repo.

Only prerequisite: you write code. Git is the barrier, not a burden.

— Lao Wei

键盘快捷键

先按 g 再按下面的键。? 打开这个面板。