Headless WordPress
Headless WordPress forms with Core Forms — the calm way to embed a WP form on Astro, Next.js, Vue, or plain HTML
Use WordPress as a form backend and render the form anywhere else — Astro, Next.js, plain HTML, mobile webview. Three integration modes, one API key, full spam protection.
You’ve moved the rest of your site off WordPress. The marketing pages live in Astro, the docs sit on Next.js, the storefront runs on something else entirely. But you still need a form — and real form infrastructure (storage, spam filtering, admin moderation, email notifications, webhooks to your CRM) is not something you want to rebuild from scratch.
Core Forms 4.1.0 added a first-class headless mode for exactly this. WordPress stays where it is, doing what it’s good at — storing submissions, running the spam filters, firing the action loop. The form HTML, the submit POST, and the rendered response all happen on a completely different origin.
This post walks through what “headless forms” actually means in practice, the three integration modes Core Forms ships, the security model, and a handful of footguns worth knowing about.
What “headless” means here
A normal WordPress form lives entirely inside the WordPress page. The HTML is rendered by PHP, the submit POST goes to admin-ajax.php, the response shows inline. WP-Admin reads the submissions, Email Logs traces the notifications, the whole flow is one cookie domain.
A headless form moves the render and the submit off-WordPress. The HTML is fetched from the WP REST API (or copied once and pasted in), the submit POST goes from your Astro/Next/Vue origin to wp-json/core-forms/v1/forms/{id}/submit, and the response comes back as JSON.
What stays on the WordPress side:
- Form storage (the form definition, fields, settings)
- Submission storage (every submitted entry, with status: new / read / spam / payment)
- Spam protection (reCAPTCHA v3, hCaptcha, Cloudflare Turnstile, Akismet, honeypot)
- The action loop (email, webhooks, Slack, WhatsApp, Mailchimp, FluentCRM, Stripe, et cetera)
- Admin UI for everything above
What moves off-WordPress:
- Where the form HTML is served from
- Where the user types
- Where the success message renders
That’s it. From the WP-Admin perspective, a submission from your Astro site looks identical to one from a WP-rendered page — same row in the submissions table, same actions fired, same email notifications.
Three integration modes
Core Forms ships three flavours of the same headless API. Pick whichever fits.
1. The drop-in embed.js widget (vanilla JS, zero build step)
Paste two lines into any HTML page on any origin:
<div data-cf-form="contact" data-cf-key="ABC123…"></div>
<script src="https://your-wp-site.com/wp-json/core-forms/v1/embed.js" defer></script>
The script fetches the form HTML from your WP REST endpoint, injects it into the target div, intercepts submission, and POSTs the data back as JSON. You get all the form theme styling, all the validation, all the success/error flow.
This is the path of least resistance. No build step, no framework, no JS knowledge required. Works on plain HTML, in a CMS where you can paste a script tag, inside a Webflow embed, inside a WordPress page on a different WordPress site, anywhere.
2. Native fetch (Astro, Next.js, Vue, React, Svelte, whatever)
For sites with a JS framework, skip the embed script and call the REST API directly:
// Fetch the form schema (HTML + fields)
const res = await fetch('https://your-wp-site.com/wp-json/core-forms/v1/forms/contact');
const form = await res.json();
// Submit user input
await fetch('https://your-wp-site.com/wp-json/core-forms/v1/forms/contact/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Core-Forms-Key': 'ABC123…'
},
body: JSON.stringify({
name: 'Ada',
email: 'ada@example.com',
_cf_page_url: window.location.href
})
});
The schema response includes the rendered HTML, a structured field list (for client-side validation), the form’s localized messages, the submit URL, and a requires_captcha flag so you know whether to mount a CAPTCHA widget.
3. Server-side proxy (the production-grade option)
The first two modes ship the API key to the browser. That’s fine for spam-tolerant forms (newsletter signups, low-stakes contact forms) — at worst, someone harvests the key and floods spam at your form for as long as it takes you to rotate it. Core Forms’ spam filters catch what they always catch.
For anything beyond that, proxy through your own backend. The headless tab in Core Forms ships a guidance card with copy-paste templates for:
- Cloudflare Workers / Pages Functions
- Next.js App Router server actions
- Astro server endpoints
The frontend posts to your own /api/submit route without an API key. Your backend adds the X-Core-Forms-Key header and forwards to WordPress. The key never lands in the browser bundle.
The security model
The submit endpoint is gated by a single site-wide API key, configured under Settings → Headless. The key has to be in the X-Core-Forms-Key header on every submit (4.2.0 dropped the ?cf_key= query-string fallback, which was leaking the key into server logs and Referer headers).
Beyond the API key:
- Per-form opt-in. Every form has a
headless_enabledtoggle. Forms that aren’t flagged headless can’t be reached via the REST surface, even with a valid key. - CORS allowlist. Configure the origins your forms get embedded on (Settings → Headless → Allowed origins, one per line). The REST handler only emits
Access-Control-Allow-Originfor matching origins; everything else gets blocked by the browser. Empty allowlist preserves the prior reflect-any-Origin behaviour with an in-UI warning. - Captcha still applies. Headless submissions run through the same
cf_validate_formfilter as in-page ones, so reCAPTCHA v3, hCaptcha, Turnstile, and Akismet all gate the submission as normal. The schema response tells the frontend whether the formrequires_captcha. - Honeypot still applies. The hidden honeypot field is in the schema HTML; the frontend just needs to not strip it.
- Origin allowlist + captcha = practical CSRF defence. WordPress nonces don’t make sense across origins (different cookie domain), so the security model relies on the API key + origin pinning + captcha rather than nonce verification.
Variables that need a little care
Submitter context variables — [CF_REFERRER_URL], [CF_POST_ID], [CF_USER_AGENT] — work out of the box when the form is rendered server-side by PHP, because PHP can read the Referer header and $_SERVER directly.
In a headless submission those values arrive at the WordPress server, but they describe the WP REST endpoint, not the page the visitor filled the form on. Browsers strip the cross-origin Referer to just the origin under the default strict-origin-when-cross-origin policy.
Core Forms 4.2.0 fixed this by reading a _cf_page_url field from the JSON body and substituting it into $_SERVER['HTTP_REFERER'] for the duration of Forms::process(). The bundled embed.js forwards window.location.href automatically; if you’re using the native-fetch path, add it explicitly:
body: JSON.stringify({
name: 'Ada',
email: 'ada@example.com',
_cf_page_url: window.location.href // ← do this
})
Without that field, [CF_REFERRER_URL] in your email templates will resolve to your /wp-json/core-forms/v1/forms/X/submit URL on every submission. Not what anyone wants.
What you don’t lose
The fear with any headless setup is that you’re trading away admin power for clean architecture. With Core Forms you’re not.
- Submissions still appear in WP-Admin. Open Core Forms → Submissions, filter by inbox / spam / all, mark as read, reply to the submitter, export to CSV. Exactly the same as in-page submissions.
- The full action loop still fires. Send Email, Auto-Responder, Mailchimp, FluentCRM, HubSpot, ConvertKit, Slack, Discord, Telegram, Twilio SMS, the three WhatsApp actions, Notion, Airtable, Google Sheets, Webhooks, Zapier, Make, Create User, Create Post — every action you’ve configured runs identically.
- Payments work. A headless form can charge a card on Stripe, PayPal, Razorpay, or Polar. The provider’s hosted checkout opens in the visitor’s browser; the webhook return path comes back to your WordPress origin and updates the submission’s payment status.
- Email Logs catch every notification. Failed sends show up in Core Forms → Email Logs the same way they always have. Resend works for email-class action types.
A typical setup walkthrough
Five minutes, start to finish:
- Generate an API key. In WP-Admin, Settings → Headless, click Generate. Save the page.
- Add allowed origins. Same page — add the production origin and any staging origins, one per line.
- Flip the per-form toggle. Open a form, head to the Headless tab, check Enable for headless sites.
- Copy a snippet. The Headless tab shows three pre-filled snippets: the drop-in widget, native fetch, and cURL. Each one already includes your form’s ID, the submit URL, and the API key.
- Paste it into your Astro / Next / plain-HTML site. Done.
The form starts taking submissions immediately. They land in WP-Admin like any other Core Forms submission.
When not to use this
The headless mode is great for a particular pattern: WordPress as backend, modern frontend stack as the visitor-facing site. It’s not the right fit for every form.
Skip it if:
- Your whole site is already WordPress. Use the Gutenberg block or shortcode — you get the same form theme, same submit flow, same admin UI, with zero extra moving parts.
- You need ultra-strict CSRF guarantees. WP nonces require a same-origin cookie. Headless submissions can’t use them. If you’re handling financial / health / regulated data and need true cookie-bound CSRF protection, run the form on the same origin as the visitor.
- Your frontend can’t reach your WordPress backend. Internal WP installs (behind VPN, on a private network) work for in-page rendering but not for cross-origin headless calls.
For everything else — marketing sites on Astro, docs sites on Next.js, signup forms on plain HTML, “request a demo” forms on a separate landing page — headless mode is the calm answer to “where does the form actually live?”
WordPress, where it always has. Just rendered somewhere else.
See it in action: Core Forms → Integrations → Headless / REST