Your client clicks Publish. Then what?
Automatic rebuilds for a headless WordPress + Astro site — the build hook, the debounce, the WP-Cron trap, and the two failure modes nobody writes about.
There are a lot of good tutorials about pairing WordPress with Astro. Almost all of them
end at the same place: you fetch your posts, you run npm run build, you deploy,
and the article stops. (We checked the four tutorials the Astro docs themselves link to.
None covers rebuild-on-publish.)
That's a working demo. It is not a site you can hand to a client. Because the moment you hand it over, someone who is not you writes a post, presses Publish, refreshes the public site — and nothing happens. Nothing is broken, either. The static build is exactly as old as the last time you ran it.
That gap is the subject of this article. It's the least glamorous part of a headless setup and the part that decides whether the project survives contact with a real editor.
Three ways to rebuild, and what each one really costs
| Approach | What it costs |
|---|---|
| You rebuild by hand | Free, and fine while you're building. Fatal at handover: your client now has to message you to make a typo fix appear. You've made yourself a dependency for the life of the site. |
| Scheduled rebuild (every 15 min, hourly, nightly) |
One line of config. You pay for it twice: the editor waits up to a full interval without knowing why, and you burn build minutes on the intervals where nothing changed. A good safety net, a poor main mechanism. |
| Build hook on publish | The site rebuilds because something changed. Costs a few lines of PHP and the edge cases in the rest of this article — which is why tutorials skip it. |
Step 1 — get a build hook URL from your host
Most static hosts expose a URL you POST to in order to start a build. On Netlify (Project configuration → Build & deploy → Continuous deployment → Build hooks), Cloudflare Pages (Workers & Pages → your project → Settings → Builds → Add deploy hook) and Vercel (Settings → Git → Deploy Hooks), that URL is the secret: no API client, no auth header, no SDK.
Two exceptions worth knowing before you pick an approach. GitLab CI (Settings →
CI/CD → Pipeline trigger tokens) needs both a token and a ref
parameter. GitHub Actions needs a real token in an Authorization
header, an event_type in the body, and a workflow listening on
repository_dispatch.
wp-config.php, outside
version control:
define( 'REBUILD_HOOK_URL', 'https://api.netlify.com/build_hooks/xxxxxxxx' );
Step 2 — call it when content changes
You may reach for a plugin here. The best-known one, JAMstack Deployments, still works — it's what we run ourselves — but its last release is from November 2020 and it's marked as tested up to WordPress 5.5. That may be fine for you; it's worth knowing before you make it load-bearing on a client site.
The mechanism is small enough to own. The full file is at the end of this article; here is the part that decides when to rebuild:
add_action( 'transition_post_status', 'rebuildhook_on_transition', 10, 3 );
add_action( 'before_delete_post', 'rebuildhook_on_delete', 10, 2 );
Three decisions in there matter more than the rest:
- Check both statuses, not just the new one.
'publish' !== $new_status && 'publish' !== $old_statusfires on publishing, on updating an already-published post (both statuses arepublish), and on unpublishing. That last one matters: a post the client sent back to draft is still on the live site until you rebuild. - The menu guard is the one that's load-bearing. Menu items are stored with status
publish, so without excludingnav_menu_itemevery menu tweak triggers a build. Conversely, the revision guard is belt-and-braces only: revisions are saved with statusinheritand never get past the status check. If you've read elsewhere that you need it to survive autosaves — you don't, and the real burst source is something else entirely (step 3). - The post-type list is yours to edit. The file ships with
array( 'post', 'page' ), which is almost certainly wrong for your site. If your frontend queriesrealisationorevenement, add them — there's arebuildhook_post_typesfilter for exactly that. What this won't catch is a menu change, a term rename, or a replaced media file; see the limits.
wp_delete_post() does
not fire transition_post_status. And wp_trash_post()
starts with if ( ! EMPTY_TRASH_DAYS ) { return wp_delete_post( $post_id, true ); }
— so on any site with define( 'EMPTY_TRASH_DAYS', 0 ), a common performance
tweak, "Move to Trash" deletes outright and fires no transition at all. Your client removes
an outdated legal page, WordPress deletes it, and it stays public indefinitely. That's why
the file hooks before_delete_post as well.
Step 3 — the part everyone forgets: the burst
Here's the failure you'll actually hit. Your client sits down on a Monday morning and
publishes eleven pages in a row — or, more often, clicks Update on the same page
nine times while tweaking a sentence. Every one of those is a
publish → publish transition. They pass every filter above.
The naive version fires eleven builds. Depending on your host they queue, they race, some get cancelled in favour of the last one (Vercel does this), or you simply pay for all eleven. Vercel also caps deploy hooks at 60 triggers per hour per project, so a big enough burst just gets refused.
So: fire immediately, then hold a lock, and remember whether anything happened while the lock was up. The order of operations matters more than it looks — the follow-up event is armed before anything else, because a "pending" flag with nothing scheduled to consume it is a change that never gets built:
function rebuildhook_request() {
// Arm the follow-up FIRST: the pending branch below must never be able to
// leave a flag set with nothing scheduled to consume it. And check that the
// scheduling actually worked — wp_schedule_single_event() can return false.
$armed = rebuildhook_arm();
if ( $armed && get_transient( 'rebuildhook_lock' ) ) {
set_transient( 'rebuildhook_pending', 1, DAY_IN_SECONDS );
return;
}
// Clear the flag BEFORE claiming the lock: in the other order, a concurrent
// request can raise it in between and we'd delete a change we never built.
delete_transient( 'rebuildhook_pending' );
set_transient( 'rebuildhook_lock', 1, rebuildhook_window() );
rebuildhook_defer_fire();
}
The first publish still goes out instantly — that's what the client sees, and it's what makes the setup feel alive. The rest collapse into a single follow-up build.
get_transient() followed by
set_transient() is not atomic: two simultaneous publishes can both take the
lock and both fire. If that matters, the core pattern for a real lock is
WP_Upgrader::create_lock(), which uses an INSERT IGNORE on the
options table. On a site with a persistent object cache — standard at managed hosts —
transients can be evicted before they expire: an evicted lock means extra builds,
an evicted pending flag means a change that never gets built. And don't set
REBUILDHOOK_WINDOW to 0 to "disable" the debounce:
set_transient() treats an expiration of 0 as never expires, so the
lock would freeze permanently and nothing would ever build again. The file clamps it to a
10-second minimum for that reason.
Step 4 — the WP-Cron trap
That follow-up build depends on wp_schedule_single_event, and here's what
catches people out: WP-Cron isn't a cron job. It's hooked to
init, so it only gets a chance to run when something requests the site — any
front-end hit, wp-admin, REST or AJAX — and it fires a non-blocking loopback request so the
visitor never waits for the job itself.
On a headless site there's no front-end traffic at all. Your REST and GraphQL requests do spawn cron, but that traffic is your build's: it arrives in a burst during a build and then stops. So an event scheduled for two minutes after a build can sit there until the next build asks for it — which is exactly the wrong way round. That's why the symptom is intermittent rather than total: the catch-up build shows up hours later, when you happen to open wp-admin.
If you have SSH
# wp-config.php — above the require_once ABSPATH . 'wp-settings.php'; line
define( 'DISABLE_WP_CRON', true );
# crontab -u www-data -e — the web user, NOT root: WP-CLI refuses to run as
# root without --allow-root, and files it writes as root will break PHP-FPM.
# Absolute path: cron's PATH is /usr/bin:/bin, and wp lives in /usr/local/bin.
# Keep stderr: this log is the only way you'll learn that cron stopped running.
*/5 * * * * cd /var/www/example.com && /usr/local/bin/wp cron event run --due-now >> /var/log/wp-cron.log 2>&1
If you don't — shared hosting, cPanel, no WP-CLI
This is most agency projects, and it's the step where people give up after doing all the work. You don't need WP-CLI. WordPress ships an endpoint you can hit over plain HTTP, which is all a cPanel cron task or a free external scheduler can do:
*/5 * * * * curl -fsS "https://example.com/wp-cron.php?doing_wp_cron" >/dev/null
Same DISABLE_WP_CRON line in wp-config.php, then paste that URL
into your panel's cron section — or into any external ping service, if your host doesn't
offer cron at all. It's the same mechanism WordPress would have triggered itself; you're
just supplying the requests that a headless site no longer gets.
And if you can't have cron at all: the file hooks the same catch-up to
admin_init, so any admin page load drains a pending build. It's not a
schedule — nothing happens while nobody is logged in — but it means a change made inside
the lock window isn't lost forever, it's built the next time someone opens wp-admin. If
even that isn't enough, shorten REBUILDHOOK_WINDOW to 30 seconds and accept
the extra builds. That's a real, defensible setup, not a broken one.
Step 5 — check that it actually works
- The hook, alone. Don't paste the URL on the command line — it lands in your shell
history and in
ps. Read it in first:
A build should start within seconds. If this fails, nothing downstream matters.read -rs HOOK curl -X POST -i "$HOOK" - The hook, from WordPress. Publish a test post; a build should start. If it
doesn't, check your debug log — and check that outbound HTTP isn't blocked:
WP_HTTP_BLOCK_EXTERNALinwp-config.phpwill silently kill the hook. It's normally set by the site owner or a security plugin rather than by a host, andWP_ACCESSIBLE_HOSTSlets you allow just your build hook's domain through. - The burst. Publish three posts in under a minute. You should see two builds: one immediate, one when the follow-up event runs — allow a full cron interval for that (five minutes with the crontab above, not two). Three builds means the lock isn't holding. One build after two cron intervals means the follow-up never ran: back to step 4.
WP_HTTP_BLOCK_EXTERNAL blocks a
request, WordPress builds the error string with
sprintf( 'User has blocked requests through HTTP to the URL: %s.', $url ) —
your build hook secret, in full, in wp-content/debug.log, which is often
readable over HTTP. Log the error code, not the message. The file below does.
The trap that broke this article's first draft
The first version of everything above called the build hook straight from
transition_post_status. That looks right and is subtly wrong, and it took a
code review to catch. In wp-includes/post.php:
5180: wp_transition_post_status( ... ) <- the hook fires HERE
5290: do_action( 'save_post' )
5304: wp_after_insert_post( ... )
And in the REST controller — the path the block editor and every headless client take —
wp_update_post() runs on line 980, then handle_featured_media(),
handle_terms() and meta->update_value() on lines 1003 to 1025.
Categories, tags, the featured image and every custom field are written after the transition fires. Trigger the build there and your static site rebuilds from content that is one save behind: the post appears, uncategorised, with no featured image and empty Pods or ACF fields. Worse, it's intermittent — a fast build loses the race, a slow one doesn't.
The fix is to detect on the transition but fire on shutdown, which runs
after every write:
function rebuildhook_defer_fire() {
static $deferred = false;
if ( $deferred ) {
return;
}
$deferred = true;
add_action( 'shutdown', 'rebuildhook_fire', 1 );
}
The static flag also collapses a bulk edit or a WP-CLI import loop into a single call per request, which the transient lock alone wouldn't do.
A trap in the obvious optimisation
The natural instinct is to pass 'blocking' => false so the editor's
Publish click doesn't wait for the HTTP round-trip. It doesn't do that.
With the default Requests transport, curl_exec() runs synchronously either
way; WordPress just returns early and hands you a fake response array. Publish waits for
the round-trip regardless.
What you actually buy with it is the loss of every error: process_response()
returns before the curl_errno() check, so a revoked hook, a DNS failure or a
404 all come back looking like nothing happened. A short timeout with blocking on is
strictly better — you wait the same time and you learn the status code:
$response = wp_remote_post(
REBUILD_HOOK_URL,
array(
'timeout' => 5,
'blocking' => true, // 'false' is not async in WordPress.
)
);
If you genuinely want it off the request path, the only reliable route is
fastcgi_finish_request() on shutdown, where your host supports it.
When the build fails — the last call you'll still get
Everything above makes the site rebuild when your client publishes. None of it tells you when that stops working: build minutes exhausted, a broken build, a rotated hook. The client publishes, waits, sees nothing, and calls you — the exact call this whole setup exists to prevent.
Two things, neither of which takes long:
- Turn on your host's failure notification. Netlify, Vercel and Cloudflare all send build-failure email or webhooks. It's off by default on nobody's list of priorities, and it's the single highest-value five minutes in this article.
- Log the status code, as the file below does. A hook returning 401 or 404 for a week is otherwise completely silent.
What to tell the client, in one sentence
This part has nothing to do with code and saves the most support time. An editor who publishes and sees no change assumes it's broken, and messages you.
Tell them, in writing, at handover:
"When you press Publish, the public site updates itself within a couple of minutes. It is not instant, and that's normal — no need to click Publish again, it won't go faster. If it's been ten minutes, then something is wrong and you should tell me."
Ou, en français : « Quand vous cliquez sur Publier, le site public se met à jour tout seul en une à deux minutes. Ce n'est pas instantané, c'est normal — inutile de recliquer sur Publier, ça n'ira pas plus vite. Si au bout de dix minutes il ne s'est rien passé, c'est qu'il y a un problème : prévenez-moi. »
Give them a number and a threshold. Without the threshold they'll either panic at 30 seconds or never report a genuine outage. The "don't click again" line matters too: it's the human half of the debounce you just built.
What this doesn't solve
- Previews. A draft has no build, so the client can't preview an unpublished page on the real design. Solving that properly means an on-demand rendering path.
- Everything that isn't a post. Menu changes, term renames, a replaced media file,
an options page — none of them are a post transition, so none trigger a build. You can hook
wp_update_nav_menuandedited_termthe same way; just know that they're not covered by default, and tell your client which changes are "live" and which aren't. - Big sites. Rebuilding 5,000 pages for a typo is wasteful. Astro's Content Layer
API makes the full build much faster (Astro measured up to 5× on Markdown-heavy
sites); genuinely partial builds are a separate, still-experimental
experimental.incrementalBuildflag that needs acacheKeyper route and switches itself off whenbuild.concurrencyis above 1. - Scheduled posts. A post set to publish at 9 a.m. transitions via WP-Cron — so step 4 isn't optional if your client schedules anything.
- Media.
wp_delete_attachment()doesn't firebefore_delete_post, so adding'attachment'to the post-type filter gives you partial coverage only. - WP-CLI with
--skip-pluginsor--skip-themes. Yourrebuildhook_post_typesfilter isn't registered in that context, so custom post types are silently ignored.
The whole file
Drop this in wp-content/mu-plugins/rebuild-on-publish.php. Must-use plugins
load automatically and can't be deactivated by accident from the admin. Set
REBUILD_HOOK_URL in wp-config.php, and add your own post types to
the list at the top.
<?php
/**
* Plugin Name: Rebuild on publish
* Description: Calls the static host's build hook when public content changes.
*/
defined( 'ABSPATH' ) || exit;
// Override in wp-config.php if you want a different window.
// Minimum 10 seconds: set_transient() treats an expiration of 0 as "never
// expires" and stores the option as autoloaded, which would freeze the debounce.
defined( 'REBUILDHOOK_WINDOW' ) || define( 'REBUILDHOOK_WINDOW', 120 );
add_action( 'transition_post_status', 'rebuildhook_on_transition', 10, 3 );
add_action( 'before_delete_post', 'rebuildhook_on_delete', 10, 2 );
add_action( 'rebuildhook_flush', 'rebuildhook_flush_pending' );
// Catch-up leg. WP-Cron is traffic-driven (wp_cron() runs on 'init' and defers
// to shutdown), and a headless WordPress has no public traffic by design; many
// installs also set DISABLE_WP_CRON with no system cron behind it. Any admin
// page load drains a pending build.
add_action( 'admin_init', 'rebuildhook_flush_pending' );
function rebuildhook_window() {
return max( 10, (int) REBUILDHOOK_WINDOW );
}
/**
* Post types your frontend actually queries. Add your own CPTs here.
*/
function rebuildhook_types() {
$types = apply_filters( 'rebuildhook_post_types', array( 'post', 'page' ) );
return is_array( $types ) ? $types : array();
}
function rebuildhook_on_transition( $new_status, $old_status, $post ) {
// Public now, or just stopped being public.
if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
return;
}
// Belt and braces: revisions are status 'inherit' and never get here anyway.
if ( wp_is_post_revision( $post ) ) {
return;
}
// Menu items really are stored as 'publish' — this guard is load-bearing.
if ( 'nav_menu_item' === $post->post_type ) {
return;
}
if ( ! in_array( $post->post_type, rebuildhook_types(), true ) ) {
return;
}
rebuildhook_request();
}
/**
* Permanent deletion never fires transition_post_status.
* Reachable with EMPTY_TRASH_DAYS = 0, REST ?force=true, wp post delete --force.
*/
function rebuildhook_on_delete( $post_id, $post = null ) {
// $post only exists since WP 5.5. Without the default value, deleting a post
// raises an ArgumentCountError (fatal) on older installs.
if ( ! $post instanceof WP_Post ) {
$post = get_post( $post_id );
}
if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status ) {
return;
}
if ( ! in_array( $post->post_type, rebuildhook_types(), true ) ) {
return;
}
rebuildhook_request();
}
function rebuildhook_request() {
if ( wp_installing() ) {
return;
}
// Arm the follow-up FIRST: the pending branch must never leave a flag set
// with nothing scheduled to consume it. wp_schedule_single_event() can fail
// (pre_schedule_event / schedule_event filters, cron manager plugins), so
// check that it actually worked.
$armed = rebuildhook_arm();
if ( $armed && get_transient( 'rebuildhook_lock' ) ) {
set_transient( 'rebuildhook_pending', 1, DAY_IN_SECONDS );
return;
}
// Clear the flag BEFORE claiming the lock: in the other order, a concurrent
// request can raise it in between and we would delete a change we never built.
delete_transient( 'rebuildhook_pending' );
set_transient( 'rebuildhook_lock', 1, rebuildhook_window() );
rebuildhook_defer_fire();
}
function rebuildhook_arm() {
if ( wp_next_scheduled( 'rebuildhook_flush' ) ) {
return true;
}
return (bool) wp_schedule_single_event( time() + rebuildhook_window() + 5, 'rebuildhook_flush' );
}
function rebuildhook_flush_pending() {
if ( ! get_transient( 'rebuildhook_pending' ) ) {
return;
}
// Still inside a window: keep the flag, come back after it.
if ( get_transient( 'rebuildhook_lock' ) ) {
rebuildhook_arm();
return;
}
delete_transient( 'rebuildhook_pending' );
set_transient( 'rebuildhook_lock', 1, rebuildhook_window() );
rebuildhook_defer_fire();
}
/**
* transition_post_status runs BEFORE save_post, and before terms, meta, featured
* image and template are written (the REST controller writes them after
* wp_update_post()); before_delete_post runs before the row is gone. Calling the
* build hook from there would let the build read stale content back. shutdown
* runs after every write.
*/
function rebuildhook_defer_fire() {
static $deferred = false;
if ( $deferred ) {
return;
}
$deferred = true;
add_action( 'shutdown', 'rebuildhook_fire', 1 );
}
function rebuildhook_fire() {
if ( ! defined( 'REBUILD_HOOK_URL' ) || ! REBUILD_HOOK_URL ) {
rebuildhook_log( 'REBUILD_HOOK_URL is not defined, nothing was called' );
return;
}
$response = wp_remote_post(
REBUILD_HOOK_URL,
array(
'timeout' => 5,
'blocking' => true, // 'false' is not async in WordPress. See the article.
)
);
if ( is_wp_error( $response ) ) {
// Never log the message or the data: several WP_Http errors embed the URL
// (http_request_not_executed, and cURL messages such as "Could not resolve host").
rebuildhook_log( 'request failed (' . $response->get_error_code() . ')' );
return;
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code > 299 ) {
rebuildhook_log( 'build hook returned HTTP ' . $code );
}
}
function rebuildhook_log( $message ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[rebuild] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
If you try this and something in it is wrong, we'd genuinely like to know — yves@cms.blue. It's had three rounds of review and none of them is a production site with your plugins on it.