RG Forms logoRG Forms
React

A React contact form with no backend

The React part of this is not the hard bit. What separates a form that works from one that quietly loses leads is the state handling around it — and getting one header right.

Last updated August 19, 2026

The short answer

A React contact form needs no backend of your own if the submit handler POSTs directly to a hosted endpoint. Send the form data as JSON with Content-Type: text/plain — application/json triggers a CORS preflight that Google Apps Script endpoints cannot answer — and track idle, sending, sent and error states so the UI stays honest. With RG Forms the endpoint is an Apps Script web app in your own Google account, so submissions land in your own Google Sheet and your React app stays a pure static build with no API route to deploy.

The component

Drop-in, no dependencies. Works in Vite, Create React App, Next.js (with 'use client'), Remix or an Astro React island.

ContactForm.tsx
import { useState } from 'react';

const ENDPOINT = import.meta.env.VITE_FORM_ENDPOINT; // or process.env.NEXT_PUBLIC_FORM_ENDPOINT

export default function ContactForm() {
  const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    setState('sending');

    try {
      const res = await fetch(ENDPOINT, {
        method: 'POST',
        headers: { 'Content-Type': 'text/plain' }, // no preflight
        body: JSON.stringify({
          tab: 'contact',
          fields: Object.fromEntries(new FormData(form)),
        }),
      });
      const data = await res.json();
      setState(data.result === 'success' ? 'sent' : 'error');
      if (data.result === 'success') form.reset();
    } catch {
      setState('error');
    }
  }

  if (state === 'sent') return <p>Thanks — we&apos;ll be in touch.</p>;

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input type="email" name="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" required />
      <input type="text" name="_hp" tabIndex={-1} autoComplete="off" aria-hidden="true"
             style={{ position: 'absolute', left: '-9999px' }} />
      <button disabled={state === 'sending'}>
        {state === 'sending' ? 'Sending…' : 'Send'}
      </button>
      {state === 'error' && <p role="alert">Something went wrong. Please try again.</p>}
    </form>
  );
}

The one line people get wrong

headers: { 'Content-Type': 'text/plain' }. Every instinct says application/json, and every AI assistant writes it that way by default. It fails.

Setting application/json makes the request “non-simple”, so the browser sends a preflight OPTIONS request first. Google Apps Script web apps only implement doGet and doPost — there’s no OPTIONS handler — so the preflight fails and the browser blocks the real request before it leaves. You see a CORS error in the console and no request in the Network tab.

text/plain is one of the three content types that skip preflight. The body is still JSON.stringify(...), and the script parses it identically. One header, and the whole class of problem disappears.

As a reusable hook

If you have more than one form — contact, newsletter, quote request — pull the mechanics out. Each form is a tab in the same project, so only tab changes.

hooks/useFormEndpoint.ts
import { useCallback, useState } from 'react';

type Status = 'idle' | 'sending' | 'sent' | 'error';

const ENDPOINT = import.meta.env.VITE_FORM_ENDPOINT;

export function useFormEndpoint(tab: string) {
  const [status, setStatus] = useState<Status>('idle');
  const [error, setError] = useState<string | null>(null);

  const submit = useCallback(
    async (form: HTMLFormElement) => {
      setStatus('sending');
      setError(null);

      try {
        const res = await fetch(ENDPOINT, {
          method: 'POST',
          headers: { 'Content-Type': 'text/plain' },
          body: JSON.stringify({
            tab,
            fields: Object.fromEntries(new FormData(form)),
          }),
        });

        const data = await res.json();

        if (data.result === 'success') {
          form.reset();
          setStatus('sent');
          return true;
        }

        setError(data.error ?? 'Submission failed.');
        setStatus('error');
        return false;
      } catch {
        setError('Network error. Please try again.');
        setStatus('error');
        return false;
      }
    },
    [tab],
  );

  return { status, error, submit, reset: () => setStatus('idle') };
}

// Usage:
//   const { status, error, submit } = useFormEndpoint('contact');
//   <form onSubmit={(e) => { e.preventDefault(); submit(e.currentTarget); }}>

What makes it production-grade

  • Disable the button while sendinga double-click otherwise writes two rows and sends two emails.

  • Handle the network failure separatelya rejected fetch and a { result: "error" } response are different problems and deserve different messages.

  • Always show a fallback contact routein the error state, put a real email address on screen. Never let a failed submission be a dead end.

  • Use uncontrolled inputsFormData reads the DOM directly, so you don’t need useState per field. Less code, fewer re-renders, and the field names double as your payload keys.

  • Keep the honeypot off-screen, not `display: none`some bots skip fields that are explicitly hidden, which defeats the trap.

  • Announce status changesrole="status" with aria-live="polite" so screen-reader users hear the result.

Check the endpoint independently

When a form misbehaves, this tells you instantly whether the problem is your React code or the endpoint.

Test the endpoint from your terminal
curl -L -X POST "https://script.google.com/macros/s/AKfycb.../exec" \
  -H "Content-Type: text/plain" \
  -d '{"tab":"contact","fields":{"name":"Ada","email":"ada@example.com","message":"Hello"}}'

# → {"result":"success"}   and a new row appears in your Google Sheet

Common questions

Does this work with Next.js?

Yes. Add 'use client' at the top of the component and use process.env.NEXT_PUBLIC_FORM_ENDPOINT. The page can stay fully static — see the Vercel guide.

Why not use a Server Action or API route?

You can, but then you own server code plus storage plus an email provider. If the form only needs to reach your inbox and leave a record, this keeps the deployment static and the maintenance at zero.

How do I add file uploads?

This endpoint handles text fields only. For attachments, link to a Google Drive upload form or ask people to email directly.

Can I validate on the server?

The Apps Script is in your own Google account and fully editable, so you can add whatever checks you like before the row is written. Out of the box, required-field enforcement is a frontend concern.

More answers in the full FAQ.

Your endpoint goes live in ~90 seconds