p4ni.

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:

  1. At build time, exclude every post whose pubDate is in the future.
  2. 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_dispatch is 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.)
  • concurrency with cancel-in-progress: false stops 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 pubDate in frontmatter; the schema coerces it to a Date.
  • One shared publishedOnly predicate 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_dispatch covers 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.