New: Cloudflare Turnstile CAPTCHA is now available. ->
Back to all posts

Contact Form 7 with Headless WordPress: Setup, Errors, and Alternatives (2026)

By Forminit in Guides • Published August 2, 2026

Short answer: Contact Form 7 can work with a headless WordPress site, but not through its normal shortcode workflow. You rebuild the form in your frontend, submit FormData to CF7’s REST API, supply its hidden fields, solve CORS, reconnect spam protection, and add separate plugins for storage, SMTP, and redirects. For existing simple forms, that can be reasonable. For a new Next.js, React, Nuxt, or Astro frontend, keeping WordPress for content and moving form processing to a dedicated form backend is usually simpler; that setup takes about 10 minutes and is covered in Option 3 below.


There are three practical ways to handle forms in a headless WordPress project:

  1. Keep Contact Form 7 and submit to its REST API from your frontend.
  2. Use a headless-compatible WordPress form plugin that is designed for external frontends.
  3. Use a dedicated form backend such as Forminit, keeping WordPress for content only.

The right choice comes down to one architectural question: should WordPress remain your form server? This guide covers all three options, the errors you will hit along the way (wpcf7_unsupported_media_type, wpcf7_unit_tag_not_found, rest_no_route), and how to migrate if you decide to switch.

Plugin capabilities and Contact Form 7 behavior referenced below were verified against official plugin pages and documentation in August 2026.

What changes when WordPress becomes headless?

On a traditional WordPress site, WordPress controls both sides of the form:

Visitor

WordPress-rendered Contact Form 7 form

Contact Form 7 validation and spam checks

WordPress email delivery

Contact Form 7 inserts the HTML, hidden fields, and JavaScript needed to submit the form. It has used the WordPress REST API for Ajax submissions since version 4.8 (2017), and with more than 10 million active installations, it is understandable that teams want to keep it when migrating an established site.

In a headless setup, WordPress no longer renders the public page:

Visitor

Next.js, React, Nuxt, Astro, or another frontend

A submission API

Storage, notifications, and integrations

Your frontend fetches posts and pages from WordPress through REST or WPGraphQL, but a CF7 shortcode is not automatically transformed into a React component:

[contact-form-7 id="a0b94c4" title="Contact form"]

The shortcode belongs to WordPress’s PHP rendering layer. When that layer is removed, you rebuild the visible fields and the submission behavior yourself. (If you are earlier in the migration, start with our headless WordPress build-to-deploy guide and come back here for forms.)

Can Contact Form 7 work with headless WordPress?

Yes. CF7 exposes a REST endpoint that accepts submissions from any client:

POST /wp-json/contact-form-7/v1/contact-forms/{FORM_ID}/feedback

But “CF7 has a REST endpoint” does not mean your existing form automatically works in Next.js. You remain responsible for:

  • Rebuilding every field in the frontend, with names that match CF7’s form tags exactly
  • Sending a multipart FormData payload (not JSON)
  • Finding the correct numeric form ID
  • Supplying CF7’s hidden fields, including the unit tag
  • Mapping server validation errors to your UI
  • Handling CORS between the frontend and WordPress origins
  • Reconnecting CAPTCHA or Turnstile outside the WordPress-rendered page
  • Storing submissions somewhere other than email
  • Monitoring WordPress email deliverability
  • Maintaining compatibility across WordPress and plugin updates

A headless CF7 implementation is possible, but it is an integration project, not a reusable shortcode.

Option 1: Keep Contact Form 7 as the form backend

Keeping CF7 makes the most sense when you already have complex forms, WordPress mail templates, or custom PHP hooks that would be expensive to replace.

Step 1: Recreate the form in your frontend

Suppose the WordPress form contains:

[text* your-name]
[email* your-email]
[tel your-phone]
[textarea* your-message]

The names sent by your headless form must match those form tags exactly:

<form>
  <input type="text" name="your-name" required />
  <input type="email" name="your-email" required />
  <input type="tel" name="your-phone" />
  <textarea name="your-message" required></textarea>
  <button type="submit">Send message</button>
</form>

Renaming your-email to email, for example, makes CF7 treat the expected field as missing and return validation_failed.

Step 2: Find the numeric form ID

One of the most common headless CF7 mistakes is using the shortcode’s hashed identifier in the REST URL. Newer CF7 versions display a hash like a0b94c4 in the shortcode, but the REST endpoint requires the numeric WordPress post ID.

Open the form editor and read the URL:

/wp-admin/admin.php?page=wpcf7&post=123&action=edit

The numeric form ID here is 123. Using the hash instead produces a rest_no_route or 404 response.

Step 3: Submit FormData, not JSON

A common failed request looks like this:

await fetch(CF7_ENDPOINT, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    'your-name': 'Jane Smith',
    'your-email': 'jane@example.com',
    'your-message': 'Hello',
  }),
});

CF7 rejects it:

{
  "code": "wpcf7_unsupported_media_type",
  "message": "The request payload format is not supported.",
  "data": { "status": 415 }
}

The endpoint expects a FormData body:

const formData = new FormData();
formData.append('your-name', 'Jane Smith');
formData.append('your-email', 'jane@example.com');
formData.append('your-message', 'Hello');

const response = await fetch(CF7_ENDPOINT, {
  method: 'POST',
  body: formData,
});

Do not set the multipart Content-Type header manually: the browser or server runtime must generate the boundary. A hand-written multipart header is the usual reason code works in Postman but fails in React, Nuxt, or Axios.

Step 4: Include Contact Form 7’s hidden values

When CF7 renders a form inside WordPress, it adds internal hidden fields:

<input type="hidden" name="_wpcf7" value="123">
<input type="hidden" name="_wpcf7_version" value="6.x">
<input type="hidden" name="_wpcf7_locale" value="en_US">
<input type="hidden" name="_wpcf7_unit_tag" value="wpcf7-f123-o1">
<input type="hidden" name="_wpcf7_container_post" value="0">

A headless frontend never receives these because CF7 did not render the form. Missing or invalid unit-tag data returns:

{
  "code": "wpcf7_unit_tag_not_found",
  "message": "There is no valid unit tag.",
  "data": { "status": 400 }
}

Store these values as server-side environment variables rather than scattering them through components, and verify them against your installed CF7 version after updates. These are internal implementation details, not a documented public contract, so treat them as a maintenance dependency.

Step 5: Proxy the request through your framework

Submitting through a server route avoids most browser CORS problems and gives you one place for rate limiting and logging:

// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    const incoming = await request.formData();
    const cf7Data = new FormData();

    cf7Data.append('_wpcf7', process.env.CF7_FORM_ID!);
    cf7Data.append('_wpcf7_version', process.env.CF7_VERSION!);
    cf7Data.append('_wpcf7_locale', process.env.CF7_LOCALE!);
    cf7Data.append('_wpcf7_unit_tag', process.env.CF7_UNIT_TAG!);
    cf7Data.append('_wpcf7_container_post', '0');

    for (const [key, value] of incoming.entries()) {
      cf7Data.append(key, value);
    }

    const endpoint =
      `${process.env.WORDPRESS_URL}/wp-json/contact-form-7/v1/` +
      `contact-forms/${process.env.CF7_FORM_ID}/feedback`;

    const response = await fetch(endpoint, {
      method: 'POST',
      body: cf7Data,
      cache: 'no-store',
    });

    return NextResponse.json(await response.json(), {
      status: response.status,
    });
  } catch (error) {
    console.error('CF7 submission failed:', error);
    return NextResponse.json(
      { message: 'Unable to submit the form.' },
      { status: 500 },
    );
  }
}

Your client component submits to /api/contact, checks for result.status === 'mail_sent', and maps validation errors to the UI. That is the beginning of the implementation, not the end: a production form also needs field-level validation messages, abuse prevention, storage, and a fallback for failed email delivery.

Common Contact Form 7 headless errors

wpcf7_unsupported_media_type (HTTP 415)

Meaning: CF7 does not support the submitted payload format. Common cause: Sending a JSON body to the feedback endpoint, or manually setting the multipart header. Fix: Send a real FormData object and let the runtime set the Content-Type boundary.

wpcf7_unit_tag_not_found (HTTP 400)

Meaning: The request does not contain a unit tag CF7 recognizes. Common causes: _wpcf7_unit_tag is missing or invalid, the multipart request was formatted incorrectly, or a proxy dropped the field. Fix: Supply _wpcf7_unit_tag in the format wpcf7-f{formId}-o1. The unit tag normally comes from CF7’s rendered HTML, which is exactly why this error is so common in fully headless frontends.

rest_no_route (HTTP 404)

Meaning: WordPress cannot match the URL to a registered REST route. Check: CF7 is active, the REST API is enabled, you used the numeric form ID (not the shortcode hash), the URL has no accidental double slash, the request uses POST, and security plugins are not blocking the CF7 namespace.

validation_failed (HTTP 400)

Meaning: One or more fields did not satisfy the CF7 form definition. Check: Field names exactly match the form tags, required fields have values, email and URL fields are well formed, acceptance fields send the expected values, and files meet the configured size and type rules.

Works in Postman, fails in Next.js or Nuxt

Almost always a request-construction difference, not a CF7 configuration problem: manually defined multipart headers, missing hidden fields, browser CORS restrictions, or a framework serializing FormData unexpectedly. Proxying through a server route makes request handling predictable.

One observation before the next two errors: every error on this page exists because WordPress sits in the middle of the submission path. Option 3 removes WordPress from that path, and this entire error class with it.

The form sends email but stores nothing

Contact Form 7 does not store submissions by default. Its companion Flamingo plugin or a third-party add-on such as CFDB7 adds database storage and CSV export. Without one, the notification email is the only record of the lead: if WordPress mail is misconfigured or the message is rejected downstream, the submission is gone.

WordPress says the email was sent, but it never arrives

A successful application-level response does not guarantee inbox delivery. WordPress sites commonly add an SMTP plugin such as Post SMTP to authenticate outgoing email, keep logs, and expose failures.

The real Contact Form 7 production stack

CF7 is intentionally modular. The core plugin manages forms and mail templates; everything else is an add-on. A production headless stack can grow to look like this:

RequirementWordPress or CF7 plugin
Form definition and mail templateContact Form 7
Submission database and CSV exportCFDB7 or Flamingo
Content and pattern-based spam filteringMaspik
Honeypot protectionWP Armour
CAPTCHA or bot challengeSimple Cloudflare Turnstile
Authenticated email delivery and logsPost SMTP
Contact segmentation and automationFluentCRM
Dynamic field visibilityConditional Fields for CF7
Thank-you page redirectsRedirection for CF7
API or CRM forwardingContact Form to Any API or custom hooks

Two cautions when assembling this stack:

You probably should not install every anti-spam plugin. Maspik, WP Armour, and Turnstile solve overlapping parts of the spam problem (content rules, honeypot, and bot challenge respectively). Layers can help, but each one should be tested for false positives. And note that Antispam Bee is not CF7 protection: its own documentation says it protects default WordPress comments, not form plugins.

More plugins do not automatically mean a bad setup, but they do mean dependency. Every submission still reaches WordPress; PHP and the database stay in the request path; plugin compatibility must be re-tested after updates; logs and configuration are spread across different admin screens; and a WordPress outage becomes a form outage. That trade is acceptable when WordPress should own the whole workflow. It is less attractive when WordPress is meant to be a private content system.

What Contact Form to Any API solves, and what it does not

Contact Form to Any API forwards CF7 submissions to CRMs, webhooks, and external REST APIs after CF7 has received and validated them. For a conventional WordPress site that wants to keep CF7, it is a sensible integration path. It does not help your headless frontend submit successfully in the first place: the endpoint, hidden fields, CORS, and spam flow all still have to work. Which raises the obvious question: if the data’s destination is outside WordPress anyway, why route it through WordPress at all?

Option 2: Use a headless-compatible WordPress form plugin

Some modern WordPress form products expose dedicated headless endpoints and API keys for Next.js, React, Astro, and other frontends. This keeps form administration, storage, and integrations inside WordPress while removing CF7’s headless-specific workarounds.

This option fits when editors must create and modify forms in WordPress, submission data must remain in the WordPress database, and your team accepts WordPress being part of every form request. It does not remove WordPress from the submission path; it just makes that path friendlier to external frontends.

Option 3: Keep WordPress for content, use a form backend for forms

A dedicated form backend separates content management from submission processing:

WordPress        →  posts, pages, media, structured content
Next.js frontend →  displays content and your custom form UI
Forminit         →  validates, stores, and processes submissions
                    sends notifications, autoresponders, webhooks

With this architecture, WordPress no longer needs Contact Form 7, a storage add-on, a honeypot plugin, a CAPTCHA integration, an SMTP plugin for form notifications, a redirect add-on, or a CRM forwarder. Forminit handles submissions, storage, validation, spam protection, files, notifications, and webhooks as one service, with a 2 KB JavaScript SDK for browser, Node.js, Next.js, and Nuxt.js.

In practice the setup is three steps: create a form at forminit.com to get a Form ID, point your existing form component at it (directly or through the Next.js proxy), and configure notifications and a webhook in the dashboard. There are no hidden fields to reverse-engineer, no CORS rules, and nothing to install in WordPress. The full working component is in the Next.js example below.

The important distinction: Forminit does not replace WordPress as your CMS, and it does not replace a CRM like FluentCRM, HubSpot, or Salesforce. It replaces the form backend layer. A webhook with signed delivery then forwards validated submission data to whatever CRM you choose.

Contact Form 7 stack vs Forminit

RequirementContact Form 7 approachForminit approach
Form interfaceRebuild manually when headlessYour own HTML, React, or design system
Submission endpointWordPress CF7 REST endpointForminit endpoint or SDK
Server-side validationCF7 form rulesTyped Form Blocks (email, phone, URL, date, country, rating)
Submission storageFlamingo or CFDB7 add-onBuilt-in searchable inbox
CSV exportStorage add-onBuilt in
Spam protectionSeparate pluginsBuilt in, plus optional Cloudflare Turnstile, reCAPTCHA, hCaptcha, honeypot
File uploadsCF7 config + WordPress processingBuilt in, 25 MB per submission
Email notificationsWordPress mail + SMTP pluginManaged notifications, custom SMTP
AutoresponderSecond mail templateBuilt-in autoresponder
RedirectsRedirect add-onConfigurable redirect URL
CRM connectionPlugin or PHP hookWebhook or integration
Marketing attributionCustom fields and scriptsSDK auto-captures UTM and ad click IDs
WordPress required at submission timeYesNo
Plugin maintenanceMultiple WordPress dependenciesOne SDK/API integration

When should you keep Contact Form 7?

Keep CF7 when most of the following are true: your forms are stable and working, the WordPress installation stays publicly reachable, existing CF7 hooks are business-critical, form definitions must remain editable in WordPress, submission data is intentionally stored in WordPress, and you only run one or two simple forms. A working system does not need replacing merely because an alternative architecture exists.

When is a dedicated form backend the better choice?

A form backend becomes more attractive when WordPress should be content-only, the frontend lives on Vercel, Netlify, or Cloudflare, forms must keep working when WordPress is down or hidden from the public internet, you need storage, files, webhooks, and spam controls without assembling plugins, or you want the same form infrastructure across projects that are not WordPress at all. (New to the pattern? See what a headless form backend is.)

How to migrate a Contact Form 7 form to Forminit

You keep your HTML or React interface and replace the submission destination.

1. Audit the existing CF7 form. Record field names and types, required and consent fields, file-upload rules, recipients (including CC/BCC and reply-to), mail templates, autoresponse content, redirect destination, webhook or CRM actions, and spam configuration. Do not start from the shortcode: it is an instruction for WordPress, not a data schema.

2. Create a Forminit form and copy its Form ID. There is no field builder to reproduce: your fields are defined by the input names in your HTML using typed blocks (sender, email, phone, text, date, select, checkbox, country, rating, file). Form Blocks validate on the server and give CSV exports and webhooks a consistent structure.

3. Map the fields. The CF7 form above becomes:

<input type="text" name="fi-sender-fullName" required />
<input type="email" name="fi-sender-email" required />
<input type="tel" name="fi-sender-phone" />
<textarea name="fi-text-message" required></textarea>

4. Configure notifications and the autoresponder. Recipients, reply-to, subject, and message templates can use submitted field values. Connect custom SMTP when messages should come from your own domain.

5. Configure spam protection. Built-in protection works out of the box; add Cloudflare Turnstile, a honeypot, reCAPTCHA v3, or hCaptcha plus authorized domains as needed. Tokens are verified server-side.

6. Add the CRM or webhook. Point a webhook at HubSpot, Salesforce, FluentCRM’s endpoint, an automation platform, or your own API. Test failed deliveries, not just successful ones.

7. Configure the redirect. The SDK returns your configured redirectUrl, so the frontend can redirect or show an inline confirmation.

8. Test failure cases. Invalid email, empty required field, oversized file, duplicate click, slow network, spam challenge failure, webhook timeout, submission limit reached, unauthorized domain. A form is production-ready when its failure behavior is understood, not when one test email arrives.

Next.js App Router example with Forminit

Install the SDK:

npm install forminit

Create the proxy route so the API key stays on the server:

// app/api/forminit/route.ts
import { createForminitProxy } from 'forminit/next';

export const POST = createForminitProxy({
  apiKey: process.env.FORMINIT_API_KEY,
});

Create the form component:

'use client';

import { FormEvent, useMemo, useState } from 'react';
import { Forminit } from 'forminit';

type Status = 'idle' | 'submitting' | 'success' | 'error';

export function ContactForm({ formId }: { formId: string }) {
  const forminit = useMemo(
    () => new Forminit({ proxyUrl: '/api/forminit' }),
    [],
  );
  const [status, setStatus] = useState<Status>('idle');
  const [message, setMessage] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;

    setStatus('submitting');
    setMessage('');

    const { error, redirectUrl } = await forminit.submit(
      formId,
      new FormData(form),
    );

    if (error) {
      setStatus('error');
      setMessage(error.message);
      return;
    }

    form.reset();
    setStatus('success');
    setMessage('Thank you. Your message has been received.');

    if (redirectUrl) {
      window.location.assign(redirectUrl);
    }
  }

  return (
    <form onSubmit={handleSubmit} encType="multipart/form-data">
      <label htmlFor="full-name">Name</label>
      <input id="full-name" name="fi-sender-fullName" type="text" autoComplete="name" required />

      <label htmlFor="email">Email</label>
      <input id="email" name="fi-sender-email" type="email" autoComplete="email" required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="fi-text-message" required />

      <button type="submit" disabled={status === 'submitting'}>
        {status === 'submitting' ? 'Sending…' : 'Send message'}
      </button>

      {message && (
        <p role={status === 'error' ? 'alert' : 'status'} aria-live="polite">
          {message}
        </p>
      )}
    </form>
  );
}

Unlike the CF7 implementation, this component needs no WordPress URL, no numeric form ID hunt, no plugin version, no locale, no unit tag, no CORS rule, no storage plugin, and no SMTP plugin. The frontend contains only fields relevant to the submission.

Headless form security checklist

Whichever backend you choose:

  • Use HTTPS everywhere: frontend, WordPress API, and form endpoint.
  • Keep secrets on the server. Public form IDs are identifiers, not secrets; API keys and SMTP passwords never belong in a client bundle.
  • Validate on the server. Browser validation is a usability feature, not a security boundary.
  • Restrict allowed domains so other sites cannot reuse your public endpoint.
  • Layer spam protection carefully. Start with rate limits and a honeypot; add CAPTCHA where spam volume justifies the friction, and re-test legitimate submissions after every change.
  • Store the submission before relying on email. Email should notify the team, not be the only copy of the lead.
  • Validate uploaded files: size, MIME type, count, retention, and who can download them.
  • Log downstream failures. A successful form request does not mean the webhook, CRM sync, and notification all succeeded.
  • Document data handling: where submissions live, which processors receive them, retention periods, and how deletion requests are handled.

Decision table

SituationRecommended approach
Existing CF7 form, simple headless frontend, WordPress must own emailKeep CF7 REST API
Complex CF7 hooks power business workflowsKeep CF7, build the integration carefully
Editors must create and modify forms in WordPressCF7 or a headless-compatible WordPress form plugin
WordPress should be a content-only CMSDedicated form backend
Forms must work when WordPress is unavailableDedicated form backend
You need storage, files, spam protection, and webhooks quicklyDedicated form backend
Submission data must stay in the WordPress databaseWordPress-based form solution
Same form infrastructure across several frameworksFramework-independent form backend

Frequently asked questions

Can Contact Form 7 work with headless WordPress?

Yes. Build the form UI in your frontend and submit FormData to CF7’s REST endpoint (/wp-json/contact-form-7/v1/contact-forms/{id}/feedback). You must recreate the field names, hidden values like _wpcf7_unit_tag, validation display, and spam protection that CF7 normally adds to WordPress-rendered pages.

Does the Contact Form 7 REST API accept JSON?

No. The feedback endpoint expects a multipart FormData body. Sending a JSON body returns wpcf7_unsupported_media_type with HTTP status 415. Build a FormData object and let the browser or server runtime set the Content-Type header and boundary automatically.

What is _wpcf7_unit_tag?

An identifier Contact Form 7 generates for each rendered instance of a form, normally embedded in the WordPress-rendered markup. A headless frontend must supply it manually in the format wpcf7-f{formId}-o1, otherwise the API returns wpcf7_unit_tag_not_found.

Where do I find the real Contact Form 7 form ID?

Edit the form in WordPress admin and read the number after post= in the URL (/wp-admin/admin.php?page=wpcf7&post=123). Newer CF7 shortcodes display a hashed ID like a0b94c4, which is not the numeric ID the REST endpoint requires; using it returns rest_no_route.

Does Contact Form 7 store submissions?

Not by default. CF7 only sends email. To retain submissions in WordPress you need its companion Flamingo plugin or a third-party add-on such as CFDB7, which adds database storage and CSV export. Without one, the notification email is the only record of each lead.

Does Antispam Bee protect Contact Form 7?

No. Antispam Bee’s own documentation states it protects default WordPress comments, not form plugins or registrations. For CF7 you need form-compatible protection such as a honeypot plugin, Maspik, or a CAPTCHA integration like reCAPTCHA or Cloudflare Turnstile.

Can Contact Form 7 file uploads work in a headless frontend?

Yes, but the browser must send a multipart FormData request containing the files, WordPress must accept their type and size, and your frontend must display upload and validation errors itself. File handling is one of the most fragile parts of a headless CF7 integration.

Is Forminit a WordPress plugin?

No. Forminit is an external form backend API. Your frontend submits directly to a Forminit endpoint, so no plugin is installed in WordPress and no submission passes through the WordPress server. It works with WordPress, Next.js, React, Nuxt, Astro, and any frontend that can send an HTTP request.

Will my form work if WordPress is offline?

With Contact Form 7, no: WordPress receives and processes every submission, so a WordPress outage is a form outage. With a dedicated form backend like Forminit, yes: the form submits to the backend directly, so it keeps working as long as your frontend is available.

Should I submit directly from the browser or through a server route?

Submit directly from the browser for a deliberately public endpoint with domain restrictions and spam controls. Use a server route when you need an API key kept secret, additional validation, centralized logging, or rate limiting. For CF7, a server route also avoids most CORS problems.

Final recommendation

Contact Form 7 remains a practical, widely used WordPress form plugin, and its REST endpoint means it can be adapted to a headless frontend. For an established WordPress workflow with stable forms, keeping it is a legitimate choice.

But headless changes the cost-benefit calculation. Once you have rebuilt the form UI, recreated CF7’s hidden values, solved CORS, reconnected spam protection, added a storage plugin, configured SMTP, and installed redirect and CRM add-ons, Contact Form 7 is no longer the simple part of your system.

For new headless projects, the cleaner division is: WordPress manages content, a form backend manages submissions. Create a Forminit form and point your existing frontend at it: you keep the design, editors keep WordPress, and WordPress leaves the submission path.

Further reading