Theme Auto-Updates (self-hosted, license-gated)
Optional. Lets a customer who bought a license key get "Update Available"
notices and one-click updates in wp-admin, the same UX as a
WordPress.org theme — without actually being on WordPress.org (see
Licensing for why this theme can't be listed there).
How it works
wp-admin (periodic core update check)
→ inc/updater.php: pre_set_site_transient_update_themes
→ GET {nextjs_url}/api/theme-updates/check?version=X&license=Y
→ verifies the license (src/lib/license.ts's verifyLicenseKey)
→ compares X against releases/manifest.json's "latest"
→ returns {newVersion, downloadUrl} or {newVersion: null}
→ injects the response into WP's own update_themes transient
→ customer clicks "Update Now" → WP downloads from downloadUrl
→ GET {nextjs_url}/api/theme-updates/download?version=X&license=Y
→ re-verifies the license, streams the versioned ZIP
No database, no third-party service (Freemius, EDD Software Licensing,
etc.) — license verification is the same offline RSA-signature check used
everywhere else in this project, just run server-side in Node instead of
WordPress's PHP. releases/manifest.json is the only state, and it's a
committed file, not a runtime database.
Cutting a release
node scripts/build-theme-release.js --version=1.0.1 --changelog="Fixed X, added Y"
This updates wp-content/themes/wp-headless-theme/style.css's Version:
header, zips the theme into nextjs-starter-kit/releases/, and records it
as the new latest in nextjs-starter-kit/releases/manifest.json. Commit
both the style.css change and nextjs-starter-kit/releases/ together, then
deploy — the next check request from any licensed customer site will see
the new version.
Why releases/ lives inside nextjs-starter-kit/, not the repo root:
Coolify's Nixpacks build only bundles the app's own base directory into the
deployed container (confirmed from a real deploy log — the Docker build
context was ~550KB, matching just nextjs-starter-kit/, not the whole
repo). Anything the running app needs to read at runtime has to live where
it's actually deployed. scripts/sync-template.js excludes releases/
from the npx create-headlesscore template, so scaffolded projects don't
inherit old release ZIPs.
Setup
No env vars beyond what Stripe Licensing already
needs: LICENSE_SIGNING_PRIVATE_KEY_PEM (used to derive the public key for
verification — the update routes verify with the same keypair that mints
keys). On the WordPress side, nothing to configure beyond what's already
required for licensing to work at all — License Key and Next.js
URL in Headless Core → Settings. If either is empty,
inc/updater.php's hook is a no-op.
What this does NOT do
themes_apifilter (the "View version X details" popup) — WordPress still shows the plain "Update Available" notice and Update button without it; only the optional details thickbox is missing.- Automatic (unattended) updates. This only makes the update visible
and downloadable through WP's normal manual-update flow — it doesn't
enable
auto_update_themebehavior. Adding that is a one-lineadd_filter( 'auto_update_theme', '__return_true' )if wanted later, deliberately not defaulted on here (an unattended update to code that hasn't been reviewed is a bigger blast radius than showing a notice). - Rollback. Only the latest version is ever offered; older ZIPs stay
in
releases/for reference but there's no in-admin "downgrade" UI. - Revoke access on refund. Same limitation as Stripe Licensing — an expired/never-issued key fails the check, but there's no denylist for a specific revoked key that hasn't technically expired yet.