One-off tip

Support checkout

  1. 1 Choose amount
  2. 2 Payment
  3. 3 Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Scheduled publishing in Astro on Netlify
Cover image: A calendar with red push-pin buttons — Photo by Towfiqu barbhuiya on Unsplash
astro netlify engineering

Scheduled publishing in Astro on Netlify

A date-aware filter, a scheduled function, and a daily build hook — no CMS needed

Published
19 May 2026
Read time
9 min read
SeriesPart of How this blog was built — documenting every decision that shaped this site.

Static sites have an elegant deployment story right up until you need to publish something on a specific date. A CMS solves this with a “schedule” button. A database-backed blog solves this with a query clause. A static site rebuilds once at deploy time — after that, nothing changes until the next deploy.

For most personal blogs that’s fine. Mine has posts queued weeks ahead with deliberate publish dates, so letting it drift wasn’t an option.

The solution has three parts: a helper that knows whether a post is visible right now, a scheduled function that triggers a daily rebuild, and a cron expression chosen so the build always fires before 9am UK time. None of it requires a CMS or a database.

The problem with draft: false alone

The draft field already keeps in-progress posts off the live site. But draft is a binary flag set at write time — you have to remember to flip it, and the post goes live on the next deploy, not at a predictable time.

What’s needed is a second condition: the post’s pubDate must be in the past before it appears. The build already has access to the current time, so this is a straightforward filter.

isPublished() — one filter to rule them all

Every page and component that calls getCollection("posts") needs to apply the same logic. The cleanest way to enforce this is a shared helper in src/utils/drafts.ts:

src/utils/drafts.ts
// Drafts are hidden by default. `pnpm dev` enables them locally via SHOW_DRAFTS=true.
// Also enabled in production when SHOW_DRAFTS=true (used by the preview branch deploy).
export const showDrafts: boolean = import.meta.env.SHOW_DRAFTS === "true";
type PublicationData = { draft: boolean; pubDate: Date };
export type PublicationStatus = "draft" | "scheduled" | "published";
function getPublicationData(
input: { data: PublicationData } | PublicationData,
): PublicationData {
return "data" in input ? input.data : input;
}
export function getPublicationStatus(
input: { data: PublicationData } | PublicationData,
): PublicationStatus {
const data = getPublicationData(input);
if (data.draft) return "draft";
if (data.pubDate > new Date()) return "scheduled";
return "published";
}
export function isPubliclyPublished(post: {
data: { draft: boolean; pubDate: Date };
}): boolean {
return getPublicationStatus(post) === "published";
}
// Returns true for posts that should be visible at build/request time.
// Hides drafts (unless showDrafts) and posts whose pubDate is in the future.
export function isPublished(post: {
data: { draft: boolean; pubDate: Date };
}): boolean {
if (showDrafts) return true;
return isPubliclyPublished(post);
}

isPublished replaces every inline draft check across the codebase. Before this, each call site had a slightly different spelling of the same test — and none of them checked the date:

// Before — only checked draft, missed pubDate entirely
.filter((post) => !post.data.draft)
// After — consistent and date-aware
.filter(isPublished)

The call sites appear in pages, paginated routes, tag pages, and sidebar components — nine files in total. Replacing them all at once means there is no path through the build where a future-dated post can slip through.

Two functions in drafts.ts are worth keeping straight:

  • isPublished — use this for rendering post lists. When SHOW_DRAFTS=true (set by default when you run pnpm dev), it passes through drafts and scheduled posts so you can preview queued content locally. On production builds it hides both.
  • isPubliclyPublished — use this anywhere that must reflect strict public state regardless of preview mode: RSS feeds, post counts, sitemaps. It always behaves as if SHOW_DRAFTS is off.

Setting pubDate values

For the filter to work predictably, pubDate values need to be straightforward UTC timestamps with no offset:

pubDate: 2026-04-13T00:00:00

A date like 2026-04-13T09:00:00+01:00 evaluates to 08:00 UTC. If the build fires at 07:45 UTC, the post will not appear until the following day’s build — one day late and silently wrong. Midnight UTC removes this class of error entirely.

If two posts share the same date and you care about their sort order, a short offset keeps them before the build window and in the intended sequence:

# Appears first in descending sort (higher timestamp)
pubDate: 2026-03-30T00:10:00
# Appears second
pubDate: 2026-03-30T00:00:00

The scheduled Netlify function

Astro builds the site once at deploy time. To have it pick up newly-eligible posts each day, we need to trigger a fresh deploy on a schedule.

Netlify supports this natively: a function declared with a schedule in netlify.toml runs as a cron job. Our function’s only job is to call the Netlify build hook API:

netlify/functions/scheduled-build.mjs
export default async function handler() {
const hookId = process.env.BUILD_HOOK_ID;
if (!hookId) {
console.error("BUILD_HOOK_ID is not set — skipping scheduled build.");
return;
}
const url = `https://api.netlify.com/build_hooks/${encodeURIComponent(hookId)}`;
const res = await fetch(url, { method: "POST" });
if (res.ok) {
console.log("Scheduled build triggered successfully.");
} else {
console.error(`Failed to trigger build: ${res.status} ${res.statusText}`);
}
}

No npm packages needed — the function uses the standard fetch and an environment variable for the hook ID.

netlify.toml configuration

The schedule is declared alongside the function configuration in netlify.toml.

netlify.toml
[functions]
directory = "netlify/functions"
node_bundler = "esbuild"
# Rebuild daily so future-dated posts go live automatically.
# Requires BUILD_HOOK_ID env var — see netlify/functions/scheduled-build.mjs.
[functions."scheduled-build"]
schedule = "45 7 * * *" # always before 09:00 UK time: 07:45 GMT in winter, 08:45 BST in summer

Netlify’s cron syntax is always UTC. 45 7 * * * fires at 07:45 UTC — before 09:00 in both BST (UTC+1) and GMT (UTC+0). If you’d rather guarantee 08:45 BST and accept 07:45 GMT in winter, the expression is the same — there is no timezone-aware option in cron, so you pick the UTC value that satisfies your worst case.

Dashboard setup

One step in the Netlify dashboard is required:

  1. Site configuration → Build & deploy → Build hooks — create a hook named “Scheduled publish”. Netlify generates a URL ending in a unique ID.
  2. Copy just the ID from the URL (the path segment after /build_hooks/).
  3. Site configuration → Environment variables — add a new variable:
    • Key: BUILD_HOOK_ID
    • Value: the ID you copied
  4. Add BUILD_HOOK_ID to your local .env.example (without a value) so it’s documented for anyone cloning the repository.

The function reads this variable and constructs the full URL itself, so the secret is never hardcoded in the repository.

Verifying the setup

Before waiting for the next scheduled run, confirm everything is wired up correctly.

Trigger a build manually. POST to the hook URL directly from your terminal:

Terminal window
curl -X POST "https://api.netlify.com/build_hooks/YOUR_HOOK_ID"

Replace YOUR_HOOK_ID with the ID you copied. Netlify responds with {} and a 200 — check the Deploys tab in the dashboard to confirm a build starts within a few seconds.

Check function logs after a scheduled run. Once the cron fires, Netlify logs the function’s output under Functions in the dashboard. Select scheduled-build and look for Scheduled build triggered successfully. in the invocation log. If BUILD_HOOK_ID is missing or misconfigured, the error message from the early return will appear there instead.

How it fits together

A post ready to publish looks like this:

---
title: "My next post"
pubDate: 2026-04-20T00:00:00
draft: false
---

Push to main. Netlify deploys immediately — because pubDate is in the future, isPublished returns false and the post is excluded from every page. On the morning of April 20th, the scheduled-build function fires at 07:45 UTC, triggers a new deploy, and isPublished returns true. The post goes live without any manual intervention.

Where draft still fits in

With scheduled publishing in place, draft and pubDate serve two distinct roles.

draft: true means the post isn’t ready — you’re still writing it, it might be half-finished, and you don’t want it visible even in a deploy preview. It hides the post indefinitely regardless of its date. Running pnpm dev reveals it locally (SHOW_DRAFTS=true is set by default in the dev script). Nothing goes live until you explicitly flip the flag.

draft: false with a future pubDate means the post is complete and queued. You’re done writing, you’re happy with it, and you want it to go live on a specific date without any further action from you.

The practical workflow:

  1. Start writing → draft: true, no pubDate needed yet
  2. Finish writing → draft: false, set a future pubDate
  3. Push → the post sits invisibly in the repository until its date arrives
  4. Morning of the publish date → the scheduled build picks it up automatically

The only thing to be careful about: if you push draft: false with a past pubDate, the post goes live immediately on that deploy rather than waiting for the next scheduled build. Past dates are treated as “already due”, not scheduled.

What this doesn’t do

Minute-precision timing. Builds take a minute or two, so “publish on April 20th” means “publish sometime between 07:45 and ~08:00 UTC on April 20th”. For a personal blog that’s entirely fine.

Build deduplication. If you push a code change on the same morning, Netlify may queue two builds back to back. Both would produce the correct result — the second one is just redundant. You could add a check in the function to skip the trigger if a recent deploy already exists, but it’s rarely worth the complexity.

Unpublishing. Moving a post’s pubDate forward while keeping draft: false will not remove it from the live site because Netlify serves the last successful build until a new one is deployed. Drafts (draft: true) are the right tool for keeping content off the site.

Wrapping up

With the three pieces in place, scheduled publishing runs without any manual intervention. drafts.ts gives you isPublished for filtering post lists and isPubliclyPublished for feeds and counts. The scheduled function fires daily at the time you configured and triggers a fresh build. The build hook in the Netlify dashboard is the only setup step that lives outside the repository.

The authoring workflow reduces to: write the post, set draft: false with a future pubDate, push, and walk away. The scheduled build on publish day takes care of the rest.

Working on something similar?

If you’re building a content pipeline, a scheduled job, or anything that needs reliable deploy automation — I’m available for consulting. Get in touch via the contact page and tell me what you’re working on.

Working on something similar?

Need help raising the bar?

I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.

  • Engineering practice review
  • Hands-on delivery
  • Team mentoring
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

Free · Practical · One email per post

Get practical engineering notes

One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.