For a while I believed the two things I wanted were mutually exclusive:
- A fully static site. Cloudflare Pages,
adapter-static, everything pre-rendered, zero servers to babysit, free tier forever. - A blog where I could write a post today and have it go live next Tuesday at 7am, without me being awake to push a button.
Static means the HTML is frozen at build time. Scheduling means content appears based on the clock. Those feel like opposites. The usual answer is "just add a CMS" or "render the blog server-side." I didn't want either. So I went looking for a third option, and it turned out to be simpler than I expected and it costs $0/month.
Here's the architecture I landed on for Quick Tools, and why each piece is the way it is.
The core idea: the database is a build-time input, not a runtime dependency
The trick is to stop thinking of the database as something the site talks to, and start thinking of it as something the build talks to.
- The blog content lives in Neon Postgres (one row per
(slug, lang)pair). - A Node script reads that database once, during the build, and writes the posts to disk as plain
meta.json+{lang}.mdfiles. - The rest of the build (SvelteKit's SSG, sitemap, RSS, OG images) reads those files like they were always there.
- The shipped site contains zero database code. There's no connection string in the client bundle, no API route, nothing. The DB might as well not exist once the build finishes.
So the site stays 100% static. The database is just where I keep my drafts and schedule a fancy spreadsheet that the build pipeline happens to read.
Materialization: turning rows into files
The whole publishing rule lives in one SQL WHERE clause:
const rows = await sql`
SELECT slug, lang, title, excerpt, body, cat, published_at, /* ... */
FROM blog_posts
WHERE status IN ('scheduled', 'published')
AND published_at <= now()
ORDER BY slug, lang
`;
That's the entire scheduling engine. A post is visible only when its status is scheduled or published and its published_at is in the past. A post dated next Tuesday simply doesn't come back from this query today — so it never gets written to disk, so it never ends up in the build.
Then we just write the rows out as files:
for (const [slug, rows] of grouped) {
const dir = join(BLOG_DIR, slug);
mkdirSync(dir, { recursive: true });
const { meta, contents } = rowsToFiles(slug, rows);
writeFileSync(join(dir, 'meta.json'), stringifyMeta(meta), 'utf-8');
for (const [lang, body] of Object.entries(contents))
writeFileSync(join(dir, `${lang}.md`), body, 'utf-8');
}
This script runs first in the build pipeline, before sitemap/RSS/OG generation all of which read the materialized files. Nothing downstream knows or cares that the content came from Postgres. It just sees a folder full of markdown.
The clock: who triggers the build when nobody pushed?
A static site only changes when it rebuilds. A push to main rebuilds it but I'm not going to push a commit at 7am every day just to flip a post live. So the build needs to fire on a schedule too.
GitHub Actions cron, three times a day:
on:
push:
branches: [main]
workflow_dispatch:
schedule:
# Rebuild to publish due scheduled posts. ~90 builds/month within the
# Cloudflare Pages free tier (500/month). Times are UTC.
- cron: '0 10 * * *' # 07h BRT
- cron: '0 15 * * *' # 12h BRT
- cron: '0 22 * * *' # 19h BRT
Every cron run is a full rebuild. The build re-runs that SQL query against now(), and any post whose published_at has passed since the last build gets materialized and shipped. No post-go-live commit, no manual deploy.
The honest trade-off
Go-live is the next cron run after published_at, not the exact minute. If I schedule a post for 07:30 BRT, it goes live at the 12h build, not at 07:30 on the dot. For a blog, "live within a few hours of the scheduled time" is completely fine and that imprecision is the price of staying static. I decided up front this was an acceptable trade, and three builds a day keeps the worst-case lag to a handful of hours.
The build budget math also matters: 3 builds/day ≈ 90 builds/month, comfortably inside Cloudflare Pages' free tier of 500/month. I get scheduling and I never see a bill.
The part that actually bit me: don't let an empty database wipe your blog
Here's the failure mode that's easy to miss. The build reads the DB and overwrites the blog/ directory. So what happens the day Neon has a hiccup, or a connection times out, or a bad query returns zero rows?
The naive version of this script clears blog/, gets nothing back from the database, writes nothing and cheerfully deploys an empty blog. A transient database blip becomes a production content wipe. That's the kind of thing you discover at the worst possible moment.
The fix is to treat the committed files as a last-known-good snapshot and refuse to destroy them on a bad read:
async function main() {
let rows;
try {
rows = await fetchRows();
} catch (err) {
const existing = listPostDirs(BLOG_DIR).length + listPostDirs(NEWS_DIR).length;
if (existing > 0) {
console.warn(`⚠ Could not reach Neon (${err.message}). Keeping committed snapshot of ${existing} posts.`);
return; // proceed with last-known-good content
}
throw new Error(`Neon unreachable and no committed snapshot exists`);
}
if (rows.length === 0) {
console.warn(`⚠ Query returned 0 posts. Keeping committed snapshot instead of wiping blog/.`);
return;
}
writePosts(groupBySlug(rows));
}
Two guards, both pointing the same way: a bad or empty read never wipes good content. If Neon is unreachable, we keep whatever's committed in git and log a warning. If the query genuinely returns zero rows (suspicious I always have published posts), same thing. The blog only gets rewritten when the database hands back real data.
This is why the blog/ files are committed to the repo at all. They're not the source of truth Neon is but they're a durable fallback that makes every deploy safe even when the source of truth is temporarily gone. The materialization is idempotent: a clean build against unchanged data produces a byte-identical diff, so committing the snapshot stays noise-free.
Authoring: writing and scheduling without touching SQL
The day-to-day flow never involves writing SQL by hand. There's an upsert script that reads a post directory (the same meta.json + {lang}.md format) and writes it into Neon with a status and a timestamp:
# schedule a post to go live at a specific time (status = scheduled)
pnpm blog:upsert .claude/articles/my-post --at 2026-06-20T10:00:00Z
# publish immediately
pnpm blog:upsert .claude/articles/my-post --status published
So I write markdown locally, run one command to push it into the schedule, and forget about it. The next cron build after that timestamp puts it live. The files-on-disk format and the database rows are two views of the same thing, and a pure mapping module converts between them with no database code in it at all which keeps the migration, the build, and the authoring script all sharing one source of truth for the shape of a post.
Why this beats the obvious alternatives
vs. a real CMS / headless backend: No server to run, patch, or pay for. No runtime coupling if my database vendor disappeared tomorrow, the currently-deployed site keeps working forever, because the content is baked in.
vs. server-side rendering the blog: SSR would give me exact-minute scheduling, but at the cost of a running server on every request, cold starts, and a database in the hot path of page loads. For a content blog, that's a lot of moving parts to buy precision I don't need.
vs. pure git-based (markdown in the repo, no DB): This is the closest alternative, and it's great until you want scheduling. Git has no concept of "publish this Tuesday." You'd be back to writing a commit at go-live time exactly what I was trying to avoid. The database gives me a published_at column and a cron job does the rest.
Where this breaks down and the escape hatch
No architecture is free of limits, and the honest thing is to name where this one stops working before you hit it.
The ceiling isn't disk and it isn't build time it's the file count per deployment. Cloudflare Pages caps a deploy at 20,000 files, and every prerendered page ships as one or two files (the .html, plus a separate data file if SvelteKit doesn't inline it). So files ≈ (routes × languages) + JS/CSS chunks + images. At a steady publishing pace this grows linearly, and somewhere in the thousands of posts you'd start eyeing that 20k wall.
The instinct is "then I'll have to switch to SSR." You don't at least not all of it. The key realization: a prerendered page is a file; an on-demand (SSR) page is not. A page rendered at the edge on request generates zero static files, so it simply doesn't count toward the limit.
That turns "static vs. SSR" from a binary into a dial. The migration path, when the day comes:
- First, just trim. Stop prerendering the truly disposable long-tail (for me, that's dated news posts in every language). No architecture change, buys years.
-
Then go hybrid. Swap
adapter-staticfor the Cloudflare adapter and decide per route: keep the pages that matter tools, the blog index, recent and popular posts prerendered as static files; let the long-tail render on-demand at the edge. Those edge-rendered pages produce no files, so the file budget stops growing for them. Crucially, a crawler hitting an edge-rendered page gets the same fully-formed HTML it would from a static file SSR at the edge costs you a few milliseconds of compute, not your SEO. - Only at extreme scale would you cache SSR responses at the edge with revalidation and that's a problem most projects never have.
So the answer to "will I eventually need a server?" is: only for the slice that actually needs it, and even then it's edge compute on a free tier, not a box you babysit. The trigger to watch is concrete total files in the build output approaching ~80% of the limit. You act on a number, not on a panic.
The takeaway
The reframe that unlocked it: "static" is a property of what you ship, not of where your content lives. A database can absolutely be part of a static site's life as long as it only ever speaks to the build, never to the browser.
Once you accept that builds are cheap and can be triggered by a clock, "scheduled publishing on a static site" stops being a contradiction and becomes a cron job plus one WHERE clause. The only real engineering left is making sure a bad read can't take your content down with it. And the best part: the whole stack Cloudflare Pages, GitHub Actions, Neon runs on the free tier. Monthly cost: $0.
Quick Tools is a platform of online utilities. Check it out at quickeasy.tools.













