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:
- Static by default.
prerender = trueis opt-out, the default is plain HTML. Performance, SEO, CDN caching — all free. - MDX content collections. Content lives in MDX files, validated by Zod schemas. Rename a field, the build errors. Git-tracked, zero-cost export.
- First-class Cloudflare Pages. The
@astrojs/cloudflareadapter 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-pagecurrentPathfeeds aLocaleSwitcherthat 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