DEV Community

Roger Rajaratnam
Roger Rajaratnam

Posted on • Originally published at sourcier.uk

Scheduled publishing in Astro on Netlify

Original post: Scheduled publishing in Astro on Netlify

Series: Part 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:

// 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);
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

[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
Enter fullscreen mode Exit fullscreen mode

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:

curl -X POST "https://api.netlify.com/build_hooks/YOUR_HOOK_ID"
Enter fullscreen mode Exit fullscreen mode

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
---
Enter fullscreen mode Exit fullscreen mode

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.

Mermaid diagram

Diagram fallback for Dev.to. View the canonical article for the full version: https://sourcier.uk/blog/scheduled-publishing-astro

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.

Top comments (0)