If you’re building a WordPress site, you need a good reason not to choose a WordPress form plugin. They are convenient and offer plenty of customizations that would take a ton of effort to build from scratch. They render the HTML, validate the data, store the submissions, and provide integration with third-party services.

But suppose we plan to use WordPress as a headless CMS. In this case, we will be mainly interacting with the REST API (or GraphQL). The front-end part becomes our responsibility entirely, and we can’t rely anymore on form plugins to do the heavy lifting in that area. Now we’re in the driver’s seat when it comes to the front end.

Forms were a solved problem, but now we have to decide what to do about them. We have a couple of options:

  • Do we use our own custom API if we have such a thing? If not, and we don’t want to create one, we can go with a service. There are many good static form providers, and new ones are popping up constantly.
  • Can we keep using the WordPress plugin we already use and leverage its validation, storage, and integration?

The most popular free form plugin, Contact Form 7, has a submission REST API endpoint, and so does the well-known paid plugin, Gravity Forms, among others.

From a technical standpoint, there’s no real difference between submitting the form‘s data to an endpoint provided by a service or a WordPress plugin. So, we have to decide based on different criteria. Price is an obvious one; after that is the availability of the WordPress installation and its REST API. Submitting to an endpoint presupposes that it is always available publicly. That’s already clear when it comes to services because we pay for them to be available. Some setups might limit WordPress access to only editing and build processes. Another thing to consider is where you want to store the data, particularly in a way that adheres to GPDR regulations.

When it comes to features beyond the submission, WordPress form plugins are hard to match. They have their ecosystem, add-ons capable of generating reports, PDFs, readily available integration with newsletters, and payment services. Few services offer this much in a single package.

Even if we use WordPress in the “traditional” way with the front end based on a WordPress theme, using a form plugin’s REST API might make sense in many cases. For example, if we are developing a theme using a utility-first CSS framework, styling the rendered form with fixed markup structured with a BEM-like class convention leaves a sour taste in any developer’s mouth.

The purpose of this article is to present the two WordPress form plugins submission endpoints and show a way to recreate the typical form-related behaviors we got used to getting out of the box. When submitting a form, in general, we have to deal with two main problems. One is the submission of the data itself, and the other is providing meaningful feedback to the user.

So, let’s start there.

The endpoints

Submitting data is the more straightforward part. Both endpoints expect a POST request, and the dynamic part of the URL is the form ID.

Contact Form 7 REST API is available immediately when the plugin is activated, and it looks like this:


If we’re working with Gravity Forms, the endpoint takes this shape:


The Gravity Forms REST API is disabled by default. To enable it, we have to go to the plugin’s settings, then to the REST API page, and check the “Enable access to the API” option. There is no need to create an API key, as the form submission endpoint does not require it.

The body of the request

Our example form has five fields with the following rules:

  • a required text field
  • a required email field
  • a required date field that accepts dates before October 4, 1957
  • an optional textarea
  • a required checkbox

For Contact Form 7’s request’s body keys, we have to define them with the form-tags syntax:

{ "somebodys-name": "Marian Kenney", "any-email": "[email protected]", "before-space-age": "1922-03-11", "optional-message": "", "fake-terms": "1"

Gravity Forms expects the keys in a different format. We have to use an auto-generated, incremental field ID with the input_ prefix. The ID is visible when you are editing the field.

{ "input_1": "Marian Kenney", "input_2": "[email protected]", "input_3": "1922-03-11", "input_4": "", "input_5_1": "1"

Submitting the data

We can save ourselves a lot of work if we use the expected keys for the inputs’ name attributes. Otherwise, we have to map the input names to the keys.

Putting everything together, we get an HTML structure like this for Contact Form 7:

<form action="https://your-site.tld/wp-json/contact-form-7/v1/contact-forms/<FORM_ID>/feedback" method="post"> <label for="somebodys-name">Somebody's name</label> <input id="somebodys-name" type="text" name="somebodys-name"> <!-- Other input elements --> <button type="submit">Submit</button>

In the case of Gravity Forms, we only need to switch the action and the name attributes:

<form action="https://your-site.tld/wp-json/gf/v2/forms/<FORM_ID>/submissions" method="post"> <label for="input_1">Somebody's name</label> <input id="input_1" type="text" name="input_1"> <!-- Other input elements --> <button type="submit">Submit</button>

Since all the required information is available in the HTML, we are ready to send the request. One way to do this is to use the FormData in combination with the fetch:

const formSubmissionHandler = (event) => { event.preventDefault(); const formElement = event.target, { action, method } = formElement, body = new FormData(formElement); fetch(action, { method, body }) .then((response) => response.json()) .then((response) => { // Determine if the submission is not valid if (isFormSubmissionError(response)) { // Handle the case when there are validation errors } // Handle the happy path }) .catch((error) => { // Handle the case when there's a problem with the request });
}; const formElement = document.querySelector("form"); formElement.addEventListener("submit", formSubmissionHandler);

We can send the submission with little effort, but the user experience is subpar, to say the least. We owe to users as much guidance as possible to submit the form successfully. At the very least, that means we need to:

  • show a global error or success message,
  • add inline field validation error messages and possible directions, and
  • draw attention to parts that require attention with special classes.

Field validation

On top of using built-in HTML form validation, we can use JavaScript for additional client-side validation and/or take advantage of server-side validation.

When it comes to server-side validation, both Contact Form 7 and Gravity Forms offer that out of the box and return the validation error messages as part of the response. This is convenient as we can control the validation rules from the WordPress admin.

For more complex validation rules, like conditional field validation, it might make sense to rely only on the server-side because keeping the front-end JavaScript validation in sync with the plugins setting can become a maintenance issue.

If we solely go with the server-side validation, the task becomes about parsing the response, extracting the relevant data, and DOM manipulation like inserting elements and toggle class-names.

Response messages

The response when there is a validation error for Contact Form 7 look like this:

{ "into": "#", "status": "validation_failed", "message": "One or more fields have an error. Please check and try again.", "posted_data_hash": "", "invalid_fields": [ { "into": "span.wpcf7-form-control-wrap.somebodys-name", "message": "The field is required.", "idref": null, "error_id": "-ve-somebodys-name" }, { "into": "span.wpcf7-form-control-wrap.any-email", "message": "The field is required.", "idref": null, "error_id": "-ve-any-email" }, { "into": "span.wpcf7-form-control-wrap.before-space-age", "message": "The field is required.", "idref": null, "error_id": "-ve-before-space-age" }, { "into": "span.wpcf7-form-control-wrap.fake-terms", "message": "You must accept the terms and conditions before sending your message.", "idref": null, "error_id": "-ve-fake-terms" } ]

On successful submission, the response looks like this:

{ "into": "#", "status": "mail_sent", "message": "Thank you for your message. It has been sent.", "posted_data_hash": "d52f9f9de995287195409fe6dcde0c50"

Compared to this, Gravity Forms’ validation error response is more compact:

{ "is_valid": false, "validation_messages": { "1": "This field is required.", "2": "This field is required.", "3": "This field is required.", "5": "This field is required." }, "page_number": 1, "source_page_number": 1

But the response on a successful submission is bigger:

{ "is_valid": true, "page_number": 0, "source_page_number": 1, "confirmation_message": "<div id='gform_confirmation_wrapper_1' class='gform_confirmation_wrapper '><div id='gform_confirmation_message_1' class='gform_confirmation_message_1 gform_confirmation_message'>Thanks for contacting us! We will get in touch with you shortly.</div></div>", "confirmation_type": "message"

While both contain the information we need, they don‘t follow a common convention, and both have their quirks. For example, the confirmation message in Gravity Forms contains HTML, and the validation message keys don’t have the input_ prefix — the prefix that’s required when we send the request. On the other side, validation errors in Contact Form 7 contain information that is relevant only to their front-end implementation. The field keys are not immediately usable; they have to be extracted.

In a situation like this, instead of working with the response we get, it’s better to come up with a desired, ideal format. Once we have that, we can find ways to transform the original response to what we see fit. If we combine the best of the two scenarios and remove the irrelevant parts for our use case, then we end up with something like this:

{ "isSuccess": false, "message": "One or more fields have an error. Please check and try again.", "validationError": { "somebodys-name": "This field is required.", "any-email": "This field is required.", "input_3": "This field is required.", "input_5": "This field is required." }

And on successful submission, we would set isSuccess to true and return an empty validation error object:

{ "isSuccess": true, "message": "Thanks for contacting us! We will get in touch with you shortly.", "validationError": {}

Now it’s a matter of transforming what we got to what we need. The code to normalize the Contact Forms 7 response is this:

const normalizeContactForm7Response = (response) => { // The other possible statuses are different kind of errors const isSuccess = response.status === 'mail_sent'; // A message is provided for all statuses const message = response.message; const validationError = isSuccess ? {} : // We transform an array of objects into an object Object.fromEntries( response.invalid_fields.map((error) => { // Extracts the part after "cf7-form-control-wrap" const key = /cf7[-a-z]*.(.*)/.exec(error.into)[1]; return [key, error.message]; }) ); return { isSuccess, message, validationError, };

The code to normalize the Gravity Forms response winds up being this:

const normalizeGravityFormsResponse = (response) => { // Provided already as a boolean in the response const isSuccess = response.is_valid; const message = isSuccess ? // Comes wrapped in a HTML and we likely don't need that stripHtml(response.confirmation_message) : // No general error message, so we set a fallback 'There was a problem with your submission.'; const validationError = isSuccess ? {} : // We replace the keys with the prefixed version; // this way the request and response matches Object.fromEntries( Object.entries( response.validation_messages ).map(([key, value]) => [`input_${key}`, value]) ); return { isSuccess, message, validationError, };

We are still missing a way to display the validation errors, success messages, and toggling classes. However, we have a neat way of accessing the data we need, and we removed all of the inconsistencies in the responses with a light abstraction. When put together, it’s ready to be dropped into an existing codebase, or we can continue building on top of it.

There are many ways to tackle the remaining part. What makes sense will depend on the project. For situations where we mainly have to react to state changes, a declarative and reactive library can help a lot. Alpine.js was covered here on CSS-Tricks, and it’s a perfect fit for both demonstrations and using it in production sites. Almost without any modification, we can reuse the code from the previous example. We only need to add the proper directives and in the right places.

Wrapping up

Matching the front-end experience that WordPress form plugins provide can be done with relative ease for straightforward, no-fuss forms — and in a way that is reusable from project to project. We can even accomplish it in a way that allows us to switch the plugin without affecting the front end.

Sure, it takes time and effort to make a multi-page form, previews of the uploaded images, or other advanced features that we’d normally get baked right into a plugin, but the more unique the requirements we have to meet, the more it makes sense to use the submission endpoint as we don’t have to work against the given front-end implementation that tries to solve many problems, but never the particular one we want.

Using WordPress as a headless CMS to access the REST API of a form plugin to hit the submissions endpoints will surely become a more widely used practice. It’s something worth exploring and to keep in mind. In the future, I would not be surprised to see WordPress form plugins designed primarily to work in a headless context like this. I can imagine a plugin where front-end rendering is an add-on feature that’s not an integral part of its core. What consequences that would have, and if it could have commercial success, remains to be explored but is a fascinating space to watch evolve.

Similar Posts