back to writing
August 5, 2026

TIL: you can build a native WordPress contact form without a plugin

When Claude and I were working on building this WordPress site, I was really pleasantly surprised when it made the decision to build the content form using native WordPress functionality...

When Claude and I were working on building this WordPress site, I was really pleasantly surprised when it made the decision to build the content form using native WordPress functionality rather than using a plugin. I didn’t know that was possible! I always love when there’s an option to choose built-in features in WordPress, so this made me (nerdily) happy. I wrote about it back in my post on building my site with Claude.

Recently I learned that there is more than one way to build a form in WordPress when I came across a post by Chandra Patel on handling forms with admin-post.php, where he too shared that he was surprised by this capability. The approach he described works differently from my admin-ajax.php form, so I did some Claude-assisted research to learn more about this, and the pros and cons of each approach. During my research I learned that there’s even a third REST API option too!

So here’s the breakdown of what each approach offers:

How my contact form works

My form uses WordPress’ AJAX system. When you fill it in and hit send, a bit of JavaScript catches the submission before the page can reload. It quietly bundles up your name, email, and message and sends them to a WordPress file called admin-ajax.php, along with a security token called a nonce that proves the request came from my actual form and not a bot hitting the URL directly.

On the server, WordPress runs a small function I have in my theme. It checks the nonce, cleans up the submitted data, makes sure the email address is real, and then sends the message to me using WordPress’s built-in wp_mail() function. It sends back a simple yes or no. If it’s a yes, the page shows “message sent” right there, without reloading which gives a smooth user experience which I like.

The other way: admin-post.php

The approach in Chandra’s post skips the JavaScript. The form posts the old-fashioned way, straight to a WordPress file called admin-post.php. WordPress looks at a hidden field in the form to figure out which handler function should run (through a hook named admin_post_{$action}), checks the nonce, sends the email, and then redirects you to a thank-you page or back to the form with a success message.

The main difference here is the page reload. My version updates in place. This version does a full round trip: submit, process, redirect, new page. It’s the classic way web forms have worked, and has the advantage of not needing Javascript.

Where each one wins

The AJAX approach that Claude built me offers a nicer, smoother experience: nothing flashes or reloads, and the reply appears instantly. That’s why it’s so common on modern sites. The trade-off is that it leans on JavaScript. If someone has JavaScript turned off, or a script fails to load, my form quietly does nothing, which isn’t great, but for my tiny personal site, it’s not the end of the world.

Because the admin-post.php approach doesn’t need Javascript, it’s more sturdy. It also handles the “did you want to resubmit this form?” browser warning cleanly, because it redirects you to a fresh page after sending. The cost is the reload, and showing a success message is a bit more fiddly, since you have to pass that message along through the redirect instead of just handing it back on the spot.

I’ll just elaborate a bit on what the resubmission warning is about: with a plain form post, the page you land on is the direct response to your submission. The browser remembers that the page came from a POST. So if you refresh it, or hit the back button to return to it, the browser has to send your form data all over again to rebuild the page. It can’t tell whether doing that is safe, so it stops and asks you first. For example, in Chrome the dialog says something like “Confirm form resubmission.” On a contact form, clicking through it could fire off your message a second time. On a checkout page it could mean getting charged twice, which is why browsers are so careful about it.

The redirect sidesteps this: after admin-post.php processes the form, it doesn’t leave you sitting on the POST response. It sends you on to a normal page loaded with a regular GET request, and that’s the page you actually see. Refresh it and the browser just re-runs a harmless GET. Hit back and you land on a GET page too. The resubmission dialog never comes up. This is an old, well-worn pattern with a name: Post, Redirect, Get, usually shortened to PRG.

Note that this only applies to plain form posts, the kind that reload the page. A form that submits in the background with JavaScript never loads a new page from the POST, so there’s no POST response sitting in your history to resend, and the warning has nothing to trigger it. If you build a form the classic way without the redirect, though, the resubmission prompt is the thing you’ll run into, and PRG is the standard fix.

Neither approach is more “correct.” They’re two built-in tools for slightly different jobs. If you want a slick, no-reload experience and you’re comfortable relying on JavaScript, AJAX fits. If you want a form that keeps working with JavaScript switched off, or you’re building something more elaborate like a settings page, admin-post.php fits.

Restricting who can submit

There’s a flip side to the logged-in question. Both admin-ajax.php and admin-post.php give you two hooks: one that fires for logged-in users and one for logged-out visitors. My contact form registers both, because I want anyone to be able to reach me. If you leave off the logged-out hook, though, only logged-in users can submit. That’s handy for a membership site, where a form should only work for people who are signed in.

One thing to know if you build it that way: being logged in isn’t the same as being allowed. The hooks only check that someone is signed in, not what they’re permitted to do, so by default any logged-in user, right down to a basic subscriber, could submit. If you want to limit a form to certain roles, say editors and above, you add a current_user_can() check inside the handler. The REST API has its own version of this in the permission_callback, which is where you decide who gets through.

One practical wrinkle with admin-post.php on a public page: some security plugins are wary of it. The file lives inside /wp-admin/, the admin area, and many security tools treat any request to wp-admin from a logged-out visitor as suspicious, because that’s what a lot of attacks look like. They might rate-limit it, show a challenge, or block it outright. admin-ajax.php sits in the same folder, but it’s been the standard front-end AJAX endpoint for so long that security plugins almost always leave it alone. Front-end use of admin-post.php is less common, so it’s more likely to trip a rule. It’s not a reason to avoid it, just something to test if you pair a public admin-post.php form with a security plugin.

Turns out there’s a third way you can do this too!

Once I started digging, I found a newer option which WordPress core has been pointing people toward for years: a custom REST API endpoint.

With the REST API approach, instead of sending the form data to the shared admin-ajax.php file, you give your form its own dedicated address, something like /wp-json/miriam/v1/contact. You set that address up in your theme or plugin with a function called register_rest_route, and you tell WordPress which function should run when someone posts to it. The JavaScript on the page then sends the message there instead.

From the visitor’s side it feels the same as my current form: fill it in, hit send, get a reply in place with no reload. The difference is under the hood. A REST endpoint is purpose-built for exactly this kind of request, so it doesn’t load the whole admin system on every submission the way admin-ajax.php does. It also comes with tidy, built-in spots to clean up the incoming data, check the nonce, and decide who’s allowed to submit, instead of writing all of that by hand. If I ever wanted something other than my contact page to talk to the same endpoint, say a mobile app or another site, REST would already be set up for it.

It takes a little more setup to register the route in the first place (but is that really a factor when you’re building things with AI?), and it still relies on JavaScript for the no-reload experience. Having said that, if I were building the form today I’d definitely build it this way since it’s a cleaner foundation, and it’s likely I’ll switch over to this at some point down the line.

The three contact form options, side by side in code

Here’s the structure of what runs on my site now, trimmed down so it’s easy to follow.

// Register the handler for the "ms_contact" action.
add_action( 'wp_ajax_ms_contact',        'ms_handle_contact' ); // logged-in users
add_action( 'wp_ajax_nopriv_ms_contact', 'ms_handle_contact' ); // visitors

function ms_handle_contact() {
    // 1. Verify the nonce (the security token sent with the form).
    if ( ! wp_verify_nonce( $_POST['nonce'] ?? '', 'ms_contact_nonce' ) ) {
        wp_send_json_error( 'Security check failed.' );
    }

    // 2. Clean up the incoming data.
    $name    = sanitize_text_field( $_POST['ms_name'] ?? '' );
    $email   = sanitize_email( $_POST['ms_email'] ?? '' );
    $message = sanitize_textarea_field( $_POST['ms_message'] ?? '' );

    // 3. Make sure the required fields are there and the email is real.
    if ( empty( $name ) || ! is_email( $email ) || empty( $message ) ) {
        wp_send_json_error( 'Please fill in every field with a valid email.' );
    }

    // 4. Send it, and report back yes or no.
    $sent = wp_mail( get_option( 'admin_email' ), "Contact from {$name}", $message );
    $sent ? wp_send_json_success() : wp_send_json_error( 'Could not send.' );
}

Chandra’s admin-post.php version handles the same four steps, but it hooks onto different actions and, instead of returning JSON, it redirects when it’s done. That redirect is the Post, Redirect, Get pattern from earlier, which is what keeps the resubmission warning from ever showing up.

// Register the handler for the "ms_contact" action on admin-post.php.
add_action( 'admin_post_ms_contact',        'ms_handle_contact_post' ); // logged-in users
add_action( 'admin_post_nopriv_ms_contact', 'ms_handle_contact_post' ); // visitors

function ms_handle_contact_post() {
    // 1. Verify the nonce (submitted as a hidden field in the form).
    if ( ! wp_verify_nonce( $_POST['ms_contact_nonce'] ?? '', 'ms_contact_action' ) ) {
        wp_die( 'Security check failed.' );
    }

    // 2. Clean up the incoming data.
    $name    = sanitize_text_field( $_POST['ms_name'] ?? '' );
    $email   = sanitize_email( $_POST['ms_email'] ?? '' );
    $message = sanitize_textarea_field( $_POST['ms_message'] ?? '' );

    // 3. Make sure the required fields are there and the email is real.
    if ( empty( $name ) || ! is_email( $email ) || empty( $message ) ) {
        wp_safe_redirect( home_url( '/contact/?sent=invalid' ) );
        exit;
    }

    // 4. Send it, then redirect to a fresh page so a refresh can't resend it.
    $sent = wp_mail( get_option( 'admin_email' ), "Contact from {$name}", $message );
    wp_safe_redirect( home_url( $sent ? '/contact/?sent=1' : '/contact/?sent=error' ) );
    exit;
}

The steps in the middle are the same as before. What’s different is the ends: it hooks onto admin_post_* instead of wp_ajax_*, and it finishes with wp_safe_redirect() and exit rather than sending back JSON. Whatever page you redirect to then reads the ?sent= value in the URL and shows the right message.

And here’s the same idea as a REST endpoint. The middle three steps are almost identical. What changes is the setup around them: you register a dedicated route, and you hand back proper responses instead of the AJAX-only helpers.

// Register a dedicated endpoint: /wp-json/ms/v1/contact
add_action( 'rest_api_init', function () {
    register_rest_route( 'ms/v1', '/contact', [
        'methods'             => 'POST',
        'callback'            => 'ms_handle_contact_rest',
        'permission_callback' => '__return_true', // public form: anyone may submit
    ] );
} );

function ms_handle_contact_rest( WP_REST_Request $request ) {
    // 1. Verify the nonce (sent as an X-WP-Nonce header from the page).
    if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) {
        return new WP_Error( 'bad_nonce', 'Security check failed.', [ 'status' => 403 ] );
    }

    // 2. Clean up the incoming data.
    $name    = sanitize_text_field( $request['ms_name'] ?? '' );
    $email   = sanitize_email( $request['ms_email'] ?? '' );
    $message = sanitize_textarea_field( $request['ms_message'] ?? '' );

    // 3. Make sure the required fields are there and the email is real.
    if ( empty( $name ) || ! is_email( $email ) || empty( $message ) ) {
        return new WP_Error( 'invalid', 'Please fill in every field with a valid email.', [ 'status' => 400 ] );
    }

    // 4. Send it, and report back yes or no.
    $sent = wp_mail( get_option( 'admin_email' ), "Contact from {$name}", $message );
    return $sent
        ? new WP_REST_Response( [ 'sent' => true ], 200 )
        : new WP_Error( 'mail_failed', 'Could not send.', [ 'status' => 500 ] );
}

Same nonce check, same three sanitizing calls, same is_email() guard, same wp_mail() across all three. If you understand one, you understand the others. The security work doesn’t get harder or easier as you move between these approaches, because it lives in what the handler does, not in which door the request came through.

The front ends differ more than the handlers do. The admin-post.php version is the odd one out: it uses no JavaScript at all. It’s a plain HTML form that posts straight to admin-post.php, with the action and the nonce carried along as hidden fields:

<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
  <input type="hidden" name="action" value="ms_contact">
  <?php wp_nonce_field( 'ms_contact_action', 'ms_contact_nonce' ); ?>

  <input type="text" name="ms_name" required>
  <input type="email" name="ms_email" required>
  <textarea name="ms_message" required></textarea>
  <button type="submit">Send</button>
</form>

The two JavaScript-based approaches barely differ from each other. Here’s the part of my current script that sends the form to admin-ajax.php:

var data = new FormData( form );
data.append( 'action', 'ms_contact' );   // tells admin-ajax.php which handler to run
data.append( 'nonce', msData.nonce );    // the security token, sent in the body

fetch( msData.ajaxUrl, {                  // the admin-ajax.php URL
  method: 'POST',
  body: data,
  credentials: 'same-origin'
} )
  .then( function ( res ) { return res.json(); } )
  .then( function ( res ) { /* show success or error in place */ } );

For the REST version, you point fetch at the dedicated route and move the nonce into a header. The action field goes away, because the route itself already says which handler runs:

var data = new FormData( form );          // no "action" field needed

fetch( '/wp-json/ms/v1/contact', {        // the dedicated route
  method: 'POST',
  body: data,
  credentials: 'same-origin',
  headers: { 'X-WP-Nonce': msData.nonce } // the nonce moves to a header
} )
  .then( function ( res ) { return res.json(); } )
  .then( function ( res ) { /* show success or error in place */ } );

The one thing to note is the nonce. For REST you create it in PHP with wp_create_nonce( 'wp_rest' ) and pass it to the page (the same wp_localize_script step I already use), so the token in the header matches the 'wp_rest' check in the handler.

We can’t ignore form spam protection

The above snippets don’t prevent form spam out of the box. It’s worth adding some spam protection, and one good option is a honeypot: a hidden field that real people never see and never fill in, so any submission that has it filled gets thrown away. It’s a few lines, no plugin, and it catches a lot. You can also add a simple time check to see how quickly a form was submitted: a form submitted half a second after loading is almost certainly a bot. And/or you can add rate limiting if the volume warrants it.

WordPress never stops surprising

I’ve been working with WordPress for over 20 years and I seriously had no idea it had all these built-in ways to manage forms. I knew about some of the obvious functions, like wp-mail, but I had never even thought to tackle forms without a plugin. In cases where forms are being changed regularly, or they have more complex needs, a plugin is probably the right approach. But for set-it-and-forget-it types of forms, why bring a plugin into the mix when you don’t have to?

I love that my contact form is just a handful of lines in my theme, with no dependency on anyone else’s code sitting in between.

Where to read more

filed under tagged

Other posts

all posts
The Monthly Routine That Actively Improves My Site Health
I don’t only want my site to be agent-ready; I also want it to be generally healthy and optimized for the web overall. So I have a monthly process running...
I wanted to be able to do more with AI on my site, so I gave Claude WP REST API access – with limitations
A conversation about why WordPress core still ships so few AI abilities and the limitations they have got me thinking: why am I making my site fully dependent on Abilities...