Contact forms are a fundamental touchpoint between site visitors and site owners, enabling customer inquiries, lead generation, and essential feedback that drives engagement.
In headless WordPress, this can be tricky since the frontend and backend are separated. This means that you have to figure out a way for the frontend to send that data to your WP backend as securely as possible.
In this article, we’ll discuss implementing a simple contact form in headless WordPress using WPGraphQL, Ninja Forms, and the Next.js App Router.
If you prefer the video format, you can access it here:
Prerequisites
Before we begin, you should have a basic understanding of the following:
- Headless WordPress concepts
- The WPGraphQL plugin
- The Next.js App Router
This article is not a step-by-step walkthrough, but if you’d like to explore the codebase and follow along, you can clone the example repository, which includes a detailed setup guide.
Using Ninja Forms with WPGraphQL
Ninja Forms is a flexible and user-friendly form-building plugin for WordPress. It offers a robust set of features out of the box, and its core functionality is free and open source.
To expose Ninja Forms data via GraphQL, we’ll use the WPGraphQL for Ninja Forms extension. This plugin adds a GraphQL schema for Ninja Forms, allowing queries and mutations for form data.
For this example, we’ll stick with the free tier of Ninja Forms and use its default fields: Name, Email, and Message.

After downloading the WPGraphQL for Ninja Forms plugin, let’s test that it works by requesting and submitting data to its API:
Requesting form default form data:

Submitting data to the form via mutation:

You should get the results back in the right-hand pane, with the data expected when you query for it, and the success boolean set to true if the submission was successful.
Stoked, these both work!
Form Submission in Next.js App Router
The next step I took was to create the API route responsible for handling the form submission safely. In App Router, route handlers allow you to create custom request handlers for a given route using the Web Request and Response APIs.
We will take advantage of that convention in the app/api/contact/route.ts
file.
This file securely bridges a Next.js frontend with a WordPress backend via GraphQL. In the POST
handler, the code first reads JSON from the incoming request and expects three properties—name, email,
and message
. If any of these fields is missing, it immediately returns a 400 response
with an error. Once validation passes, the handler constructs a WPGraphQL mutation:
const mutation = `
mutation SubmitForm($input: SubmitFormInput!) {
submitForm(input: $input) {
success
message
errors {
fieldId
message
slug
}
}
}
`;
Code language: PHP (php)
Next, the code issues a fetch call to the WordPress GraphQL endpoint, which is specified via the environment variable NEXT_PUBLIC_GRAPHQL_ENDPOINT
. In that call, the headers include "Content-Type": "application/json"
and an Authorization header built from another environment variable:
Authorization: Bearer ${process.env.WP_AUTH_TOKEN}
Because WP_AUTH_TOKEN
is pulled from process.env
, it ensures that only your Next.js app—holding this secret—can successfully authorize and submit data to the WordPress endpoint.
The request body contains the query and a variables object whose input includes formId: 1
, an array of field objects mapping each field’s id to value: data.name, value: data.email
, and value: data.message
, plus a clientMutationId
set to "contact-form-submission"
.
When the response arrives, the code calls await wpResponse.json()
, then checks both wpResponse.ok
and response.errors
. If either indicates a failure, it logs the first GraphQL error to the server console and throws an exception. In the success case—when submitForm
returns something like { success: true, message: "…", errors: [] }
—the route returns a 200 JSON response:
{ "success": true, "message": "Form submitted successfully" }
Any thrown exception or unexpected condition is caught by the catch block, which logs the error and returns a 500 response with { error: "Form submission failed" }
. Finally, the file exports a GET handler that always returns a 405 “Method not allowed”
response, ensuring only POST requests are processed.
Interfacing with the API route via server actions
Now that we have discussed the API route in route.ts, let’s go over the server action responsible for interfacing with that API endpoint.
The actions.ts
file implements a server-side action that functions as the middleware between the client-side form submission and the API endpoint.
When a user triggers the form submission, the submitForm
server action is invoked, which processes the form data and initiates an HTTP POST
request to the /api/contact
endpoint.
This endpoint, implemented in route.ts
, then executes the WordPress WPGraphQL mutation through the Ninja Forms API.
Rendering the Form on the Client
The contact-form.tsx
file implements a client-side form component that leverages Next.js’s Form API and server actions for handling contact form submissions.
The component is marked with the "use client"
directive, indicating that it runs on the client side and utilizes React’s useFormStatus
hook to manage form submission states.
The form implementation consists of two main components.
This is a React component called SubmitButton
that renders a <button>
whose appearance and behavior change based on the form’s submission state.
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={`w-full py-3 px-6 rounded bg-yellow-500 text-black font-semibold hover:bg-yellow-400 transition-colors ${
pending ? "opacity-50 cursor-not-allowed" : ""
}`}
>
{pending ? "Sending..." : "Send Message"}
</button>
);
}
Code language: JavaScript (javascript)
And this is the main ContactForm
component that manages the form state and submission:
export function ContactForm() {
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
async function handleSubmit(formData: FormData) {
const result = await submitForm(formData);
if ("error" in result) {
setMessage({ type: "error", text: result.error });
} else {
setMessage({ type: "success", text: result.success });
}
}
// ... form JSX
}
Code language: JavaScript (javascript)
Notice that I decided to use the form component built into Next.js to keep things simple. You can dynamically fetch the form data from the Ninja Forms WPGraphQL API. For this article and simplicity’s sake, I chose the static form provided by Next.js
Environment Variables
Before trying the form on the browser to see if it works, the last thing you have to check is your environment variables. In the .env.local
file at your project’s root, your environment variables should be as follows:
NEXT_PUBLIC_GRAPHQL_ENDPOINT="https://your-wpsite.com/graphql"
WP_AUTH_TOKEN="your-auth-token"
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Code language: JavaScript (javascript)
Generate an Auth Token
You can generate an auth token to add to your environment variable by using the openssl command in terminal:
openssl rand -base64 32
This command generates a random 32-byte string encoded in base64, which is great for use as an authentication token. The output is a secure, random string that can be used as the WP_AUTH_TOKEN
in your environment variables.
Test the form on the browser
Everything is set up, and now it’s time to test this form on the browser. I will navigate to my contact form route. Here is the form working in all its glory!

Locking down the edit page on WP Admin
If you’re using a static form component in your frontend, any changes made to the form structure in the WordPress admin can break the submission logic.
To prevent this, restrict access to Ninja Forms editing screens by creating a custom plugin that overrides its default capabilities:
Example Plugin: Ninja Forms Admin Lockdown
<?php
/**
* Plugin Name: Ninja Forms Admin Lockdown
* Description: Restricts access to all Ninja Forms admin screens so that only users with the 'edit_themes' capability (typically Administrators) can view or modify forms.
* Version: 1.0.0
* Author: Your Name
* License: GPLv2 or later
* Text Domain: ninja-forms-admin-lockdown
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Restrict access to “All Forms” and the main Ninja Forms menu.
*
* By default, Ninja Forms uses 'edit_posts' to gate access.
* Returning 'edit_themes' here ensures only users with that capability can see or edit.
*/
function nf_allforms_capabilities( $cap ) {
return 'edit_themes';
}
add_filter( 'ninja_forms_admin_parent_menu_capabilities', 'nf_allforms_capabilities' );
add_filter( 'ninja_forms_admin_all_forms_capabilities', 'nf_allforms_capabilities' );
/**
* Restrict access to “Add New Form” submenu.
*/
function nf_newforms_capabilities( $cap ) {
return 'edit_themes';
}
add_filter( 'ninja_forms_admin_parent_menu_capabilities', 'nf_newforms_capabilities' );
add_filter( 'ninja_forms_admin_all_forms_capabilities', 'nf_newforms_capabilities' );
add_filter( 'ninja_forms_admin_add_new_capabilities', 'nf_newforms_capabilities' );
Code language: HTML, XML (xml)
In order to do this, you need to hook into Ninja Forms’ capability filters and return a higher-level capability.
Install and activate this plugin to restrict form editing to only site Administrators (those who have the `edit_themes`
capability).
Conclusion
Integrating a contact form on a headless WordPress site doesn’t have to be complex. By combining Next.js, WPGraphQL, and Ninja Forms, you can build a secure, modern contact form that connects your frontend and backend seamlessly.
As always, we’re super stoked to hear your feedback and learn about the headless projects you’re working on, so hit us up in the Headless WordPress Discord!