Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 097 / 100

Front-end forms with the REST API: a step-by-step guide

Modern form submissions.

Expona Admin8 min read

Front-end forms with the REST API: a step-by-step guide

Building modern web applications means decoupling your user interface from your server logic. When you stop relying on traditional WordPress form submissions and page reloads, you unlock faster, more responsive experiences. This guide walks you through implementing front-end forms with the REST API: a step-by-step guide to modern form submissions that feel native to single-page applications while maintaining WordPress security and data integrity.

Why REST API forms matter

The classic WordPress form workflow—submit, process, redirect—works, but it feels dated. Users expect instant feedback, real-time validation, and no page refresh. The REST API gives us that capability. Instead of form data traveling through wp-admin/admin-ajax.php or a page reload, we submit asynchronously to clean, versioned endpoints. This approach also separates concerns: your React component doesn't care about WordPress internals, and your PHP handler doesn't need to render HTML.

We've built contact forms, subscription handlers, and product configuration wizards this way in production. The pattern is reliable once you understand the security layer.

Submit via fetch to custom endpoints

Your first step is registering a custom REST endpoint. You'll add this to your plugin or theme's main file:

php
add_action( 'rest_api_init', function() {
	register_rest_route( 'myapp/v1', '/contact', array(
		'methods'             => 'POST',
		'callback'            => 'handle_contact_form',
		'permission_callback' => '__return_true',
	) );
} );

The endpoint lives at /wp-json/myapp/v1/contact. On the front end, you send data like this:

php
const formData = {
	name: document.querySelector( '#name' ).value,
	email: document.querySelector( '#email' ).value,
	message: document.querySelector( '#message' ).value,
};

fetch( '/wp-json/myapp/v1/contact', {
	method: 'POST',
	headers: {
		'Content-Type': 'application/json',
	},
	body: JSON.stringify( formData ),
} )
	.then( response => response.json() )
	.then( data => {
		if ( data.success ) {
			console.log( 'Form submitted successfully' );
		} else {
			console.log( 'Errors:', data.errors );
		}
	} )
	.catch( error => console.error( 'Network error:', error ) );

That's the basic wiring. Your front-end sends JSON, your endpoint receives it, and you get back structured data. No page reload, no hidden form fields, no traditional POST action.

Validate on the server

Never trust client-side validation alone. A savvy user with browser dev tools can bypass any JavaScript check. Your server must validate everything.

php
function handle_contact_form( $request ) {
	$params = $request->get_json_params();

	// Basic validation
	$errors = array();

	if ( empty( $params['name'] ) || strlen( $params['name'] ) < 2 ) {
		$errors['name'] = 'Name must be at least 2 characters.';
	}

	if ( empty( $params['email'] ) || ! is_email( $params['email'] ) ) {
		$errors['email'] = 'Please provide a valid email address.';
	}

	if ( empty( $params['message'] ) || strlen( $params['message'] ) < 10 ) {
		$errors['message'] = 'Message must be at least 10 characters.';
	}

	// If there are errors, return them immediately
	if ( ! empty( $errors ) ) {
		return new WP_REST_Response(
			array(
				'success' => false,
				'errors'  => $errors,
			),
			400
		);
	}

	// Sanitize before storage
	$clean_data = array(
		'name'    => sanitize_text_field( $params['name'] ),
		'email'   => sanitize_email( $params['email'] ),
		'message' => sanitize_textarea_field( $params['message'] ),
	);

	// Process the form (save to DB, send email, etc.)
	// ...

	return new WP_REST_Response(
		array(
			'success' => true,
			'message' => 'Thank you for your submission.',
		),
		200
	);
}

This handler validates each field, returns validation errors with specific messages, and only sanitizes clean data. The HTTP status code (400 for bad input, 200 for success) signals to the client what happened. We've found that returning validation errors field-by-field is far better than a generic "form invalid" message.

Return clear error messages

Users hate mystery. When validation fails, they need to know exactly what went wrong and where. Structure your error response so the front end can map messages back to form fields:

php
if ( ! empty( $errors ) ) {
	return new WP_REST_Response(
		array(
			'success' => false,
			'errors'  => $errors, // keyed by field name
			'message' => 'Please correct the errors below.',
		),
		400
	);
}

On the front end, loop through errors and display them next to the relevant inputs:

php
fetch( '/wp-json/myapp/v1/contact', { /* ... */ } )
	.then( response => response.json() )
	.then( data => {
		if ( ! data.success ) {
			// Clear previous errors
			document.querySelectorAll( '.error-message' ).forEach( el => el.remove() );

			// Add new ones
			Object.keys( data.errors ).forEach( fieldName => {
				const input = document.querySelector( `[name="${fieldName}"]` );
				if ( input ) {
					const errorEl = document.createElement( 'span' );
					errorEl.className = 'error-message';
					errorEl.textContent = data.errors[ fieldName ];
					input.parentNode.appendChild( errorEl );
				}
			} );
		} else {
			// Success handling
			resetForm();
			showSuccessMessage( data.message );
		}
	} );

This pattern—associative error arrays tied to field names—is standard in modern form handling. It scales well as forms grow and keeps the UX tight.

Protect with nonces and rate limits

Here's where security becomes critical. Without nonces, anyone from any domain can POST to your endpoint. Without rate limiting, a bot can hammer it endlessly. Let's fix both.

Nonces first: WordPress nonces are security tokens bound to a user and action. Pass one from your page to the form, then verify it on the endpoint:

php
// In your page template or via wp_localize_script
wp_localize_script( 'my-form-script', 'formConfig', array(
	'nonce' => wp_create_nonce( 'contact_form_nonce' ),
	'api_url' => rest_url( 'myapp/v1/contact' ),
) );

Pass the nonce from the client:

php
fetch( formConfig.api_url, {
	method: 'POST',
	headers: {
		'Content-Type': 'application/json',
		'X-WP-Nonce': formConfig.nonce,
	},
	body: JSON.stringify( formData ),
} );

And verify it server-side by adding a permission callback:

php
register_rest_route( 'myapp/v1', '/contact', array(
	'methods'             => 'POST',
	'callback'            => 'handle_contact_form',
	'permission_callback' => function() {
		return wp_verify_nonce( $_SERVER['HTTP_X_WP_NONCE'] ?? '', 'contact_form_nonce' );
	},
) );

Now add rate limiting to prevent abuse. Store submission attempts in transients:

php
function check_rate_limit( $identifier, $limit = 5, $window = 3600 ) {
	$key = 'contact_form_' . $identifier;
	$count = get_transient( $key ) ?? 0;

	if ( $count >= $limit ) {
		return false;
	}

	set_transient( $key, $count + 1, $window );
	return true;
}

function handle_contact_form( $request ) {
	// ...
	$ip = $_SERVER['REMOTE_ADDR'];

	if ( ! check_rate_limit( $ip, 5, 3600 ) ) {
		return new WP_REST_Response(
			array(
				'success' => false,
				'message' => 'Too many submissions. Please try again later.',
			),
			429 // Too Many Requests
		);
	}
	// ...
}

This caps submissions to 5 per hour per IP. In production, you might track by user ID for logged-in users or use a more sophisticated service like Cloudflare rate limiting.

Common mistakes

  • Skipping server-side validation: Relying only on front-end checks invites data corruption and security issues. Validate everything server-side.
  • Returning generic error messages: "Form error" tells users nothing. Be specific: "Email must be valid" is better.
  • Forgetting to sanitize input: Just because data passes validation doesn't mean it's safe to store. Use WordPress sanitization functions.
  • No nonce protection: Custom endpoints without nonces are open to CSRF attacks. Always verify the nonce.
  • Ignoring rate limits: Without them, your endpoint becomes a spam vector. Even a modest limit (5-10 per hour per IP) helps.
  • Hardcoding the API URL: Use rest_url() and wp_localize_script() so the URL adapts to your WordPress installation, subdirectories and all.

Key takeaways

  • Register custom REST endpoints with register_rest_route() and submit forms via fetch instead of traditional page reloads.
  • Validate all input server-side with clear, field-specific error messages that map back to form inputs.
  • Protect endpoints with nonces to prevent CSRF and rate limits to prevent abuse.
  • Always sanitize input before storage, even after validation passes.
  • Return structured, predictable JSON responses so your front end knows exactly what to do next.
  • Use HTTP status codes correctly: 200 for success, 400 for validation errors, 429 for rate limits.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles