This blog (blog.tak3.jp) is a static Astro site served from Cloudflare Workers static assets, edited through Sveltia CMS, and published in two languages, Japanese and English. When a post goes live, it is automatically syndicated to DEV.to and, by design, to Qiita (a major Japanese developer platform) — either as a summary with a pointer back or in full, chosen per post. If you're reading this on DEV.to, this article reached you through the exact pipeline it describes. (Qiita is the honest exception right now: its API is blocking this account, so that side runs manually for the moment — more on that below.)
One more meta detail before we start: a Japanese edition of this post exists, and it is not a translation of this one. It's a pair — same subject, independently written article. That distinction is a deliberate design decision, and it's half of what this post is about. The other half is why I didn't use Cloudflare Pages.
This is a design write-up, not a tutorial. I'll focus on the decisions: skipping Pages, pairing languages with translationKey, draft safety in a setup where publishing has side effects, and running a Git-based CMS with no auth infrastructure at all.
Requirements and stack
What I wanted wasn't "a blog" but a publishing base with my own site as the single source of origin. Five requirements:
- Publish in both Japanese and English
- Keep all content in Git (history, no lock-in, editable from both an editor and a browser)
- Push operating costs as close to zero as possible (serverless, free tiers)
- Auto-syndicate published posts to external platforms (DEV.to / Qiita)
- Be implementable by AI coding agents, with a spec document as the single source of truth
The resulting stack:
| Area | Choice |
|---|---|
| SSG | Astro + TypeScript (Content Layer API) |
| Content | Markdown / MDX + YAML frontmatter |
| CMS | Sveltia CMS (Git-based, admin at /admin/) |
| Hosting | Cloudflare Workers static assets + a thin main Worker for language routing |
| Syndication | A separate Worker (Cron + D1 + KV) |
| RSS / sitemap |
@astrojs/rss / @astrojs/sitemap
|
The whole system in one picture:
flowchart LR
subgraph edit[Writing & editing]
A[Editor / AI agents] --> G
C[Sveltia CMS /admin/] --> G
end
G[(GitHub repo)] -->|push to main| B[Build: astro build → dist/]
B -->|wrangler deploy| W[Cloudflare Workers<br>static assets + main Worker]
W --> V[Readers<br>/ja/ and /en/]
W -->|/feed.json| S[Syndication Worker<br>Cron + D1 + KV]
S --> Q[Qiita]
S --> D[DEV.to]
For the CMS I chose Sveltia over Decap (formerly Netlify CMS): it keeps Decap-compatible config while being a newer, lighter, actively developed implementation. I treat a Git-based CMS not as a WordPress replacement but as a UI for editing structured content that lives in Git.
Is Cloudflare Pages deprecated?
Facts first, because this question generates a lot of noise on Reddit and Hacker News. As of July 2026, there is no official end-of-life announcement for Pages. Existing projects are supported, and you can still create new ones.
The practical reality, though, is "not recommended for new projects." Cloudflare's docs ship an official Pages → Workers migration guide and state plainly that development focus is on Workers going forward, recommending Workers for anything new. Feature-wise, Workers now covers nearly everything Pages does, and adds things Pages never had — Durable Objects, Cron Triggers, richer observability. The remaining gaps in the official compatibility matrix are small (branch-build configurability, for one).
One clarification, because it fuels the confusion: the thing that is officially deprecated is Workers Sites — an older, entirely different feature that predates Pages — with a documented migration path to Workers static assets. A good share of the "Pages is deprecated" claims are actually about that.
So no, I didn't skip Pages "because it's deprecated." I skipped it for one defensive reason and two positive ones.
The defensive reason: adopting Pages for a brand-new project means signing up for a future migration on day one.
Positive reason #1: routing as code. This site has /ja/ and /en/ side by side, and no real page at the root /. Static hosting can't read a visitor's language — but a thin main Worker sitting in front of the static assets can handle exactly one path, /, and answer with a 302.
// wrangler.jsonc (excerpt)
{
"name": "takashimatsuyama-blog",
"compatibility_date": "2026-05-01",
"main": "worker/index.ts",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": ["/"] // only the root runs the Worker; everything else is served as-is
}
}
Language negotiation is a three-step cascade: ① an explicit ?hl=ja|en query from the language switcher (recorded in a cookie), ② the cookie from a previous explicit choice (which outranks browser settings), ③ on first visit, Accept-Language — top preference en* goes to /en/, everything else defaults to /ja/, because this is a Japanese-primary site. The 302 carries Vary: Accept-Language and Cache-Control: private, no-store so the language decision never ends up in a shared cache.
// worker/index.ts (excerpt, simplified)
const isRootDocument =
url.pathname === '/' && (request.method === 'GET' || request.method === 'HEAD');
// language pages, assets, 404s: straight to static assets
if (!isRootDocument) return env.ASSETS.fetch(request);
// 1. explicit switch → 2. cookie → 3. Accept-Language (default: ja)
const hl = url.searchParams.get('hl');
if (hl === 'ja' || hl === 'en') return redirectToLocale(url, hl, true);
const cookie = readCookie(request, LANG_COOKIE);
if (cookie === 'ja' || cookie === 'en') return redirectToLocale(url, cookie, false);
return redirectToLocale(url, preferredLocale(request.headers.get('Accept-Language')), true);
Positive reason #2: one deploy story. This repo also contains the syndication Worker (Cron / D1 / KV). Instead of maintaining a Pages project and a Workers project side by side, everything is wrangler deploy.
The trade-off, stated honestly: the build-and-preview experience Pages gave you for free becomes your problem. Here, a push to main triggers wrangler deploy, and previews are the URLs issued by wrangler versions upload.
Bilingual design: pairs, not translations
URLs use path prefixes, /ja/... and /en/..., and the hreflang x-default points to /ja/. Most i18n write-ups assume an English-primary site; this one is Japanese-primary, and I wanted zero ambiguity about what a visitor with an unknown language preference sees.
The decision that actually needed thought was how to hold the content collections:
| A: one collection per language | B: single collection + lang filter |
|
|---|---|---|
| Queries | call getCollection('blog_ja') etc. per language |
one uniform query |
| CMS mapping | 1:1 with CMS collections | language is pickable (and mis-pickable) in the UI |
| Failure mode | low | a forgotten lang filter fails silently |
I went with A (blog_ja / blog_en / notes_ja / notes_en). The deciding factor: neither a human editor nor an AI agent should ever have to think about which language they're in. Each Sveltia collection maps 1:1 to an Astro collection, lang is a hidden fixed field in the CMS, and the entire class of "silently leaking the wrong language" bugs disappears.
The two languages are linked through a translationKey in the frontmatter:
// content.config.ts (excerpt, simplified)
const blogSchema = z.object({
title: z.string(),
pubDate: z.coerce.date(),
lang: z.enum(['ja', 'en']),
slug: z.string(),
translationKey: z.string(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
syndication: z.record(z.string()).default({}),
});
const blog_en = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog_en' }),
schema: blogSchema,
});
The important part: the slug is independent of the translationKey. The two editions of a post may have different slugs, different titles, different structure. What you build is not a translation but a pair — two articles on the same subject, each written for its audience. This very post demonstrates it: the Japanese edition opens differently, orders its arguments differently, and spends more words on the platforms Japanese readers syndicate to.
Since translationKey is typed by hand, a typo fails silently — the language-switch link just doesn't appear. That's fixed with a two-tier build check:
- A translationKey that exists in only one language → warning ("not translated yet" is a normal state, so it must be allowed)
- A duplicate translationKey within one language → error (that's always a typo)
You cannot mechanically distinguish "no pair yet" from "typo'd key." So route the possibly-legitimate case to a warning and the impossible case to an error.
Draft safety across every output path
Posts with draft: true are excluded from all of this:
| Output path | Drafts |
|---|---|
| Home / lists / detail pages / tags | excluded |
| RSS / JSON Feed | excluded |
| sitemap | excluded |
| Language-switch links, related posts | excluded |
| External syndication (Worker) | excluded |
On a plain static blog, a mis-published draft is fixed with a redeploy. Here, publishing has side effects: it triggers syndication to DEV.to and Qiita. Deleting the post from your own site doesn't recall what already left. So the CMS defaults new posts to draft: true, and the exclusion is enforced not just in HTML but in feeds, the sitemap, and the syndication Worker. Automated syndication and draft discipline only work as a set.
Sveltia CMS with a token — no OAuth proxy
The standard way to run a Git-based CMS against GitHub is to deploy an OAuth proxy for authentication. This site doesn't have one. There's a single editor (me) and a single target repo, and Sveltia offers "Sign in with Token": paste a GitHub fine-grained PAT and you're in.
# public/admin/config.yml (excerpt)
backend:
name: github
repo: your-account/your-blog-repo # point at your repo
branch: main
publish_mode: simple
media_folder: public/images/uploads
public_folder: /images/uploads
The token is scoped down hard:
- A fine-grained PAT restricted to this one repository
- Contents: Read and write (plus the mandatory Metadata: Read) — nothing else
- Expiring. With
publish_mode: simple(direct commits to main), no PR permissions are needed
The honest risk assessment: the token lives in the browser's localStorage and goes straight to the GitHub API. You keep zero secrets on your own infrastructure, in exchange for operational rules — don't use shared machines, rotate on expiry. For a single-operator personal blog, removing an entire moving part (the OAuth proxy) is worth that trade.
The syndication pipeline, briefly
The reveal for the opening line. The site emits /feed.json (JSON Feed), where each item carries a _meta block for syndication: the GitHub sourcePath / gitRef / sha, the canonical URL, and the post's syndication settings. A separate Worker polls it on a Cron schedule, detects new posts, and publishes to whichever platforms are set to enabled — with an allowlist: only DEV.to and Qiita are ever posted to automatically; everything else stays manual. Each post chooses whether its syndicated copy is a summary with a pointer back or the full text (via the syndicationBodyMode frontmatter field); either way, the canonical URL stays on this blog. This post is syndicated in full.
One honest caveat: at publish time, Qiita's API returns 403 Forbidden for this account — not a scope or rate-limit problem, but an account-level block on creating items through the API (the same account posted fine a few days earlier). So Qiita is temporarily manual while DEV.to keeps running automatically. A post about automating syndication, tripped up by a real syndication limit — which felt worth stating plainly.
Duplicate protection is single-flight via a unique constraint in D1, and every adapter ran in dry-run mode (no real HTTP, no storage writes) before going live. Retries, rate limits, and partial-success handling deserve their own post, so that's where they'll go.
Gotchas
compatibility_date cannot be in the future. The moment the main Worker was added, deploys started failing runtime validation — because the wrangler config carried a compatibility date Cloudflare hadn't released yet. An assets-only config had accepted it silently.
A single collection for static pages collides. The glob loader uses the frontmatter slug as the entry id, so putting /ja/about and /en/about in one pages collection collides on the same slug. Pages got split per language too (pages_ja / pages_en).
Pin the CMS CDN version. Loading Sveltia from a latest CDN URL means the admin UI can change behavior whenever upstream ships. The script tag in /admin/index.html is version-pinned; upgrades happen on my schedule.
Takeaways
- The value of Workers static assets is "static hosting where routing is code." You don't need "Pages is deprecated" as a reason — a thin Worker in front and a single deploy story are positive ones
- Design bilingual content as pairs, not translations. Separating
slugfromtranslationKey, plus a warning/error split in validation, is the whole implementation - If publishing triggers syndication, draft discipline has to cover every output path, not just HTML. The automation and the discipline only exist together
This repo was implemented primarily by AI coding agents, working from a spec document as the single source of truth. That workflow — and the open-source harness for steering coding agents that I'm building — is a story for another post.
The Japanese pair of this article is already live on the /ja/ side of this blog.
Top comments (0)