Astro: a working setup
How this blog is built — content collections, @sudoo i18n, and a one-file Cloudflare Pages deploy.
~/posts/astro-best-practices $ cat post.md
The previous post explains why this blog moved off Jekyll. This one is the boring sequel: what the new setup actually looks like.
I will not pretend the stack is exotic. Astro for the static side, two markdown trees for two languages, GitHub Actions pushing the output to Cloudflare Pages. The interesting parts are in the joints.
Posts as a typed collection
Every post lives at src/content/posts/<locale>/<slug>.md. The schema is one
file:
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const posts = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { posts };
Two things this buys you for free:
- The glob loader walks the locale subdirectories, so an entry’s id is
en-US/some-slug— locale falls out of the path, no extra frontmatter needed. - Zod runs at build time. A missing title, a malformed date, a stray tag
that’s not a string — all of those fail
pnpm buildinstead of shipping.
The post-list helpers in src/i18n/utils.ts (splitPostId,
postsForLocale, postsWithTag) do the filtering and sorting. The
[lang] page templates stay thin.
i18n, the @sudoo way
The interesting refactor recently was switching from ad-hoc string maps to
@sudoo/internationalization
keyed by an enum. The shape:
// src/i18n/profile.ts
export enum BLOG_PROFILE {
SITE_TITLE = "SITE_TITLE",
NAV_ALL_POSTS = "NAV_ALL_POSTS",
POST_PUBLISHED = "POST_PUBLISHED",
// …one entry per translatable string
}
// src/i18n/intl.ts
import { SudooInternationalization } from "@sudoo/internationalization";
import { IETF_LOCALE } from "@sudoo/locale";
import { enUSBlogProfile } from "./locale/en-US";
import { zhCNBlogProfile } from "./locale/zh-CN";
export const blogInternationalization =
SudooInternationalization.create<BLOG_PROFILE>(DEFAULT_LOCALE);
blogInternationalization.merge(IETF_LOCALE.ENGLISH_UNITED_STATES, enUSBlogProfile);
blogInternationalization.merge(IETF_LOCALE.CHINESE_SIMPLIFIED, zhCNBlogProfile);
The locale tables are Record<BLOG_PROFILE, string>, so TypeScript refuses
to compile if a locale forgets a key. Adding a new string is a four-step
ritual that the compiler walks you through: add to the enum, add to en-US,
add to zh-CN, use it in a template. There is no t("nav.allPosts") waiting
to silently return the key string when you mistype it.
Routing is plain Astro:
// astro.config.mjs
i18n: {
defaultLocale: "en-US",
locales: ["en-US", "zh-CN"],
routing: {
prefixDefaultLocale: true,
redirectToDefaultLocale: false,
},
},
prefixDefaultLocale: true is the only sane setting for a bilingual site —
every URL starts with /en-US/ or /zh-CN/, no ambiguous root. The locale
switcher uses switchLocalePath from src/i18n/utils.ts to swap the first
segment in place, so toggling languages on a tag page lands you on the same
tag page in the other locale.
Deploy: one workflow file
The deploy is unromantic on purpose:
# .github/workflows/deploy.yml
on:
push: { branches: [master] }
workflow_dispatch:
concurrency:
group: pages-deploy-blog
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm build
- env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: npx --yes wrangler@latest pages deploy dist --project-name=blog-mengw-io --branch=main
Notes worth keeping:
concurrency.cancel-in-progress: false— two pushes in a row should both ship, not race. Cloudflare handles the queueing fine.wrangler pages deploy distover a Cloudflare → GitHub integration. The integration is faster to set up; the wrangler call is easier to debug, because you can run the same line on your laptop with the same env vars and reproduce the failure.- The
branch=mainflag on a workflow that triggers offmasteris deliberate: it tells Cloudflare which environment to treat as production, independent of the Git branch name. A small landmine, easy to forget.
Two repo secrets — CLOUDFLARE_API_TOKEN (scoped to Pages: Edit on this
project only) and CLOUDFLARE_ACCOUNT_ID — and that is the whole CD story.