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 post, we’ll discuss implementing a simple contact form in headless WordPress using WPGraphQL, Ninja Forms, and Next.js App Router — and even how to handle optional image uploads via Cloudinary.
Before we begin, you should have a basic understanding of the following:
- Headless WordPress concepts
This post isn’t a step-by-step walkthrough, but if you’d like to explore the codebase and follow along, you can clone the example repository here.
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:
```
{
form(id: "1", idType: DATABASE_ID) {
title
fields {
nodes {
fieldId
label
type
}
}
}
}
```
Code language: JavaScript (javascript)
Submitting data to the form via mutation:
```
mutation SubmitForm($input: SubmitFormInput!) {
submitForm(input: $input) {
success
message
errors {
fieldId
message
slug
}
}
}
```
Code language: JavaScript (javascript)
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}`
Code language: JavaScript (javascript)
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" }
Code language: JSON / JSON with Comments (json)
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.
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.
```
"use server";
export async function submitForm(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
const imageUrl = formData.get("imageUrl") as string | null;
if (!name || !email || !message) {
return { error: "Please fill in all fields" };
}
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
try {
const response = await fetch(`${baseUrl}/api/contact`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name, email, message, imageUrl }),
});
const result = await response.json();
if (!response.ok) {
return { error: result.error || "Submission failed" };
}
return {
success: "Thank you for your message! We will get back to you soon.",
};
} catch (error) {
return {
error: error instanceof Error ? error.message : "Failed to submit form",
};
}
}
```
Code language: PHP (php)
Notice that we also read an optional imageUrl
field from formData
. If the user has uploaded an image to Cloudinary, we will pass that URL along with name
, email
, and message
. Otherwise, imageUrl
remains null
.
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. In addition, we’ve added support for optional image uploads to Cloudinary.
```
"use client";
import Form from "next/form";
import { useFormStatus } from "react-dom";
import { submitForm } from "./actions";
import { useState } from "react";
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>
);
}
export function ContactForm() {
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
async function handleImageUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "Upload failed");
}
setImageUrl(result.secure_url);
} catch (error) {
setMessage({
type: "error",
text: error instanceof Error ? error.message : "Failed to upload image",
});
} finally {
setIsUploading(false);
}
}
async function handleSubmit(formData: FormData) {
if (imageUrl) {
formData.append("imageUrl", imageUrl);
}
const result = await submitForm(formData);
if ("error" in result) {
setMessage({ type: "error", text: result.error });
} else {
setMessage({ type: "success", text: result.success });
setImageUrl(null);
}
}
return (
<div className="space-y-6">
{message && (
<div
className={`p-4 rounded ${
message.type === "success"
? "bg-green-500/20 text-green-300 border border-green-500/20"
: "bg-red-500/20 text-red-300 border border-red-500/20"
}`}
>
{message.text}
</div>
)}
<Form action={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-yellow-300 mb-2">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full p-3 rounded bg-black/50 border border-yellow-500/20 text-yellow-200 focus:border-yellow-500/60 focus:outline-none"
/>
</div>
<div>
<label htmlFor="email" className="block text-yellow-300 mb-2">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full p-3 rounded bg-black/50 border border-yellow-500/20 text-yellow-200 focus:border-yellow-500/60 focus:outline-none"
/>
</div>
<div>
<label htmlFor="message" className="block text-yellow-300 mb-2">
Message
</label>
<textarea
id="message"
name="message"
required
rows={5}
className="w-full p-3 rounded bg-black/50 border border-yellow-500/20 text-yellow-200 focus:border-yellow-500/60 focus:outline-none"
/>
</div>
<div>
<label htmlFor="image" className="block text-yellow-300 mb-2">
Image (Optional)
</label>
<input
type="file"
id="image"
name="image"
accept="image/*"
onChange={handleImageUpload}
className="w-full p-3 rounded bg-black/50 border border-yellow-500/20 text-yellow-200 focus:border-yellow-500/60 focus:outline-none"
/>
{isUploading && (
<p className="mt-2 text-yellow-300">Uploading image...</p>
)}
{imageUrl && (
<div className="mt-2">
<img
src={imageUrl}
alt="Uploaded"
className="max-w-xs rounded border border-yellow-500/20"
/>
</div>
)}
</div>
<SubmitButton />
</Form>
</div>
);
}
```
Code language: PHP (php)
Notice that we now handle an optional image upload. When the user selects a file, handleImageUpload
sends it to our new /api/upload
endpoint (implemented below) and captures the returned secure_url
. If the upload succeeds, imageUrl
is stored in state; if it fails, an error message appears. When the form is submitted, we append imageUrl
(if present) to formData
before calling submitForm
.
With Part 1, we covered everything required to get a headless contact form up and running: we installed and tested Ninja Forms with WPGraphQL, built a secure Next.js API route (/api/contact/route.ts
) that submits name, email, and message via a GraphQL mutation, and wired up a corresponding server action (actions.ts
) to bridge our client-side form to that endpoint.
We also enhanced the client form (contact-form.tsx
) to allow users to pick an image, upload it to Cloudinary via a new /api/upload
route, and then include the resulting secure_url
alongside the rest of the form data. In Part 2, we’ll dive into the details of the Cloudinary integration, such as how to configure and implement the server-side upload route, manage environment variables, and optimize image delivery on the frontend, so that every submission, including an optional photo, flows seamlessly into WordPress.