Cloudflare Static Deploy
How wcygan.net ships: prerender every route to flat HTML/JS/CSS, upload via Workers Assets, no server at runtime. No @cloudflare/vite-plugin, no wrangler runtime coupling in app code.
Why static, not SSR
The blog has zero per-request logic. Homepage, /posts, /{slug}, /about, /resume, /feed, /mermaid-examples are all content-static. Mermaid diagrams are rendered to static SVG at build time, so they ship as plain images with no client-side JavaScript. RSS is a committed public/rss.xml. Prerendering beats SSR on cost, latency, portability, and operational surface.
If we ever add auth, per-user data, or build-time bindings (KV/D1), revisit — see "When to switch to the Workers runtime" below.
Required pieces
1. vite.config.ts — prerender + keep Nitro deno-server preset
tanstackStart({
srcDirectory: 'src',
prerender: {
enabled: true,
crawlLinks: true,
autoSubfolderIndex: true,
failOnError: true,
},
}),
react(),
nitro({ preset: 'deno-server' }),
- Do NOT use
preset: 'static'. Nitro's own prerenderer with that preset does not know about TanStack Start's route handlers and 404s on every path. TanStack Start runs its own prerender against the Nitro-built server via a preview server. Keepingpreset: 'deno-server'gives it a Deno-compatible server to probe, and the prerendered HTML lands in.output/public/alongside a server bundle in.output/server/that we simply don't ship. crawlLinksdiscovers posts via links from/and/posts. Every post must be reachable from a crawled page, otherwise add it toprerender.pages: ['/slug-a', ...].autoSubfolderIndexemits/posts/foo/index.html(correct for Workers Assets routing) rather than/posts/foo.html.
Mermaid is build-time only now (the render script serves it to a headless Chrome; it never enters the client or server bundle), but the alias and ssr.external entry still guard any stray import. Keep the Mermaid alias as a regex — a string alias gets re-applied by Nitro's SSR bundler and produces mermaid/dist/.../dist/... paths that fail to resolve:
resolve: {
alias: [
{ find: '~', replacement: new URL('./src', import.meta.url).pathname },
{ find: /^mermaid$/, replacement: 'mermaid/dist/mermaid.esm.min.mjs' },
],
},
ssr: { external: ['mermaid'] },
2. wrangler.jsonc — assets-only Worker
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "wcygan-net",
"compatibility_date": "2026-04-13",
"assets": {
"directory": ".output/public",
"not_found_handling": "404-page",
},
}
No main. No nodejs_compat. Wrangler is a deploy CLI only — not a runtime dependency. Keep the file minimal; every added field is a coupling point.
3. deno.json tasks
"build": "vite build",
"deploy": "deno task build && wrangler deploy",
"preview-static": "deno run --allow-net --allow-read --allow-sys jsr:@std/http@1/file-server .output/public"
Delete any start script pointing at .output/server/index.mjs — that file no longer exists under the static preset.
4. Cloudflare Workers Builds dashboard
The Workers Builds image as of 2026-05 ships Bun and Node, but not Deno, so we install Deno inline. CF auto-runs bun install first (because package.json exists); that's fine — the build command then layers Deno on top for the actual build.
| Field | Value |
|---|---|
| Build command | curl -fsSL https://deno.land/install.sh | sh -s -- -y v2.6.10 && export PATH="$HOME/.deno/bin:$PATH" && deno install --frozen && deno task build |
| Deploy command | export PATH="$HOME/.deno/bin:$PATH" && deno x wrangler deploy |
| Non-production branch deploy cmd | export PATH="$HOME/.deno/bin:$PATH" && deno x wrangler versions upload |
| Path | / |
| Production branch | main |
Three non-obvious requirements baked into those commands:
- Pin the Deno version (
v2.6.10as thesh -spositional arg — see install script source). The unpinnedcurl | shform grabs the latest release, and newer Deno tightensnode:_http_server.writeHeadargument validation, which breakssrvx@0.11.15during TanStack Start's prerender step. Bump the pin only after locally rebuilding on a newer Deno and confirming.output/public/*.htmlstill emits. - Each command exports
$HOME/.deno/binto PATH independently. The build command'sexportdoesn't persist — deploy commands run in a fresh shell. Without the re-export you get/bin/sh: 1: deno: not found. deno x wrangler ...replacesbunxanddeno task --eval.deno xis the npx/bunx equivalent (since Deno 2). It resolveswranglerfromnode_modulespopulated bydeno install --frozen. No Bun on the deploy path.
Enable Build cache to reuse node_modules across builds.
Commit only deno.lock — never bun.lock, pnpm-lock.yaml, or package-lock.json alongside it.
Verification checklist after deno task build
.output/public/index.htmlexists and is non-empty (silent prerender failure can leave the dir asset-only).output/public/posts/index.htmlexists.output/public/<slug>/index.htmlexists for every MDX post.output/public/rss.xmlpresent (copied frompublic/).output/server/exists (Nitro Deno bundle — we just don't deploy it;wrangler.jsonchas nomain, onlyassets.directory)- Total
.output/public/size sane (posts + mermaid client chunk) - Build log contains
[prerender] Prerendered N pages:with the expected route list, notPrerendered 0 pages:
scripts/build.mjs must fail loudly
TanStack Start's prerender step rejects with an unhandledRejection rather than throwing, so a naïve try/catch + process.exit(0) builder script reports success even when zero HTML emitted. Guard against this at the top of scripts/build.mjs:
process.on("unhandledRejection", (err) => {
console.error("Unhandled rejection during build:", err);
process.exit(1);
});
Without this, a broken prerender produces a deploy that uploads only /assets/*.js and yields a 404 on every route — the failure mode behind the May 2026 outage.
Catch-all route gotcha
src/routes/$slug.tsx rejects slugs containing dots in beforeLoad. Keep this. Static files (rss.xml, favicon.ico) live in public/ and Workers Assets serves them before the catch-all is ever consulted. If you rename the guard logic, prerender will try to render /rss.xml as a post — don't.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Cloudflare build fails with "pnpm not found", "bun not found", or lockfile mismatch | Dashboard still running an old package-manager command | Replace Build command with the pinned-Deno form from the dashboard table above |
wrangler deploy errors "No compatible entrypoint" |
wrangler.jsonc has main but no server bundle built |
Remove main — assets-only deploys omit it |
| Post route returns 404 in prod but works locally | Prerender crawler didn't discover it | Add to prerender.pages explicitly or ensure it's linked from /posts |
/rss.xml returns HTML 404 page |
Catch-all matched before static file | Confirm $slug.tsx beforeLoad still rejects dotted slugs |
| Mermaid chunk appears in client bundle | A source file imported mermaid directly; it must stay build-time only (scripts/render-diagrams.mjs) |
Remove the import. Posts embed prebuilt SVGs from public/; no client mermaid chunk should ever exist |
| Build succeeds locally, fails on Cloudflare | Build image Deno/Wrangler mismatch or a missing lifecycle script approval | Check build log for Deno version, deno install warnings, and module resolution errors |
Prod 404 on every route; build reported "Success"; deploy uploaded only /assets/*.js |
Prerender crashed with TypeError [ERR_INVALID_ARG_TYPE]: "headers" argument must be an Array from srvx/dist/adapters/node.mjs writeHead. CF's unpinned Deno is stricter than local. |
Pin Deno version in the build command (sh -s -- -y vX.Y.Z). Add the unhandledRejection guard above so future regressions fail the build instead of shipping empty. |
/bin/sh: 1: deno: not found during deploy command |
Build command's export PATH doesn't survive into the fresh deploy shell |
Prepend export PATH="$HOME/.deno/bin:$PATH" && to every deploy/version command |
Transient error reading a body from connection on a single npm tarball during deno install --frozen |
npm CDN flake in CF build sandbox | Hit "Retry deployment" in dashboard. If recurrent, wrap with for i in 1 2 3; do deno install --frozen && break; sleep 3; done |
When to switch to the Workers runtime
Only if ALL of these become true:
- Need to read request context (cookies, auth) server-side
- Need Cloudflare bindings (KV, D1, R2, Queues, DO)
- Need ISR / on-demand revalidation
Then: install @cloudflare/vite-plugin + wrangler, replace Nitro with the Cloudflare plugin, add main: "@tanstack/react-start/server-entry" and compatibility_flags: ["nodejs_compat"] to wrangler.jsonc. See the Cloudflare TanStack Start framework guide (link below).
Reference docs
Primary:
- TanStack Start — Static Prerendering —
prerenderconfig shape; crawler behavior - Nitro — Deployment presets —
staticpreset and output layout - Cloudflare Workers — Static Assets —
assetsbinding inwrangler.jsonc,not_found_handlingmodes - Cloudflare Workers Builds — Build configuration — dashboard build and deploy fields
Secondary / "if we migrate to Workers runtime":
- Cloudflare — TanStack Start framework guide — the SSR-on-Workers path
- Cloudflare — Wrangler vs Vite plugin — when each is appropriate
- Cloudflare Changelog (Dec 19, 2025) — TanStack Start prerendering — prerender combined with bindings
- TanStack Start — Incremental Static Regeneration — if we outgrow pure static
Adjacent project skills:
wcygan-net-stack— stack fundamentals (Deno, TanStack Start, MDX, Mermaid)github-actions-troubleshooter— CI failures that aren't Cloudflare-specific