Auto-export your WordPress site as static HTML and deploy to Cloudflare Pages on every publish/update.
A WordPress plugin that turns your install into a headless dashboard: editors keep using WordPress, but public visitors hit a static site living on Cloudflare's edge — fast, free, indestructible.
| Feature | What it means |
|---|---|
| 🌐 Whole-site export | Homepage, posts, pages, custom post types, taxonomy archives, author archives — all rendered to static HTML on every publish/update. |
| 🌍 TranslatePress multilingual export | Auto-detects TranslatePress and adds every secondary-language URL to the export, so each language renders to static HTML in the deploy. Translations live in the WP database but are rendered server-side, so the frozen HTML is already fully translated. Works with subdirectory mode (/fr/, /de/) — the only multilingual layout one Cloudflare Pages project can serve; subdomain/separate-domain URLs are skipped (and logged) since one deploy serves one host. Opt out via sforge_translatepress_export. |
| 🎨 Inlined CSS | Every linked stylesheet is fetched and embedded inline. Each deployed page is self-contained — no external CSS round-trips. |
| ⚡ Featured image fetchpriority | Auto-marks the post's featured image with fetchpriority="high", loading="eager", decoding="async" so the browser prioritises the LCP candidate. Improves Core Web Vitals. Works with any theme using the_post_thumbnail(). |
| 🧠 Built-in SEO metadata injection | Auto-emits <meta description>, robots, canonical, full Open Graph (og:type, og:title, og:description, og:url, og:image, article:*, profile:*), Twitter Card, and rich JSON-LD schemas — all from native WP data. Auto-disables when Yoast, Rank Math, AIO SEO, SEOPress, or The SEO Framework is detected, to prevent duplicate tags. |
| 📜 Rich JSON-LD schemas | WebSite + SearchAction, Organization, Article (with author Person + publisher), WebPage, BreadcrumbList, CollectionPage for taxonomy archives, Person + ProfilePage schema for author archives (with avatar, bio, sameAs social links), auto-generated FAQPage from FAQ blocks or <details><summary>, auto-generated HowTo from HowTo blocks or "How to..." titles with ordered lists. |
| 🗺️ Sitemap mirroring + fallback generation | Auto-discovers /sitemap.xml, /sitemap_index.xml, /wp-sitemap.xml, follows children, handles CDATA-wrapped <loc> entries, rewrites origin URLs to your live domain, and bundles all child sitemaps in the deploy. If origin has none, generates a valid <urlset> sitemap.xml from the crawled URL list (with <lastmod>, <changefreq>, <priority>) so the live site always ships a sitemap. |
| 📝 Editable robots.txt with auto-managed Sitemap line | In-admin textarea. Leave blank to auto-generate, or paste your own Allow: / Disallow: rules. The Sitemap: line is always auto-managed — any directive you type is stripped and replaced with the URL that points to the actual deployed sitemap path (sitemap.xml, sitemap_index.xml, wp-sitemap.xml, etc.) so robots.txt never points at a dead URL. |
| 🚧 Dashboard auto-noindex (social-aware) | On activation, blocks the WordPress install from search engines via 4-layer enforcement: physical Disallow: / robots.txt at webroot (existing file backed up), wp_robots filter adding noindex,nofollow meta, X-Robots-Tag HTTP header on every response, and a robots_txt filter for the dynamic fallback. Social/messaging scrapers (Facebook, LinkedIn, Twitter/X, Pinterest, WhatsApp, Slack, Discord, Telegram, Apple, Reddit, Tumblr, Mastodon, Bluesky, iframely, Embedly) are explicitly allowed /wp-content/uploads/ so og:image previews still render when shared. Plugin's own export fetches are exempt — deployed pages remain fully indexable. Auto-restores backup robots.txt on plugin deactivation. |
| 🛡️ noindex stripping | Defensive removal of noindex / nofollow / noarchive directives from rendered HTML — your live site stays indexable even if the dashboard is locked down. |
| ⏱️ Debounced auto-deploy | Rapid edit clusters collapse into one deploy. Configurable 10s–3600s. |
| ☁️ Cloudflare Direct Upload | Content-addressable upload API: only changed assets re-uploaded. No Git integration needed. No build minutes consumed. |
| 📊 Live progress UI | Activity log auto-refreshes every 4 seconds with batch-by-batch telemetry, render percentages, and a status pill (Idle / Queued / Working). |
| 📚 Built-in Setup Guide | Full colour-coded walk-through inside WP admin — no docs hunting. |
WordPress (your dashboard) Cloudflare (your live site)
┌────────────────────────────────┐ ┌──────────────────────┐
│ Editor publishes/updates post │ │ │
│ ↓ │ │ Cloudflare Pages │
│ on:transition_post_status │ │ ┌──────────────┐ │
│ ↓ │ │ │ / │ │
│ wp_schedule_single_event │ │ │ /post-slug/ │ │
│ (debounce 120s) │ │ │ /category/.. │ │
│ ↓ │ │ │ /author/.. │ │
│ Crawl URL list │ │ │ /sitemap.xml │ │
│ ↓ │ │ │ /robots.txt │ │
│ Render via wp_remote_get │ Direct │ └──────────────┘ │
│ ↓ (inject SEO + schemas) │ Upload │ │
│ ↓ (inline CSS, rewrite URLs) │ API │ │
│ ↓ (strip noindex) │ │ │
│ Mirror sitemap & robots.txt │ │ ① upload-token │
│ ↓ │ ─────────► │ ② check-missing │
│ Hash each file (SHA-256) │ │ ③ assets/upload │
│ ↓ │ │ ④ deployments │
│ POST manifest + branch │ │ (multipart) │
└────────────────────────────────┘ └──────────────────────┘
cd wp-content/plugins
git clone https://github.com/gunjanjaswal/staticforge-for-cloudflare-pages.git- Download/clone, zip the
staticforge-for-cloudflare-pages/folder. - WP Admin → Plugins → Add New → Upload Plugin → choose zip.
- Activate.
Open the in-plugin Setup Guide (
StaticForge for Cloudflare Pages → Setup Guide) for a richer walk-through with colour-coded sections. Six steps below are the executive summary.
- dash.cloudflare.com → Workers & Pages → Create application → Pages tab → Upload assets.
- Project name: lowercase slug (e.g.
mysite). Becomeshttps://mysite.pages.dev. - Drag-drop a placeholder
index.htmlto seed it. Plugin overwrites on first real deploy.
- Top-right avatar → My Profile → API Tokens → Create Token → custom token.
- Permission:
Account → Cloudflare Pages → Edit. - Account Resources: include your account.
- Copy the token — shown once.
- CF Dashboard → Workers & Pages overview → right sidebar → Account ID (32-char hex).
| Field | Value |
|---|---|
| Account ID | from step 3 |
| API Token | from step 2 |
| Pages Project | slug only (mysite), NOT mysite.pages.dev |
| Branch | main for production |
| Public Site URL | https://mysite.pages.dev (testing) → https://example.com (after DNS cutover) |
| Post Types | post, page, your CPTs |
| Inline CSS | ☑ |
| Inject SEO meta | ☑ (auto-disables if SEO plugin detected) |
| Auto-deploy | ☑ |
| Debounce | 120 |
Click Test Connection → expect ✅ → click Rebuild + Deploy Now → watch live log.
Final entry:
Deploy OK [<deployment-id>] https://<id>.<project>.pages.dev
@ CNAME <project>.pages.dev (proxied)
www CNAME <project>.pages.dev (proxied)
dashboard A <origin-ip> (proxied; CF terminates SSL)
Then update the plugin's Public Site URL to your final domain → Save → Rebuild + Deploy Now so canonicals + sitemap entries point to the live host.
When no third-party SEO plugin is detected, StaticForge for Cloudflare Pages automatically emits a complete SEO baseline into every page's <head>:
<meta name="description">— derived frompost_excerpt→ trimmedpost_content→ bio for authors → term description for taxonomies → site tagline as fallback<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"><link rel="canonical">- Open Graph:
og:type,og:title,og:description,og:url,og:site_name,og:locale,og:image(with width/height/alt),article:published_time,article:modified_time,article:author,article:section,article:tag,profile:first_name,profile:last_name,profile:username - Twitter Card:
summary_large_imagewhen image available,twitter:title,twitter:description,twitter:image,twitter:creator
| Page type | Schemas emitted |
|---|---|
| Homepage / Front page | WebSite + SearchAction, Organization |
| Single post | Article (with author Person, publisher Organization, image, dates, articleSection, keywords), BreadcrumbList, FAQPage (auto), HowTo (auto) |
| Static page / Custom post type | WebPage (with primaryImageOfPage, dates), BreadcrumbList, FAQPage (auto), HowTo (auto) |
| Author archive | Person (display name, URL, bio, avatar ImageObject 256×256, sameAs social links from user_url + twitter/facebook/linkedin/instagram/youtube/github user meta) + ProfilePage (linked via mainEntity to the Person), BreadcrumbList |
| Taxonomy / Category / Tag archive | CollectionPage, BreadcrumbList |
- FAQPage is emitted when the post contains:
- a Yoast FAQ block (
yoast/faq-block), - a Rank Math FAQ block (
rank-math/faq-block), - a SEOPress FAQ block (
seopress/faq-block), - OR native HTML5
<details><summary>Question</summary>Answer</details>blocks anywhere in the content.
- a Yoast FAQ block (
- HowTo is emitted when the post contains:
- a Yoast HowTo block (
yoast/how-to-block), - a Rank Math HowTo block (
rank-math/howto-block), - OR the title starts with "How to" / "How To" + the post has a
<ol>with 3+ list items.
- a Yoast HowTo block (
Filter sforge_faq_items / sforge_howto_data to override the auto-detection results.
We split detection into general SEO plugins (handle everything: meta + og + schema) and schema-only plugins (handle only JSON-LD).
Tier 1 — General SEO plugin detected → ALL our injection paused (would duplicate everything):
- Yoast SEO (free + Premium, new + legacy)
- Rank Math (multiple class checks)
- All in One SEO Pack (v4+ and legacy)
- SEOPress
- The SEO Framework
- Slim SEO
- Squirrly SEO
- SmartCrawl
- WP Meta SEO
Tier 2 — Schema-only plugin detected → only our JSON-LD paused, meta+og+twitter still emit:
- Schema & Structured Data for WP & AMP (saswp, by Magazine3)
- Schema Pro (by Brainstorm Force)
- WPSSO Core
- Schema (by Hesham)
- Schema App
- Magazine3 Schema variants
Override via the "Force full injection anyway" checkbox in settings — useful if you want only some of our tags but understand you may need to disable parts of the other plugin to avoid duplicates.
Extend detection via filters: sforge_seo_competing_plugin (general SEO) or sforge_schema_competing_plugin (schema-only).
The plugin handles sitemaps two ways and you have full control:
If sitemap.xml, sitemap_index.xml, or wp-sitemap.xml is reachable on your origin, the plugin mirrors it: fetches each XML file (including child sitemaps from sitemap-index entries, with CDATA-wrapped <loc> support), rewrites WP origin URLs to your Public Site URL, and bundles all of them in the deploy at their original paths.
The mirror also scrubs leftover dashboard references:
<?xml-stylesheet ... ?>directives are removed (Yoast/Rank Math typically point them at/wp-content/plugins/.../*.xslon the origin — search engines ignore XSL anyway, and stripping it stops the dashboard host from appearing in browser-rendered sitemaps).- Both
https://dashboard.example.comand protocol-relative//dashboard.example.comhost references inside the XML are rewritten to your live host.
Activity log entry: Sitemap mirrored from origin: sitemap.xml, sitemap-pages.xml, ...
The plugin generates sitemap.xml from your settings — independently of Export Scope, so you can list URLs in the sitemap that aren't exported and vice versa. Configure at StaticForge for Cloudflare Pages → Settings → Sitemap Generator:
| Setting | Effect |
|---|---|
| Post Types | Multi-checkbox per public post type. Default post + page. All published items + the post type archive URL get listed. |
| Include Homepage | Adds home_url('/') and the posts page (if separate) with priority 1.0. |
| Include Taxonomy archives | All public taxonomies' term archives — categories, tags, custom taxonomies. |
| Include Author archives | Authors with at least one published post. |
| Split into multiple files | When ON, sitemap.xml becomes a <sitemapindex> referencing per-type sub-sitemaps (sitemap-post.xml, sitemap-page.xml, sitemap-authors.xml, sitemap-taxonomy-category.xml, etc.). Cleaner for large sites. |
Each <url> entry carries <loc> (rewritten to public URL), <lastmod> (resolved via get_post_modified_time for permalinks), <changefreq>weekly</changefreq>, <priority> (1.0 home / 0.7 other).
Activity log entries:
Sitemap generated locally (single mode): 449 URLs across 4 groups, 1 file(s).Sitemap generated locally (split mode): 449 URLs across 4 groups, 5 file(s).Sitemap fallback skipped: no post types / archives selected in Sitemap Generator settings.(warning if you've unticked everything)
sforge_sitemap_groups exposes the grouped URL list:
add_filter( 'sforge_sitemap_groups', function ( $groups ) {
$groups['custom'][] = [ 'url' => 'https://example.com/landing/', 'lastmod' => '' ];
return $groups;
} );TranslatePress keeps translations in the WordPress database and renders them server-side per request. Because StaticForge captures each page through wp_remote_get, the frozen HTML is already fully translated — the database is never needed on the live static site. The only gap is URL discovery: the crawler enumerates default-language permalinks only.
StaticForge closes that gap automatically. When TranslatePress is active, it expands the export URL list with every secondary-language URL TranslatePress would serve (using TP's own URL converter, so the permalink mode is honoured):
- Subdirectory mode (
example.com/fr/,example.com/de/) — fully supported. Every language renders to static HTML and ships in the one deploy. Language-switcher links andhreflangtags already present in the rendered HTML resolve to the now-existing static paths. - Subdomain / separate-domain mode (
fr.example.com) — skipped on purpose. A single Cloudflare Pages project serves one hostname, so per-subdomain content can't come from one deploy. StaticForge logs a notice telling you to switch TranslatePress to subdirectory mode. (Alternatively, run a separate export + separate Pages project per subdomain.)
No configuration needed — it activates when TranslatePress is detected. To turn it off:
add_filter( 'sforge_translatepress_export', '__return_false' );Activity-log entry: TranslatePress: added 1347 secondary-language URL(s) across 3 language(s) to the export.
Note on forms: static pages have no PHP/WordPress runtime, so contact forms (Contact Form 7, WPForms, Gravity Forms) won't submit on the deployed site — their AJAX/REST endpoint doesn't exist on Cloudflare Pages. Point forms at a static-friendly backend (Cloudflare Pages Function / Worker, Formspree, Basin, Web3Forms). StaticForge only rewrites your exact WordPress host, so a form posting to a different hostname is left untouched and keeps working.
By default the plugin keeps <origin>/wp-content/... URLs (theme CSS/JS, plugin assets, uploads) pointing at your WordPress origin so those assets keep working without bundling multi-gigabyte folders in every deploy. That means structured data such as og:image, JSON-LD image / logo / thumbnailUrl, and HTML <img src> / srcset will still reference your dashboard host on the deployed site.
If you want fully clean URLs on the live site:
- Set up a proxy that forwards
/wp-content/*requests from your live host to the dashboard. - Toggle Export Scope → Rewrite
/wp-content/URLs in the plugin settings.
Cloudflare Dashboard → Workers & Pages → Create application → Workers → Hello World template → deploy → Edit code:
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname.startsWith('/wp-content/')) {
url.hostname = 'dashboard.example.com';
return fetch(url, request);
}
return fetch(request);
}
};Replace dashboard.example.com with your actual WordPress host.
Worker → Settings → Triggers → Add route:
example.com/wp-content/*
www.example.com/wp-content/*
Tick Rewrite /wp-content/ URLs in plugin settings → Rebuild + Deploy Now.
# Nginx — on the live host
location /wp-content/ {
proxy_pass https://dashboard.example.com;
proxy_set_header Host dashboard.example.com;
proxy_ssl_server_name on;
}# Apache — on the live host
<Location "/wp-content/">
ProxyPass "https://dashboard.example.com/wp-content/"
ProxyPassReverse "https://dashboard.example.com/wp-content/"
</Location>curl -I https://example.com/wp-content/uploads/some-file.jpg
# Expect HTTP/2 200 with image content-typeIf that returns 200, tick the toggle, redeploy, view source of any post — og:image, JSON-LD image/logo/thumbnailUrl, and every <img src> will reference your live host instead of the dashboard.
⚠ If the toggle is on but no proxy is set up: every image / theme stylesheet / plugin script on the live site will 404. Run the curl test first.
The plugin ships its own robots.txt to your Cloudflare Pages site root, independent of your dashboard's robots.txt (which can stay locked down):
- Auto-generated default:
User-agent: * / Allow: / / Sitemap: https://example.com/<active-sitemap-path> - Editable: in-admin textarea on the settings page. Leave blank for default, or paste your own
Allow:/Disallow:rules. - Auto-managed Sitemap line: any
Sitemap:directive you type into the textarea is stripped and replaced with the URL pointing at the actually-deployed sitemap path. So if Yoast emits/sitemap_index.xml, your robots.txt always saysSitemap: https://example.com/sitemap_index.xmleven if you typed/sitemap.xml. Guarantees the link is never broken.
# Your custom rules — preserved verbatim
User-agent: *
Disallow: /preview/
Disallow: /staging/
User-agent: GPTBot
Disallow: /
# Plugin auto-appends the correct one based on the deployed primary sitemap:
Sitemap: https://example.com/sitemap_index.xml
On every deploy, the plugin:
- Tries
/sitemap.xml,/sitemap_index.xml,/wp-sitemap.xmlagainst your origin. - For each that returns valid XML, parses
<loc>entries — including those wrapped in<![CDATA[...]]>. - Follows sitemap-index references → fetches every child sitemap.
- Rewrites origin URLs inside the XML payloads to your Public Site URL.
- Bundles them all in the deploy at their original paths.
Search engines crawling your live site see correctly-formed canonical URLs.
| Setting | Default | Notes |
|---|---|---|
| Account ID | — | 32-char hex from Cloudflare. |
| API Token | — | Permission: Account · Cloudflare Pages · Edit. |
| Pages Project | — | Slug, not the URL. |
| Branch | main |
main = production, anything else = preview. |
| Public Site URL | — | Used for URL rewriting in HTML/XML. |
| Post Types | post, page |
All public types selectable. |
| Include Homepage | ☑ | |
| Include Taxonomies | ☑ | All public taxonomies + their term archives. |
| Include Authors | ☑ | Authors with at least one published post. |
| Inline CSS | ☑ | Embeds external <link> stylesheets as <style>. |
| Inject SEO meta | ☑ | Auto-disabled when an SEO plugin is detected. |
| Force SEO injection | ☐ | Force-emit even with SEO plugin active. |
| Auto-deploy | ☑ | On publish/update of selected types. |
| Debounce | 120 sec |
Min 10, max 3600. |
| Export Folder | sforge-export |
Inside wp-content/uploads/. |
| robots.txt | auto | Custom textarea; Sitemap: auto-appended. |
| Filter | Purpose |
|---|---|
sforge_url_list |
Modify the list of URLs to export. Receives an array of absolute URLs. |
sforge_translatepress_export |
Return false to disable the built-in TranslatePress language-URL expansion. |
sforge_sitemap_candidates |
Modify the sitemap discovery URL list. |
sforge_seo_inject |
Final say on whether our SEO tags emit on a given request. |
sforge_seo_inject_schema |
Final say on whether our JSON-LD block emits (meta+og still emit independently). |
sforge_seo_competing_plugin |
Add custom detection for general SEO plugins → skips ALL our injection. |
sforge_schema_competing_plugin |
Add custom detection for schema-only plugins → skips only our JSON-LD. |
sforge_faq_items |
Override the auto-detected FAQ items ([ ['question'=>'…','answer'=>'…'], … ]). |
sforge_howto_data |
Override the auto-detected HowTo data (['name'=>'…','steps'=>[ ['name'=>'…','text'=>'…'], … ]]). |
sforge_sslverify |
Disable SSL verification for self-fetch (__return_false). Useful if origin cert is invalid during migration. |
add_filter( 'sforge_url_list', function ( $urls ) {
$urls[] = home_url( '/landing/' );
return $urls;
} );
// Treat your own bespoke SEO plugin as a competitor to skip our injection on its pages.
add_filter( 'sforge_seo_competing_plugin', function ( $detected ) {
return $detected || class_exists( 'My_Custom_SEO' );
} );Grouped by where it happens. The code-styled phrases are the exact Activity Log messages, so you can match what you see to the fix.
After DNS cutover, wp-admin bounces to the live site / can't log in / can't redeploy
Symptom: clicking into wp-admin lands you on the public host's login, e.g.
https://example.com/wp-login.php?redirect_to=https%3A%2F%2Fdashboard.example.com%2Fwp-admin%2F... — but example.com is now the static Cloudflare site with no WordPress on it, so login fails and you can't reach the plugin to redeploy.
Cause: WordPress's own WP Address (siteurl) / Site Address (home) still point at the public host (example.com) instead of your dashboard host (dashboard.example.com). WordPress builds the login URL from siteurl, so it sends you to the static site.
Fix: pin both to the dashboard host in wp-config.php (add these just above /* That's all, stop editing! Happy publishing. */):
define( 'WP_HOME', 'https://dashboard.example.com' );
define( 'WP_SITEURL', 'https://dashboard.example.com' );Save, then log in from a private/incognito window (so stale cookies don't fight you) at https://dashboard.example.com/wp-admin.
This split is correct and required: WordPress lives on dashboard.example.com; the plugin's Public Site URL stays https://example.com. The renderer rewrites the dashboard host → Public Site URL in the export, so the static site still shows clean example.com links. Leave the plugin's Public Site URL as the public host — only WordPress's own two addresses move to the dashboard subdomain.
Test FAIL: Account ID, API token and project name are required
One of the three credential fields is blank. Fill Account ID, API Token, and Pages Project, Save, then test again.
Test FAIL: Project not found
You typed the full name.pages.dev URL into the Pages Project field. Use the slug only (e.g. mysite). Also confirm the project lives in the same Cloudflare account whose Account ID you pasted.
Test FAIL with an authentication / HTTP 403 message
The API token is wrong, expired, or under-scoped. Create one with exactly Account · Cloudflare Pages · Edit and Account Resources including the right account, re-paste it (shown only once at creation), and Save.
Upload token request failed: ...
The connection can test OK with a read-only token, but deploying needs write access. Recreate the token with Cloudflare Pages · Edit (not Read).
"Rebuild + Deploy Now" or auto-deploy does nothing — no new log lines
The rebuild runs on a WordPress scheduled event a few seconds after you click, so it depends on WP-Cron. If DISABLE_WP_CRON is defined, or the site gets almost no traffic, the event may never fire — you'll see Full rebuild queued (manual). but never Full rebuild started. Load any front-end page to nudge WP-Cron, or run a real system cron hitting wp-cron.php every minute.
No URLs to export. Check post type / scope settings.
No post types are ticked under Export Scope, or nothing is published in the selected types. Tick at least one post type (and/or Homepage / Taxonomies / Authors) and confirm you have published content.
Render fail <url>: HTTP 401 / 403 / 5xx
The plugin fetches your own URLs via wp_remote_get. A handful of failures is harmless; many means the site is blocking itself — HTTP basic auth, an IP allow-list, an aggressive WAF, Cloudflare "Under Attack" mode, or a coming-soon / maintenance plugin. Let the origin fetch itself (or pause the blocker during deploys). For an invalid / self-signed origin cert during migration, add add_filter( 'sforge_sslverify', '__return_false' );.
Nothing rendered, deploy skipped.
Every page failed to render, so there was nothing to upload — almost always the same self-fetch block as above. Check the Render fail lines just above it for the HTTP code.
Stuck on Manifest: ... files / Hashing files...
Upload step ran out of PHP memory or hit max execution time. Plugin already requests set_time_limit(0) and 512 MB but shared hosts may override. Raise php.ini memory_limit (256–512 MB) and max_execution_time. Plugin uses 100-file / 25 MiB batches by default.
Deployment failed: Request body is incorrect
Older plugin builds sent the deployment POST as URL-encoded form. v1.0.0+ sends multipart/form-data which Cloudflare requires. Update the plugin.
Asset upload failed: ...
Usually a single file over Cloudflare Pages' 25 MiB per-file limit (a large video / PDF in uploads), or a network timeout on a big batch. Remove or relocate oversized media and host it elsewhere.
Deployment failed: ... (other messages)
A Cloudflare-side rejection — the exact reason is quoted in the log. Common cause: more than 20,000 files in one deployment (CF Pages free-tier limit). Trim Export Scope, or split a very large site.
check-missing failed: ...
A transient Cloudflare API hiccup or token problem mid-deploy. Re-run Rebuild + Deploy Now; if it persists, re-test the connection (the token may have been revoked).
Images broken on deployed site (or only some show)
Plugin keeps /wp-content/* URLs pointing at your origin. Ensure the origin's SSL cert is valid — or proxy that subdomain through Cloudflare so CF terminates a fresh edge cert. If your host blocks Cloudflare (520/522 on /wp-content/uploads/*), tick Bundle /wp-content/uploads/ into deploy instead so images ship inside the static deploy.
If only some images are broken right after a DNS cutover, it's usually a stale deploy combined with the wrong Site Address (see Login & DNS cutover above) — fix that, then Rebuild + Deploy Now.
<project>.pages.dev doesn't redirect to my domain
The redirect is a client-side JS snippet (the Direct Upload API can't run _worker.js / Functions), so curl -I won't show it — test in a real browser. It only fires when Public Site URL is a real domain (not a .pages.dev URL) and the Redirect *.pages.dev to live host toggle is on.
Live site shows noindex
Don't tick Settings → Reading → "Discourage search engines" on the dashboard. It causes SEO plugins to also flag the sitemap. Plugin defensively strips noindex meta during export, but turn the toggle off to be safe.
Contact forms don't send on the live site
Cloudflare Pages is static — no PHP / WordPress runtime — so anything posting to admin-ajax.php or /wp-json/ (Contact Form 7, WPForms, Gravity Forms) silently fails. Point the form at a static-friendly endpoint (a Cloudflare Pages Function / Worker, Formspree, Basin, Web3Forms). A form posting to a different host is left untouched and keeps working.
Duplicate SEO meta or schema in HTML
Means an SEO plugin we don't auto-detect is also injecting tags. Either: (a) untick Inject SEO meta in settings, or (b) extend detection via the sforge_seo_competing_plugin / sforge_schema_competing_plugin filter.
FAQ / HowTo schema not appearing
FAQ schema needs Yoast / Rank Math / SEOPress FAQ blocks or <details><summary>Question</summary>Answer</details> markup in the content. HowTo needs a Yoast or Rank Math HowTo block, or a title starting with "How to" + a numbered list with 3+ items. Override with the sforge_faq_items / sforge_howto_data filters.
Sub-sitemaps missing on deploy
v1.0.0+ handles CDATA-wrapped <loc> URLs inside sitemap-index files. Earlier builds skipped them. Update + redeploy.
Sitemap fallback skipped: no post types / archives selected
Your origin exposes no sitemap, so the plugin tried to generate one — but everything is unticked under Sitemap Generator. Tick at least one post type / Homepage / Taxonomies / Authors.
Sitemap fallback returned no files.
The generator ran but matched no published URLs. Confirm you have published content in the selected sitemap post types.
TranslatePress languages aren't on the live site
If the log notes secondary languages are on separate subdomains / domains, that's expected — one Cloudflare Pages project serves one hostname. Switch TranslatePress to subdirectory mode (/fr/, /de/) and redeploy; all languages then ship in the one deploy.
Bundle uploads enabled, but no /wp-content/uploads/ references found
An image optimiser is swapping image URLs with JavaScript (e.g. EWWW Lazy Load or Easy IO), so the real URLs aren't in the rendered HTML for the bundler to find. Turn off the JS lazy-load / Easy-IO feature (keep the compression) and rebuild.
Hit ~100 deployments per day
Free tier soft cap. Raise the Debounce setting from 120 to 600+ so bulk edits collapse into fewer deploys.
| Resource | Limit |
|---|---|
| Files per deployment | 20,000 (Cloudflare Pages free tier) |
| Single file size | 25 MiB (Cloudflare Pages) |
| Deployments per day | ~100 soft cap (Cloudflare Pages free tier) |
| Bandwidth / requests | unlimited |
- New: forms that email you on submit. A static site can't process a POST, and the Pages Direct Upload deploy can't run Cloudflare Functions, so a contact form has always meant an external service. StaticForge now generates and deploys a small standalone Cloudflare Worker that takes the submitted fields and forwards them to your own email API. It's email-API-agnostic: choose a preset for Resend, SendGrid, Postmark or Mailgun (or wire up any endpoint under Custom) and it fills in the endpoint, the auth header, and an editable JSON body template. Tokens like
{{name}}/{{email}}are filled from the submission and{{all_fields}}expands to the whole message; your API key is injected from a Worker secret and never touches the page or the browser. Drop[sforge_form]into any page for a ready-made name/email/message form. A honeypot is always on and Cloudflare Turnstile is a one-tick add. New classesSFORGE_FormsandSFORGE_Worker_Deployer; new Forms settings section with its own deploy/remove controls.- Deploying the handler needs the Cloudflare API token to also carry Account · Workers Scripts · Edit, plus a free
workers.devsubdomain on the account (claimed once from Workers & Pages). Everything else in the plugin is unchanged and needs neither.
- Deploying the handler needs the Cloudflare API token to also carry Account · Workers Scripts · Edit, plus a free
- New: translation-ready. The admin UI, the Setup Guide, the contextual Help tabs, and the on-screen labels and descriptions are now wrapped in WordPress i18n functions against the
staticforge-for-cloudflare-pagestext domain, and alanguages/staticforge-for-cloudflare-pages.pottemplate ships with the plugin. Translations can be contributed at translate.wordpress.org. Nothing changes for English installs. - Fix: more in-post links get rewritten to your Public Site URL. The rewriter matched only your site's exact home URL, so a link an editor pasted as
http://when the site ishttps://, or with a straywww.(or missing one), or as a protocol-relative//your-site/..., stayed pointing at the WordPress origin. Those near-miss spellings of your own host are now folded onto the canonical form before the rewrite, so they get swapped along with everything else. Third-party links are left alone, and/wp-content/assets still follow the bundle / keep-on-origin rules. NewSFORGE_Renderer::normalize_origin_aliases().
- New: self-hosted fonts are bundled automatically. A font whose
@font-face srcstill points at your WordPress host is a cross-origin request once the page is served from*.pages.dev. Browsers fetch fonts in CORS mode, your origin doesn't send anAccess-Control-Allow-Originheader, and the font gets blocked — the console fills with "blocked by CORS policy" errors and the page drops to a system typeface. StaticForge now scans each rendered page for.woff2 / .woff / .ttf / .otf / .eotfiles served from your own host, ships them inside the deploy, and rewrites their URLs to your Public Site URL so they load same-origin. Covers theme webfonts, icon fonts, and Astra's local Google Fonts atwp-content/astra-local-fonts/. Third-party fonts (Google Fonts onfonts.gstatic.com, etc.) already send CORS headers and are left untouched. On by default via the new Export Scope → Bundle self-hosted fonts setting (bundle_fonts); ignored when Rewrite/wp-content/URLs is on, since that already rewrites fonts. NewSFORGE_Renderer::collect_fonts_from_html()/get_collected_fonts()andSFORGE_Rebuild::bundle_fonts(). Run one Full Rebuild after updating so every page picks it up. - Improved: extra paths accept a
wp-content/-relative entry. If an Extra paths to include entry isn't found at the WordPress root, the plugin now looks underwp-content/before giving up — soastra-local-fontsresolves the same aswp-content/astra-local-fonts, and both the copy and the URL rewrite act on the real files. This removes the most common reason a bundled asset kept pointing at the old domain: the rewrite hinges on thewp-content/prefix, and a bare path used to silently miss it. NewSFORGE_Extra_Assets::resolve_root_relative().
- New: extra paths to include. A new Export Scope → Extra paths to include setting lets you bundle files and folders the crawler never encounters in the rendered HTML — a plugin's icon font (Elementor's Font Awesome at
wp-content/plugins/elementor/assets/lib/font-awesome, say), a webfont directory, a downloadable PDF. List them one path per line relative to your WordPress root; the plugin copies them straight off local disk into the deploy. A whole folder is pulled in recursively, a single file is copied as-is, and a trailing wildcard (wp-content/uploads/2025/*.pdf) is expanded. Bundled files under/wp-content/get their URLs pointed at the live host so the deployed page loads the bundled copy, while the rest of/wp-content/stays on origin exactly as before. Reading locally means no origin firewall gets in the way. Paths are confined to the WordPress root — anything escaping it via..or a symlink is skipped and logged — and each rebuild is capped at 5,000 files / 200 MB. New settingextra_paths, new classSFORGE_Extra_Assets.
- New: partial rebuilds. Publishing a post used to re-render the entire site. Every page costs one HTTP round-trip to the origin, and they run one after another, so a 1,400-page site paid 1,400 sequential fetches to fix a typo — the single biggest cause of hours-long rebuilds. The plugin now re-renders only the pages an edit actually invalidates (the post itself, its term archives, its author archive, its post type archive, the homepage and posts page) plus their translations, and reuses the export cache for everything else. Editing one post typically touches a handful of URLs instead of the whole site.
- New: the export directory is now the deploy source. Rendered pages were always written to
wp-content/uploads/sforge-export/, but the deploy was built from an in-memory copy and that directory was never read back. It is now the source of truth for the manifest, which is what makes partial rebuilds possible: a Cloudflare Pages deployment is a whole-site snapshot, so the manifest must list every file even when only a few changed. - Improved: memory no longer scales with site size. File bodies are streamed from disk for hashing (
hash_update_file) and read back only for the assets Cloudflare reports as new, so peak memory tracks the 25 MB upload batch rather than the whole site. A 1,400-page export now costs about the same RAM as a 10-page one. This addresses rebuilds that stalled at the Manifest step on memory-limited hosts. - New: stale pages are pruned. Deleting or unpublishing a post now removes its exported HTML, so it stops being served. Pruning is scoped to HTML and refuses to run if a partial rebuild's URL list looks implausibly short (more than a quarter of pages suddenly unaccounted for) — it logs a warning and asks for a full rebuild rather than quietly deleting most of the live site.
- New: two rebuild buttons. Rebuild Changed + Deploy renders only what changed; Full Rebuild + Deploy re-renders everything and reconciles the export cache from scratch. Auto-deploy uses the partial path.
- New: per-post Rebuild action. A Rebuild link on post and page list tables pushes one page live without waiting out the debounce. Useful when a change never went through the publish flow — an edited menu, a widget, a template tweak, a translation saved in place.
- Improved: bundled uploads are cached. With Bundle
/wp-content/uploads/enabled, files already in the export directory are no longer re-fetched from the origin on every rebuild. - Improved: a failed render keeps the previous page. Renders used to be dropped from the deploy on failure, so a transient error could remove a live page. The last good copy now stays in the export cache and keeps being served.
- Internals: new
SFORGE_Export_Store,SFORGE_RebuildandSFORGE_Post_Actionsclasses; rebuild orchestration moved out ofSFORGE_Hooks; newSFORGE_Deployer::deploy_dir()andSFORGE_Deployer::hash_file(); newsforge_partial_rebuildcron hook alongsidesforge_full_rebuild. Asset hashing is unchanged, so assets already cached at Cloudflare stay cached across the upgrade.
- Fix: stray UTF-8 BOM in the main plugin file. A byte order mark got saved into
staticforge-for-cloudflare-pages.phpwhen 1.3.0 was cut. Those three bytes (EF BB BF) sit before the opening<?phptag, so PHP sent them to the browser as output on every request. That surfaced two ways: the Plugins screen warned "The plugin generated 3 characters of unexpected output during activation", and Site Health reported "The REST API did not process thecontextquery parameter correctly" — the BOM was being prepended to every REST response, so the JSON no longer parsed. Update if you're on 1.3.0. Nothing else changed; the 1.3.0 render origin override is untouched.
- New: Render origin override (Performance settings). The export renders each page by fetching it over HTTP from the site's own URL, sequentially. When the domain sits behind a CDN/proxy (e.g. Cloudflare), every one of those requests leaves the server and round-trips through the edge — on a large or multilingual site (hundreds/thousands of pages) that network hop dominates the rebuild time, not PHP. Set this to a host that reaches WordPress directly on the same server (usually
http://127.0.0.1, orhttp://127.0.0.1:8080if PHP listens on another port) and the whole crawl stays local. The plugin keeps your real domain in theHostheader so WordPress still serves the correct site/language, and TLS verification is skipped for the override only (a loopback cert won't match the public host). Applies to page renders, inlined CSS, mirrored sitemaps, and bundled uploads; only URLs on the origin host are redirected — everything else is fetched unchanged. Leave blank to keep the previous behaviour. New settingrender_origin, new helperSFORGE_Renderer::localize_request().
- Docs: post-DNS-cutover login trap. Added a troubleshooting entry (README, in-plugin Setup Guide, and wp.org FAQ) for the most common cutover mistake: leaving WordPress's own WP Address (
siteurl) / Site Address (home) on the public host after pointing it at Cloudflare Pages. WordPress then builds the login URL against the static site (https://example.com/wp-login.php?redirect_to=https://dashboard.example.com/...), so you can't log in or redeploy. Fix is to pinWP_HOME+WP_SITEURLto the dashboard host inwp-config.phpwhile keeping the plugin's Public Site URL on the public host. - Docs: comprehensive troubleshooting on every surface. Reorganised Troubleshooting into grouped subsections — Login & DNS cutover, Setup & connection, Rebuild won't start or finish (incl. the WP-Cron "nothing happens" case), Deploy step errors, Live site looks wrong, Sitemaps & multilingual, Limits & frequency — with 26 entries keyed to the exact Activity Log messages (
No URLs to export,Render fail … HTTP,Nothing rendered,Asset upload failed,Deployment failed,check-missing failed,Sitemap fallback …,Bundle uploads enabled, but no … references found, etc.). The same coverage now lives in all three places: this README, the wp.orgreadme.txtFAQ, and the in-plugin Setup Guide — so users hit it wherever they look. - No code change — documentation only.
- New: TranslatePress multilingual export. Auto-detects an active TranslatePress install and expands the export URL list with every secondary-language URL (via TranslatePress's own URL converter, so the configured permalink mode is honoured), so each language renders to static HTML and ships in the deploy. Translations are DB-stored but rendered server-side, so the frozen HTML is already translated — no runtime DB dependency. Supports subdirectory mode (
/fr/,/de/); subdomain/separate-domain language URLs are skipped (and a notice is logged) because one Cloudflare Pages project serves a single hostname. Auto-on when TranslatePress is detected; opt out withadd_filter( 'sforge_translatepress_export', '__return_false' ). New classSFORGE_TranslatePress, new filtersforge_translatepress_export.
- Fix: removed the plugin's own injected "View details" row-meta link to prevent a duplicate entry, since WordPress now auto-injects "View details" for wp.org-hosted plugins. Row meta is now
View details | Plugin Support | Contact Developer.
- Rename: plugin renamed from "Send Static to Pages" to "StaticForge for Cloudflare Pages". Folder slug, main file (
staticforge-for-cloudflare-pages.php), text domain, all class/constant/function/option prefixes (SSTP_/sstp_→SFORGE_/sforge_), and thesstp_full_rebuildcron hook moved over together. - Auto-migration: on
plugins_loaded(priority 1) the plugin copies legacysstp_settingsandsstp_logto the new option keys and reschedules any pendingsstp_full_rebuildcron event tosforge_full_rebuild. Guarded by a one-shotsforge_migrated_from_sstpflag so it runs at most once per install. - Uninstall now also deletes legacy
sstp_*options and clears thesstp_full_rebuildcron hook so leftover state is fully removed. - WordPress 7.0 tested. Audited for deprecated APIs — all good (uses modern
wp_remote_*,register_setting, etc.). No iframed-editor impact: the plugin only renders an admin settings page in the parent admin chrome. - WordPress 7.0 Connectors API: registers a
deployment_targetconnector (sforge-cloudflare-pages) on thewp_connectors_initaction so the plugin appears on the central Connections screen. The Cloudflare API token lives inside thesforge_settingsarray, which does not fit the Connectors API single-valueapi_keyshape, so the connector is registered withmethod: noneandcredentials_urllinking back to the StaticForge settings screen for credential management. Falls back silently on WP < 7.0. - Plugin row links: added
SettingsandSupport on Ko-finext to Deactivate, plusPlugin Support(WordPress.org forum) andContact Developerrow meta. - Donate link moved to Ko-fi: ko-fi.com/gunjanjaswal.
- Social-aware dashboard noindex — robots.txt explicitly allows
/wp-content/uploads/for facebookexternalhit / Twitterbot / LinkedInBot / Pinterestbot / WhatsApp / Slackbot / Discordbot / TelegramBot / Applebot / redditbot / Tumblr / iframely / Embedly / Mastodon / Bluesky / meta-externalagent.X-Robots-Tagskipped for media paths and social-scraper user agents so og:image previews resolve. *.pages.dev301-redirect middleware — emitsfunctions/_middleware.jsinto the deploy when Public Site URL is a custom domain.- Opt-in Rewrite
/wp-content/URLs setting + Setup Guide instructions for Cloudflare Worker / Nginx / Apache reverse proxies. - Standalone
Person+ProfilePageJSON-LD module for author archives (sameAs auto-collected from user_meta). - Dashboard auto-noindex on activation — physical robots.txt at webroot (backed up to
robots.txt.sforge-backup),wp_robotsfilter,X-Robots-Tagheader. Plugin's own export fetches exempt viaX-SFORGE-Exportheader. Restored on deactivation. - Fallback
sitemap.xmlbuilder when origin has none — standards-compliant<urlset>from native WP data. - Granular sitemap generator settings — per-post-type checkboxes, include/exclude homepage / taxonomies / authors, split-mode toggle emitting
<sitemapindex>. Filter:sforge_sitemap_groups. - Featured-image LCP boost —
fetchpriority="high",loading="eager",decoding="async". - Activity-log visibility for sitemap mirror-vs-generate decisions.
- Auto-backfill new option defaults on
plugins_loaded. - URL rewriter now also handles escaped forward slashes (
https:\/\/origin\/...) for JSON-LD / REST embeds. - Mirrored sitemaps strip
<?xml-stylesheet ... ?>directives + rewrite protocol-relative//hostreferences. Sitemap:line in custom robots.txt is auto-managed so it always points at the actually-deployed sitemap path.- Multipart/form-data deployment POST (was URL-encoded — Cloudflare rejected as "Request body is incorrect").
- Fix: fatal parse error from
?>inside a//line comment terminating<?phpblock. - Fix: CDATA-wrapped
<loc>entries in sitemap-index files now expand correctly.
- Initial public release.
- Cloudflare Pages Direct Upload API client (multipart/form-data deployment).
- Whole-site crawl: posts, pages, custom post types, taxonomy and author archives, homepage.
- CSS inlining for self-contained pages.
- Featured image LCP boost.
Issues & PRs welcome. Contact: hello@gunjanjaswal.me
If StaticForge saves you time or money, consider supporting the development on Ko-fi:
GPL-2.0-or-later. Same as WordPress.
Gunjan Jaswal — www.gunjanjaswal.me — hello@gunjanjaswal.me — ko-fi.com/gunjanjaswal