Tutorial
How to Schedule Blog Posts on a Static Astro Site
· 7 min read
On this page
Scheduled publishing is one of those features that quietly pushes people off static sites. A CMS does it with one date picker; a static site, the reasoning goes, can’t — the HTML is baked at build time, there’s no server watching the clock, so if you want posts to appear while you sleep, you need SSR, or a headless CMS, or some publishing SaaS.
You don’t. This blog is fully static — prerendered Astro, served as files from Cloudflare Workers, no database — and every post on it is scheduled. I write posts days ahead, give each one a future date, and they go live at midnight without me touching anything. The whole mechanism is a content-collection filter plus one scheduled GitHub Actions workflow, and this post walks through both, including the timezone bug you’ll hit if you compare dates the obvious way.
The mental model
A static build is a function: content in, HTML out. The trick is to make the current date one of the inputs. Then:
- At build time, exclude every post whose
pubDateis in the future. - Rebuild on a schedule — once a day, at the hour you want posts to appear.
Each daily build re-evaluates the filter against a new “today”, so a post dated tomorrow is invisible in tonight’s build and present in tomorrow’s. Nothing watches the clock at request time; the clock is consulted once per build, which is exactly as often as a daily publishing cadence needs.
That second step is the part people miss. Writing the filter is easy, but a static site doesn’t
rebuild itself when a date passes — a pubDate in the future does nothing until something
triggers a build after that date arrives. The scheduled workflow is what makes the date mean
anything.
Step 1: filter unpublished posts everywhere
Astro’s content collections make the filter a one-liner at each call site, so the only real
decision is where to put the predicate. Mine lives in src/consts.ts so every page pulls the
same definition:
// Collection filter: drafts stay visible in `pnpm dev` for preview, excluded from builds.
export function publishedOnly({ data }: { data: { draft: boolean; pubDate: Date } }): boolean {
return import.meta.env.DEV || (!data.draft && data.pubDate.getTime() <= todayInJst());
}
And every place that lists posts uses it:
import { getCollection } from 'astro:content';
import { publishedOnly } from '../consts';
const posts = (await getCollection('blog', publishedOnly))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
Two deliberate choices in that predicate:
import.meta.env.DEV short-circuits the whole thing. In pnpm dev, future-dated and draft
posts render normally, so you can preview a scheduled post at its real URL. Only production
builds filter. Without this you end up temporarily editing dates to preview your own writing,
which is exactly the kind of friction that gets a post published half-proofread.
draft and a future pubDate mean different things. A future date is “finished, waiting
for its day”. draft: true is “not finished” — and it wins even after the date passes, so a
half-written post with an optimistic date can’t leak into production because you forgot about
it. My frontmatter schema defaults draft to false; scheduling is the common case and gets
the short spelling.
The filter has to be applied everywhere posts surface, not just the blog index: tag pages,
the RSS feed, the sitemap, JSON-LD, the llms.txt index, internal “related post”
lists. Miss one and your unpublished post is invisible on the homepage but sitting in the RSS
feed, where feed readers will happily announce it early. One shared predicate makes this a
grep-able guarantee rather than a hope.
The timezone gotcha
Here’s the bug you’ll write on the first attempt. A bare date in YAML frontmatter —
pubDate: 2026-08-04
— parses as midnight UTC. If you compare it against Date.now(), the post dated August 4
publishes at midnight UTC, which is 9am on August 4 in Tokyo, or 5pm on August 3 in Los
Angeles. Depending on which side of UTC you live, posts come out embarrassingly late or a day
early.
The fix is to decide which timezone the date in the frontmatter refers to, and shift the current time into that zone before truncating it to a date:
/** Midnight JST today, as the UTC timestamp of that calendar date. */
function todayInJst(): number {
const JST_OFFSET_MS = 9 * 60 * 60 * 1000;
return new Date(Date.now() + JST_OFFSET_MS).setUTCHours(0, 0, 0, 0);
}
Now both sides of the comparison are on the same footing: pubDate is “midnight UTC of the
date I wrote”, todayInJst() is “midnight UTC of whatever date it currently is in Japan”, and
the post appears the moment its calendar date starts in my timezone. Swap in your own offset
(this simple constant works because Japan has no daylight saving; if yours does, use
Intl.DateTimeFormat with a timeZone to get the local date instead).
Step 2: the daily rebuild
The workflow is short. Mine builds and deploys to Cloudflare Workers with wrangler, but the
deploy step is whatever your host uses — the load-bearing part is the schedule trigger:
name: Deploy
on:
schedule:
# UTC 15:00 = JST 00:00 the next day
- cron: '0 15 * * *'
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.14.4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Details worth copying:
- Cron is in UTC.
0 15 * * *is midnight JST. Translate your target hour to UTC and note it in a comment, because you will not remember in three months. workflow_dispatchis your escape hatch. Typo in a post that’s already live? Trigger the same workflow by hand from the Actions tab instead of waiting for tomorrow’s run. (Pushing to main doesn’t deploy in my setup — CI runs checks and a build on push, but only this workflow ships — so manual dispatch is the fast path.)concurrencywithcancel-in-progress: falsestops a manual run and the scheduled run from deploying on top of each other, without ever cancelling a deploy halfway.
If you’re on Cloudflare, I’ve written up the full Workers deploy setup
separately — and why Workers rather than Pages for a new
site. Building in Actions and pushing with wrangler deploy has a side benefit here: scheduled
rebuilds don’t consume your host’s build minutes.
The fine print on GitHub’s cron
Two properties of scheduled workflows to know before you rely on one:
The schedule drifts. GitHub queues scheduled runs and starts them when capacity allows —
typically a few minutes late, occasionally ten or more at busy hours (the top of the hour is
the worst; scheduling at :07 or :23 helps). For “the post appears overnight” this is
irrelevant. If you need minute-precision publishing, a static rebuild is honestly the wrong
tool.
Inactive repos get their schedules suspended. GitHub disables cron workflows in a public repo after 60 days without activity, and it emails you first. On an actively-written blog you’ll never hit this — every post is a commit — but a site you leave alone for a season can stop publishing silently. A calendar note, or any trivial commit, resets the clock.
Neither is a real problem for a blog; both are surprising the first time.
What about on-demand triggers instead?
You could get fancier: a Cloudflare Worker with a Cron Trigger that calls a deploy hook, or logic that checks whether any post’s date arrived today and skips the build otherwise. I looked at both and kept the dumb version, deliberately.
A daily unconditional rebuild costs about two build-minutes a day on a site this size — well inside the free tier — and doubles as a freshness check: if a dependency or a build step breaks, I find out from tomorrow morning’s red ✗ email, not from a reader. Skipping the build when there’s nothing to publish saves pennies and costs you that signal. Static sites win by being boring; the publishing pipeline should be the most boring part of all.
The complete picture
- Posts carry
pubDatein frontmatter; the schema coerces it to aDate. - One shared
publishedOnlypredicate filters every collection query — pages, tags, RSS, sitemap, structured data — comparing against midnight in your timezone, not UTC. - Dev mode shows everything; production builds only the past.
- A daily GitHub Actions cron rebuilds and deploys, turning each date boundary into a publish
event.
workflow_dispatchcovers the “I need it live now” case.
Scheduling was supposedly the feature static sites can’t have. It’s about forty lines total, none of them running at request time — which means it also can’t go down at request time. Once you’ve had “write three posts on Sunday, publish Tuesday through Thursday” work by itself, you don’t go back to publishing by hand.