← Back to Blog
Guide forms react nextjs contact form

How to Build a React Contact Form Without a Backend

A complete React contact form with validation, loading and error states, and no server to deploy. Plus the one line that trips everyone up in the App Router.

JW

Jason Warner

July 26, 2026

How to Build a React Contact Form Without a Backend

React is great at collecting form input and offers precisely nothing for receiving it. onSubmit fires, you've got an object in memory, and now you need somewhere to send it — which has traditionally meant an API route, a serverless function, an email provider, and credentials to keep out of your bundle.

That's a lot of machinery for a Contact Us page. Here's the version without any of it.

The Whole Thing

import { useState } from 'react';

const ENDPOINT = 'https://api.bluejayrelay.com/f/frm_yourtoken';

export default function ContactForm() {
  const [status, setStatus] = useState('idle'); // idle | sending | sent | error

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('sending');

    const data = new FormData(e.target);

    try {
      const res = await fetch(ENDPOINT, {
        method: 'POST',
        body: data,
        headers: { Accept: 'application/json' },
      });
      setStatus(res.ok ? 'sent' : 'error');
      if (res.ok) e.target.reset();
    } catch {
      setStatus('error');
    }
  }

  if (status === 'sent') {
    return <p role="status">Thanks — I'll get back to you shortly.</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />

      {/* honeypot — nobody real ever sees this */}
      <input
        name="_gotcha"
        tabIndex={-1}
        autoComplete="off"
        style={{ position: 'absolute', left: '-9999px' }}
        aria-hidden="true"
      />

      <button disabled={status === 'sending'}>
        {status === 'sending' ? 'Sending…' : 'Send'}
      </button>

      {status === 'error' && (
        <p role="alert">Something went wrong. Please try again.</p>
      )}
    </form>
  );
}

That's a real contact form you can ship. No API route, no mail credentials, no server.

The Bits Worth Explaining

Use FormData rather than a useState per field. Wiring up individual state hooks is how React contact forms get to 120 lines, and new FormData(e.target) just reads every named input at submit time — so adding a phone number field is one line of markup and zero lines of JavaScript.

Send Accept: application/json. Leave it off and the endpoint assumes a classic browser POST and replies with a redirect, which is not what you want when you're managing your own UI.

Don't JSON.stringify the FormData. You'll get {} and spend twenty minutes confused. Pass the object straight to fetch and let the browser handle the encoding.

Keep all four states. idle, sending, sent, error covers everything someone needs to know. The bug I see most often here is forgetting to disable the button during sending, which lets an impatient visitor submit three times and lands you three identical emails.

On the honeypot: position it off-screen rather than using display: none. Some bots specifically skip hidden fields, so off-screen with aria-hidden and tabIndex={-1} keeps it away from sighted users and screen readers while still catching things.

In the Next.js App Router

Same component, one extra line at the top:

'use client';

import { useState } from 'react';
// …everything else identical

Without it you get an error about event handlers not being passable to client component props, which is Next.js's way of saying you forgot. You can do this with a server action instead — but then you're writing server code and handling email delivery yourself, which is the thing we were avoiding.

What Happens After the POST

The submission gets stored and emailed to you, and shows up in a dashboard. You also get an autoresponder if you want one, so the person who wrote in gets an immediate confirmation instead of wondering whether the form worked. There's a daily or weekly digest for when per-submission email is too noisy, spam filtering that starts at the honeypot above and escalates from there, origin restrictions so only your domain can post, and CSV export when you want the data elsewhere.

Client-side validation is still yours, and still worth doing — it's what makes the form pleasant to use. Native HTML validation (required, type="email", minLength) handles most contact forms with no JavaScript at all, and React Hook Form or Zod earn their place once you have conditional fields. Just keep the two things separate in your head: validation is UX, the endpoint is delivery.

A Few Things People Ask

Isn't the endpoint URL exposed in my bundle? Yes, and that's fine — it's a public submission URL, not a secret. You lock it down with an origin allowlist and spam filtering, not by hiding it. Restricting submissions by origin covers that.

Do I need to configure CORS? The endpoint handles it. If you've set an origin allowlist, remember to add your preview and staging domains — that's the one everyone forgets, and it fails only in preview.

Can I upload files? Add <input type="file" name="resume" /> and FormData picks it up automatically. There's more in accepting file uploads from a form.


Ship a working contact form this afternoon. Start free with Bluejay Forms.

#forms #react #nextjs #contact form

Ready to collect submissions?

Point your form at Bluejay and get storage, spam filtering, and email notifications.

Start collecting free