Skip to main content

Code

A WordPress Contact Form Without a Plugin (Full Code, Plus the Catch)

Yes, you can build a WordPress contact form without a plugin — I've done it in ~80 lines. Here's the full working code, and the five things it won't do.

Yes, you can build a WordPress contact form without a plugin. It takes roughly 80 lines: an HTML form, an admin-post.php handler, a nonce, a honeypot, and wp_mail(). I’ve shipped this exact pattern on client sites since 2009, back when it was the only option.

The full code is below, and it works. Copy it, adjust three names, done.

But there’s a catch, and it’s not the code. It’s everything the code doesn’t do. I’ll give you the working form first and the honest ledger after.

The direct answer

A WordPress contact form without a plugin needs two pieces: a form in your template that posts to admin_url( 'admin-post.php' ), and a PHP handler hooked to admin_post_{action} and admin_post_nopriv_{action}. The handler verifies a nonce, checks a honeypot, sanitizes the input, sends the email with wp_mail(), and redirects back with a query flag.

That’s the whole architecture. No shortcode, no builder, no settings screen.

The HTML form

Drop this into your contact page template (or a template part). Three visible fields, one hidden action field, one nonce, one honeypot.

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

	<!-- Honeypot: hidden from humans, irresistible to bots. -->
	<div style="position:absolute;left:-9999px;" aria-hidden="true">
		<label for="cf-website">Leave this empty</label>
		<input type="text" id="cf-website" name="cf_website"
			tabindex="-1" autocomplete="off">
	</div>

	<p>
		<label for="cf-name">Name</label>
		<input type="text" id="cf-name" name="cf_name" required>
	</p>
	<p>
		<label for="cf-email">Email</label>
		<input type="email" id="cf-email" name="cf_email" required>
	</p>
	<p>
		<label for="cf-message">Message</label>
		<textarea id="cf-message" name="cf_message" rows="6" required></textarea>
	</p>
	<p><button type="submit">Send message</button></p>
</form>

The hidden action field is what routes the POST to your handler. The nonce comes from wp_nonce_field(), which prints a hidden token the handler verifies before touching anything else. The honeypot is a field no human sees; bots auto-fill everything, so a filled honeypot means spam.

The handler

This goes in functions.php, or better, a small mu-plugin so it survives theme switches. Both lines hang off the admin_post_{action} hook, and the nopriv variant matters — without it, logged-out visitors (all of them) get a dead form.

add_action( 'admin_post_site_contact_form', 'site_handle_contact_form' );
add_action( 'admin_post_nopriv_site_contact_form', 'site_handle_contact_form' );

function site_handle_contact_form() {
	// 1. Nonce check.
	if ( ! isset( $_POST['site_contact_nonce'] )
		|| ! wp_verify_nonce( $_POST['site_contact_nonce'], 'site_contact_form' ) ) {
		site_contact_redirect( 'error' );
	}

	// 2. Honeypot: filled means bot. Pretend success, send nothing.
	if ( ! empty( $_POST['cf_website'] ) ) {
		site_contact_redirect( 'sent' );
	}

	// 3. Sanitize.
	$name    = sanitize_text_field( wp_unslash( $_POST['cf_name'] ?? '' ) );
	$email   = sanitize_email( wp_unslash( $_POST['cf_email'] ?? '' ) );
	$message = sanitize_textarea_field( wp_unslash( $_POST['cf_message'] ?? '' ) );

	if ( '' === $name || ! is_email( $email ) || '' === $message ) {
		site_contact_redirect( 'error' );
	}

	// 4. Send. Reply-To lets you hit "reply" in your inbox.
	$sent = wp_mail(
		get_option( 'admin_email' ),
		'Contact form: ' . $name,
		$message . "\n\n— " . $name . ' <' . $email . '>',
		array( 'Reply-To: ' . $name . ' <' . $email . '>' )
	);

	site_contact_redirect( $sent ? 'sent' : 'error' );
}

function site_contact_redirect( $status ) {
	$back = wp_get_referer() ? wp_get_referer() : home_url( '/contact/' );
	wp_safe_redirect( add_query_arg( 'contact', $status, $back ) );
	exit;
}

Two details people get wrong. First, always exit after wp_safe_redirect() — WordPress doesn’t do it for you. Second, send the email to get_option( 'admin_email' ) and put the visitor’s address in Reply-To, not in From. Spoofing From is the fastest route to the spam folder.

The success and error notice

The redirect appends ?contact=sent or ?contact=error to the URL. Read it back in the template, above the form:

<?php if ( isset( $_GET['contact'] ) ) : ?>
	<?php if ( 'sent' === $_GET['contact'] ) : ?>
		<p class="form-notice is-success">Thanks your message is on its way.</p>
	<?php else : ?>
		<p class="form-notice is-error">Something went wrong. Please try again.</p>
	<?php endif; ?>
<?php endif; ?>

That’s the whole build. On a quiet afternoon, 30 minutes including testing.

What this code doesn’t do

Here’s the catch. This is the honest ledger, from years of maintaining exactly this pattern on client sites.

No storage — a lost email is a lost lead. wp_mail() fires and forgets. If the email bounces, lands in spam, or your host’s mail queue chokes, the submission is gone and nobody knows. There’s no submissions inbox to check against. I’ve written before about why every submission should hit the database first — this code skips that entirely.

Honeypot-only spam defense. A honeypot stops dumb bots, which is most bots. It does nothing against humans on spam farms or smarter scripts that parse labels. Real spam protection is layered: honeypot plus rate limiting plus a challenge when needed. This code has one layer.

No deliverability help. No email log, no SMTP configuration, no “did it actually send” visibility. wp_mail() returning true means PHP handed the message off, not that it arrived.

No file uploads. Adding uploads means MIME validation, size limits, and storage decisions — roughly triple the code, and the part most DIY handlers get dangerously wrong.

You maintain it forever. Every WordPress hardening change, every PHP version bump, every new spam wave — it’s your code, so it’s your Tuesday evening.

When a WordPress contact form without a plugin is the right call

Three cases where I’d still write this by hand:

A single brochure site you fully control. One form, low stakes, you read the inbox daily. If a message slips through the cracks once a year, nobody’s business dies.

A hard plugin budget. Some agencies cap the plugin count per site for maintenance reasons. If forms are the thing that breaks the cap, 80 lines of your own code is a fair trade.

A learning exercise. Building a form handler by hand teaches you nonces, sanitization, and the admin-post.php flow better than any tutorial. Every WordPress developer should do it once.

When a lightweight plugin wins

The moment the form matters to revenue, the ledger flips. Stored submissions, layered spam defense, email logs, and integrations aren’t features you bolt onto 80 lines — they’re a second, much larger codebase you’d be writing alone.

My rule after a hundred-plus client forms: hand-code the form when losing a message costs nothing; use a plugin when it costs a lead. If you like the hand-coded workflow — HTML you write yourself, no drag-and-drop — that workflow exists inside a plugin too. It’s the approach I’ve argued for for years: keep the markup, delegate the plumbing.

FAQ

Is it safe to build a WordPress form without a plugin?

Yes, if you do what the code above does: verify a nonce, sanitize every input with sanitize_text_field(), sanitize_email(), and sanitize_textarea_field(), and never echo raw POST data. The pattern itself is standard WordPress. The risk isn’t the first version — it’s the shortcut you take in an edit six months later.

Why use admin-post.php instead of processing in the template?

Processing in the template runs after headers are queued on some setups, breaks redirects, and mixes logic into presentation. admin-post.php is WordPress’s built-in endpoint for form handling: it runs early, gives you the admin_post_ and admin_post_nopriv_ hooks, and keeps the handler out of your theme.

Does this HTML contact form work with any WordPress theme?

Yes. The form is plain HTML in a template, and the handler hooks into WordPress core, not the theme. Put the handler in an mu-plugin and it survives theme switches untouched. Only the form markup itself lives in the theme, and that moves in one copy-paste.

Why is my hand-coded form’s email not arriving?

Usually deliverability, not your code. wp_mail() sends through PHP mail by default, which many hosts flag as spam. Check the spam folder first, then configure SMTP at the server or site level. This is the “no deliverability layer” cost — without an email log, you’re diagnosing blind.

Build the form. Stop reading.

Every note here came out of a real Core Forms setup. Use CFLAUNCH for 20% off either plan.