Gravity Forms is a WordPress plugin that allows you to create a variety of forms on your WordPress site. Its large selection of add-ons lets you send collected form data to various CRMs, process data, and more!
In this article, you’ll learn how you can query for Gravity Form data, render the form in a Nuxt.js app, perform field validation, and submit the form entries to your headless WordPress backend.
I’ll provide a Nuxt.js app repo that contains Vue components, Vue composables, and helper functions that you can use for your own projects and experiment with. Let’s dive in!
Table of Contents
Prerequisites
To benefit from this article, you should be familiar with the basics of working with the command line, headless WordPress development, Vue, and Nuxt.
Steps for setting up:
1. Set up a WordPress site and get it running. Local by WP Engine is a good dev environment to use.
2. Install and activate the Gravity Forms, WPGraphQL, and WPGraphQL for Gravity Forms WordPress plugins.3. Clone down the Nuxt repo for this project by copying and pasting this command in your terminal:
npx degit Fran-A-Dev/nuxt3-headlesswp-gravity-forms#main my-project
Code language: PHP (php)
4. Import the questionnaire form. From the WordPress admin sidebar, go to Forms > Import/Export > Import Forms
. Select the gravityforms-questionnaire-form.json
inside the root of the Nuxt project folder and click the button to import it.
5. Create a .env.local
file inside of the root of the Nuxt project. Open that file in a text editor and paste in :
NUXT_PUBLIC_WORDPRESS_API_URL=http://wpgraphqlgravtyforms.local/graphql,
replacing wpgraphqlgravtyforms.local
with the domain for your WordPress site. This is the endpoint that Nuxt will use when it sends requests to your WordPress backend.
6. Run npm install
to install the dependencies.
7. Run npm run dev
to get the server running locally.
8. You should now be able to click the “Questionnaire” link in the header to go to the form at http://localhost:3000/questionnaire in a web browser and see it in all its glory:
WPGraphQL for Gravity Forms
The WPGraphQL for Gravity Forms plugin is a powerful extension for WPGraphQL that provides a comprehensive suite of features that allows developers to interact with Gravity Forms via GraphQL. Let’s start by querying for a form.
Querying for a Form
To query for a form, we have the `gfForm`
query that we can use to query for data about our Gravity Forms. Here’s a simple example if you want to replace the existing query in the project, open up `composables/useGravityForm.js`
and paste this query in replacement of the one currently there:
query getForm {
gfForm(id: 1, idType: DATABASE_ID) {
databaseId
title
description
formFields(first: 500) {
nodes {
... on TextField {
id
type
label
}
... on SelectField {
id
type
label
choices {
text
value
}
}
}
}
}
}
In this query, we are asking for form data. The `gfForm`
query retrieves a specific form by its database ID (id: 1)
.
For that form, the query fetches basic information like its title and description.
The query also fetches the formFields
(up to ), and for each field, it checks whether it’s a TextField
or SelectField
. Depending on the type, it will fetch the appropriate data such as id, type, label, and for SelectField
, it will also retrieve the choices (with their text and value).
Go ahead and test out this query right from the WordPress admin by following these steps:
1. Go to GraphQL > GraphiQL IDE
.
2. Paste the query above into the left column, replacing id: 1
with the ID of the imported form.
3. Click the ▶ button to execute the query.
4. See the results returned in the right column. You should see this:
Gravity Forms Field Support
Now, let’s highlight one of the latest features of WPGraphQL for Gravity Forms which we use in this project. This feature is Forms Field support with the FormField
interface.
The interface approach leverages GraphQL interfaces to abstract shared properties among Gravity Forms fields, meaning you can query a common set of fields like “label”
or “isRequired”
across multiple field types.
This method allows you to write a more composable query that automatically includes any new field type that implements a given interface without needing to update your query.
In our project, we used inline fragments on interfaces such as GfFieldWithLabelSetting
and GfFieldWithRulesSetting
to fetch common properties like label
and isRequired
from each form field.
Our query retrieves both inputType
and type
values. The sample’s current component mapping relies on the static type property to determine which Vue component to render. For the scope of this article, the inputType
is still included in the query output to point out the new support.
inputType
Prop
For other use cases outside the scope of this article, you can leverage the inputType
property instead of the static type
to dynamically determine which component to render for each Gravity Forms field.
This dynamic approach allows a single form field to resolve into multiple input types—such as a Quiz Field that can be rendered as either a Checkbox or Radio Field—based on its configuration. Using the inputType
allows your code to automatically map to the correct component, making it a bit more flexible and easier to maintain as new input variants are introduced. Stay tuned for a future article that focuses on this!
Check out the WPGraphQL for Gravity Forms readme for more documentation on gfForm
, the FormField
interface and other queries and mutations the plugin offers.
Querying for the form in Nuxt
Now that we know what a query for a form looks like and the FormField
interface the types inherit let’s see how we can use it in our Nuxt app.
Open up the Nuxt app in your code editor and navigate to the composables/useGravityForm.js
file.
This file is a Nuxt.js composable designed to interface with WPGraphQL for fetching Gravity Forms data. It imports the ref function from Vue and the runtime configuration using useRuntimeConfig from Nuxt’s #app
alias. It defines a reactive variable called formFields
that will hold the array of form field objects retrieved from the backend.
A multi-line GraphQL query named formQuery
is declared to fetch a Gravity Form’s fields by its ID. The query leverages GraphQL interfaces to abstract common properties shared by multiple field types.
For more complex field configurations, inline fragments on GfFieldWithChoicesSetting
fetch choices and input details, while GfFieldWithConditionalLogicSetting
retrieves any conditional logic rules defined on the field:
const formQuery = `
query GetGravityForm($formId: ID!) {
gfForm(id: $formId, idType: DATABASE_ID) {
formFields(first: 300) {
nodes {
id
databaseId
inputType
type
visibility
... on GfFieldWithLabelSetting {
label
}
... on GfFieldWithRulesSetting {
isRequired
}
... on GfFieldWithCssClassSetting {
cssClass
}
... on GfFieldWithDefaultValueSetting {
defaultValue
}
... on GfFieldWithSizeSetting {
size
}
... on GfFieldWithPlaceholderSetting {
placeholder
}
... on GfFieldWithMaxLengthSetting {
maxLength
}
... on GfFieldWithInputMaskSetting {
inputMaskValue
}
... on GfFieldWithChoicesSetting {
choices {
text
value
}
inputs {
id
label
}
}
... on GfFieldWithConditionalLogicSetting {
conditionalLogic {
actionType
logicType
rules {
fieldId
operator
value
}
}
}
}
}
}
}
Code language: PHP (php)
The fetchForm
function is defined to send a POST request to the WordPress GraphQL endpoint using Nuxt’s useFetch
composable. It includes a request body that contains the query and variables, with a default formId
of "1"
to retrieve a specific form. In this line containing the body object, go ahead and replace the integer with your specific ID:
body: JSON.stringify({
query: formQuery,
variables: { formId: "1" }, // Default formId (you can change this to what your id is)
}),
Code language: JavaScript (javascript)
The immediate
flag shown below is set to false
so that the fetch operation is not executed automatically, allowing for manual triggering via the execute function.
This is important because it provides better control over when data is fetched, optimizing performance and preventing unnecessary network requests. By waiting for a specific point in the component lifecycle—such as when a button is clicked or a user interacts with the page—we ensure that data is only fetched when needed. In this case, we trigger the fetch manually within the onMountedlifecycle hook, which ensures that the data is loaded once the Nuxt page component rendering the form is attached to the DOM:
immediate: false, // Prevent automatic execution
transform: (res) => {
if (res.errors) {
console.error("GraphQL Errors:", res.errors);
throw new Error(res.errors[0].message);
}
const fields = res.data?.gfForm?.formFields?.nodes;
if (!Array.isArray(fields)) {
console.error("Invalid fields data:", res.data);
throw new Error("Invalid form fields data");
}
return fields;
},
}
);
// Return execute to manually trigger the fetch later
return { data, status, fetchError, execute, refresh };
};
Code language: JavaScript (javascript)
Submitting the form in Nuxt
Staying in the useGravityForm.js
file, we finish off the logic to allow the user to submit form data to our WordPress backend via Nuxt.
We do this with the submitForm
function. This is an asynchronous function that accepts a form ID and field values, transforms these values using transformFieldValue
, and then submits them via a GraphQL mutation.
This mutation sends the form ID and the transformed field values to the backend, which responds with either errors or confirmation details. Finally, the composable returns an object containing formFields
, fetchForm
, and submitForm
so that other parts of the Nuxt application can fetch and submit Gravity Forms data:
const submitForm = async (formId, fieldValues) => {
try {
const transformedValues = Object.entries(fieldValues)
.map(([id, value]) => {
const field = formFields.value.find(
(f) => f.databaseId === parseInt(id, 10)
);
if (!field) {
console.warn(`No field found for ID ${id}`);
return null;
}
return transformFieldValue(field, value);
})
.filter(Boolean);
const mutation = `
mutation SubmitForm($formId: ID!, $fieldValues: [FormFieldValuesInput!]!) {
submitGfForm(input: {
id: $formId
fieldValues: $fieldValues
}) {
errors {
id
message
}
confirmation {
message
type
}
entry {
id
... on GfSubmittedEntry {
databaseId
}
}
}
}
`;
const response = await fetch(config.public.wordpressUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
query: mutation,
variables: {
formId: parseInt(formId, 10),
fieldValues: transformedValues,
},
}),
});
const result = await response.json();
if (result.errors) {
throw new Error(result.errors.map((e) => e.message).join(", "));
}
return result.data.submitGfForm;
} catch (error) {
console.error("Submit form error:", error);
throw error;
}
};
return { formFields, fetchForm, submitForm };
}
Code language: JavaScript (javascript)
Rendering The Form
Now that we know how the form data is being queried for and submitted, let’s check out where this logic is being used, how the state is being managed, and where the data is being rendered.
Navigate over to pages/headlesswp-gform/index.vue
.
Take a look at the entire file in your code editor. Let’s break it down from top to bottom.
The script starts by importing Vue’s reactive functions (ref, reactive
, onMounted, watch
) and several form field components (e.g., InputField, EmailField
–Don’t worry, we will discuss where these are coming from in the next section) to render the form.
It then imports the useGravityForm
composable, which provides functions to fetch form metadata and submit form data from WPGraphQL:
import { ref, reactive, onMounted, watch } from "vue";
import {
InputField,
DropdownField,
ChoiceListField,
AddressField,
DateField,
TimeField,
NameField,
PhoneField,
} from "~/components/form-fields";
import EmailFieldComponent from "~/components/form-fields/EmailField.vue";
import useGravityForm from "~/composables/useGravityForm";
const { fetchForm, submitForm, formFields } = useGravityForm();
Code language: JavaScript (javascript)
Following that, a reactive reference formValues
is declared using ref({})
to store user input for each form field.
Then we establish a reactive error storage object for both address and email validations. It defines a validateAddress
function that checks if each required component of an address is present and formatted correctly, updating error messages as needed.
Similarly, the validateEmail
function uses a regular expression to confirm that the email address adheres to a valid format. If any validation fails, the corresponding error message is set and the function returns false. This client-side validation ensures that only complete and correctly formatted data is submitted, improving user experience and data integrity.
const formValues = ref({});
const error = ref(null);
const validationErrors = reactive({
address: {
street: null,
city: null,
state: null,
zip: null,
country: null,
},
email: null,
});
// Validate the entire address object and update errors per field.
const validateAddress = (address) => {
let valid = true;
if (!address.street) {
validationErrors.address.street = "Street address is required.";
valid = false;
} else {
validationErrors.address.street = null;
}
if (!address.city) {
validationErrors.address.city = "City is required.";
valid = false;
} else {
validationErrors.address.city = null;
}
if (!address.state) {
validationErrors.address.state = "State is required.";
valid = false;
} else {
validationErrors.address.state = null;
}
if (!address.zip || !/^\d{5}$/.test(address.zip)) {
validationErrors.address.zip = "Please enter a valid 5-digit ZIP code.";
valid = false;
} else {
validationErrors.address.zip = null;
}
if (!address.country) {
validationErrors.address.country = "Country is required.";
valid = false;
} else {
validationErrors.address.country = null;
}
return valid;
};
// Validate the email value and update the error.
const validateEmail = (email) => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!emailRegex.test(email)) {
validationErrors.email = "Please enter a valid email address.";
return false;
}
validationErrors.email = null;
return true;
};
Code language: JavaScript (javascript)
Next, The updateFieldValue
function merges the current formValues
with a new value for a given field ID, ensuring that changes to input fields update the reactive state.
Inside the onMounted
lifecycle hook, the code calls fetchForm()
to get the form metadata and immediately triggers the fetch using execute()
:
// Handle field value updates
const updateFieldValue = (fieldId, value) => {
formValues.value = {
...formValues.value,
[fieldId]: value,
};
};
onMounted(() => {
const { data, error: fetchError, execute } = fetchForm();
execute();
Code language: JavaScript (javascript)
A watcher on the returned data initializes formFields
and builds an initialValues
object based on the field type, setting default values (e.g., an object for addresses, and an empty array for checkboxes).
For example, if a field is of type “ADDRESS,” the code sets its default value to an object with empty strings for street, lineTwo, city, state, zip,
and a default country
of “US.”
A separate watcher monitors fetchError
and updates the local error reference with the error message if the fetch fails:
watch(data, (newData) => {
if (newData && Array.isArray(newData)) {
formFields.value = newData;
const initialValues = {};
newData.forEach((field) => {
switch (field.type) {
case "ADDRESS":
initialValues[field.databaseId] = {
street: "",
lineTwo: "",
city: "",
state: "",
zip: "",
country: "US",
};
break;
case "CHECKBOX":
case "MULTISELECT":
initialValues[field.databaseId] = [];
break;
case "NAME":
initialValues[field.databaseId] = {
prefix: "",
first: "",
middle: "",
last: "",
suffix: "",
};
break;
default:
initialValues[field.databaseId] = "";
}
});
formValues.value = initialValues;
}
});
watch(fetchError, (err) => {
if (err) {
error.value = err.message;
}
});
});
Code language: PHP (php)
The handleSubmit
function validates the email and address fields by checking their corresponding values in formValues
and displays an alert if validation fails:
const handleSubmit = async () => {
let isValid = true;
// Validate email field before submission
const emailField = formFields.value.find((field) => field.type === "EMAIL");
if (emailField && formValues.value[emailField.databaseId]) {
if (!validateEmail(formValues.value[emailField.databaseId])) {
isValid = false;
}
}
// Validate address field before submission
const addressField = formFields.value.find(
(field) => field.type === "ADDRESS"
);
if (addressField && formValues.value[addressField.databaseId]) {
if (!validateAddress(formValues.value[addressField.databaseId])) {
isValid = false;
}
}
if (!isValid) {
alert("Please fix the errors before submitting.");
return;
}
Code language: JavaScript (javascript)
If validation passes, it calls submitForm
with the current form values, transforming them as needed and handling the response for errors or confirmation.
On successful submission, the form is reset by building a new object (resetValues
) with default values for each field, which is then assigned to formValues.value
.
Finally, the template loops over formFields
and dynamically renders the appropriate component for each field type (using the fieldComponents
mapping
), binding each component’s value to formValues
via v-model
and providing a submit button to send the form data:
try {
const response = await submitForm(1, formValues.value);
if (response?.errors?.length > 0) {
throw new Error(response.errors[0].message);
}
if (response?.confirmation) {
const temp = document.createElement("div");
temp.innerHTML = response.confirmation.message;
const cleanMessage = temp.textContent || temp.innerText;
alert(cleanMessage);
// Reset fields after submission
const resetValues = {};
formFields.value.forEach((field) => {
switch (field.type) {
case "ADDRESS":
resetValues[field.databaseId] = {
street: "",
lineTwo: "",
city: "",
state: "",
zip: "",
country: "US",
};
break;
case "CHECKBOX":
case "MULTISELECT":
resetValues[field.databaseId] = [];
break;
case "NAME":
resetValues[field.databaseId] = {
prefix: "",
first: "",
middle: "",
last: "",
suffix: "",
};
break;
default:
resetValues[field.databaseId] = "";
}
});
formValues.value = resetValues;
}
} catch (err) {
alert(`Error submitting form: ${err.message}`);
}
};
// Map field types to components
const fieldComponents = {
TEXT: TextField,
EMAIL: EmailField,
TEXTAREA: TextAreaField,
SELECT: SelectField,
MULTISELECT: MultiSelectField,
ADDRESS: AddressField,
CHECKBOX: CheckboxField,
DATE: DateField,
TIME: TimeField,
NAME: NameField,
WEBSITE: WebsiteField,
};
</script>
<template>
<div class="p-4">
<!-- Global error message -->
<div v-if="error">
<p class="text-red-600">Error: {{ error }}</p>
</div>
<div v-else-if="!formFields.length">
<p>Loading form...</p>
</div>
<form v-else @submit.prevent="handleSubmit">
<div v-for="field in formFields" :key="field.databaseId" class="mb-4">
<component
:is="fieldComponents[field.type]"
:field="field"
:model-value="formValues[field.databaseId]"
@update:model-value="
(newValue) => updateFieldValue(field.databaseId, newValue)
"
:validation-errors="validationErrors"
:validate-email="validateEmail"
:validate-address="validateAddress"
/>
</div>
<button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">
Submit
</button>
</form>
</div>
</template>
Code language: HTML, XML (xml)
Field Component Rendering
Now let’s discuss where those field component imports were coming from in the previous section. Navigate to components/field-forms
. This folder contains all the component files for the fields.
In our project, we organized the form field components into groups based on shared behavior and UI patterns. We consolidated similar text-based inputs—like Text, Email, Website, and even Text Area—into a single InputField
component.
For fields that use dropdowns, we combined Select and MultiSelect into a unified DropdownField
component. For fields that involve multiple choice inputs, such as Checkbox and Radio fields, we created a consolidated ChoiceListField
component.
Meanwhile, fields with unique layouts or behaviors (like AddressField
, DateField
, TimeField
, and NameField
) were kept as separate components.
To simplify importing these components into our main form, we created a barrel file (index.js
) in the form-fields
folder that re-exports all of them.
Since there are a few, let’s just break down the common patterns they follow:
1. Props Definition
All components define a consistent set of props:
field
: An object containing field metadata (required)
Contains information like databaseId
, label, isRequired
, and field-specific properties
modelValue
: The current value of the field
Type varies based on the field (string, array, object)
Includes appropriate default values
2. Event Handling
Each component emits events to update the parent component’s state:
All components use the update:modelValue
or update:model-value
event for two-way binding.
This follows Vue’s convention for custom v-model implementation
3. Field-Specific Validation
Many components include field-specific validation logic:
- Simple fields may validate on input
- Complex fields (like
EmailField
,AddressField
) have dedicated validation functions
- Error messages are stored in reactive variables and displayed in the template
4. Consistent Template Structure
All components follow a similar template structure:
- A wrapper div with class field-wrapper
- A label displaying the field name and required indicator if needed
Input element(s) with appropriate bindings:
:value
bound to the model value
- Event handlers to emit update events
- Error message displayed when validation fails
5. Complex Field Handling
For complex fields (like Address, Name):
- Data is structured as objects with multiple properties
- Components use appropriate layout techniques (grid, flexbox) to organize multiple inputs
- Updates maintain the overall object structure while changing specific properties
What’s the deal with Errors and Why do we handle them?
What is the deal, Jerry??? Well, the deal is that we handle two types of errors in this app. This would be a great question, the great comedian, Jerry Seinfeld could ask.
Request or Server Errors
These are errors that prevent the form entry from being saved. In our Nuxt implementation with Gravity Forms, we encounter several types of network-related errors:
- Network connectivity issues when the user’s connection drops
- WordPress backend errors (500 Internal Server Error)
- Authentication or permission errors when submitting to protected forms
- GraphQL syntax or schema errors
Our application handles these errors through the try/catch block in the form submission process. When using the submitForm
function from our useGravityForm
composable, we capture server errors and display them prominently to the user with an alert.
Inside the useGravityForm
composable, we format GraphQL errors into a user-friendly message that tells the user that the submission failed on a popup in the browser.
You can test this error handling by disabling your network connection in DevTools and attempting to submit the form. The application will display an error message indicating the network failure.
Field Validation Errors
Our application implements a dual-layer validation approach:
1.Client-Side Validation: Implemented for specific field types to provide immediate feedback
2.Server-Side Validation: Handled by the WordPress Gravity Forms backend
When the server returns validation errors, they’re processed in the handleSubmit
function of our index.vue
component. The application checks response?.errors?.length
to determine if validation errors exist and displays them accordingly.
For client-side validation, certain field types have built-in validation:
Email Field: Validates email format using a regex pattern
Address Field: Validates complete address information and proper postal code formats
Required Fields: All required fields are checked before submission
To test field validation, use the “Short Strings Only” field at the bottom of the form. This field is configured in the Gravity Forms admin to accept a maximum of 5 characters. If you enter more than 5 characters and submit the form, the server will reject the submission and return a validation error.
Unlike some fields that implement client-side validation (like email and address), this text field relies on server-side validation in Gravity Forms. The error message will display after the submission attempt, informing you about the 5-character limit constraint.
This demonstrates how our application strategically combines client-side validation for enhanced user experience with server-side validation for critical business rules and data integrity.
What is Not Included
You can drop these components, and composables and get up and running with Gravity Forms forms quickly in a Nuxt app, but there are features they don’t provide. Some examples:
- Support for Gravity Forms’ Conditional Logic rules
- Rendering an existing Gravity Forms entry and allowing the user to update its field values
- Support for all field types
Conclusion
We hope this blog post helped you understand how to use forms in headless WordPress with Gravity Forms, WPGraphQL for Gravity Forms, and Nuxt!
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!
Special thanks to David Levine and Daniel Roe for helping me write this article and the code!