splitforms.com
All articles/ TUTORIALS10 MIN READPublished May 10, 2026

How to Add a Contact Form to a Vite + React App 2026

Add a real contact form to your Vite + React SPA in 2026 — controlled inputs, no Express backend, validation, and reliable email delivery in minutes.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Vite + React is an SPA — there is no backend

The first thing to internalize about a Vite + React project: when you run vite build, the output is a folder of static files. HTML, JS bundles, a few hashed assets. That's it. There is no Node process. There is no req object. There is no app.post("/contact", ...). Whatever you deploy to Cloudflare Pages, Vercel, Netlify, S3, or GitHub Pages is just a CDN serving files.

This is great for performance and cost — your site is free to host on most platforms — but it creates one obvious problem: where does the contact form POST to? Browsers can't send email. There's no sendmail() in JavaScript. The traditional answer is to spin up an Express server with Nodemailer, deploy it to a Render or Railway VM, configure environment variables for an SMTP relay, set up CORS headers, write a rate limiter, add spam filtering, and pay $7-$25/month forever for a 20-line endpoint that exists only to forward a name and message to your inbox.

That's 200 lines of operational overhead for a contact form. The alternative is a form-to-email API like splitforms: you POST to a hosted endpoint with an access key, it sends the email. No server. No deployment. Free for 1,000 submissions a month. This guide builds a complete contact form for Vite + React using exactly that pattern, with controlled inputs, validation, spam protection, and Tailwind styling.

Most people who pick Vite for a React project pick it specifically because the deploy story is "upload a folder." The moment you add an Express server back into the mix, you've given up everything Vite gave you — instant cold starts, zero runtime config, hosting that costs literally nothing under a typical traffic budget. A hosted form endpoint preserves all of that. Your CDN serves the HTML, the user's browser POSTs directly to splitforms.com, and your own infrastructure never enters the request path. From a security perspective that's also a win: there's nothing for you to patch, no SMTP credentials sitting on a Render box, no rate limiter to write, no logging stack to maintain.

The mental shift is treating the contact form as a third-party service call, the same way you'd call Stripe Checkout or Mux for video. You don't self-host video transcoding. You don't self-host card processing. You don't need to self-host a 20-line email forwarder either. Once you accept that, the integration shrinks to one fetch call and one hidden input.

Step 1: Get a splitforms access key (1 minute)

Before writing any React code, grab the credential you'll need:

  1. Go to splitforms.com/login
  2. Enter your email — you'll get a 6-digit code
  3. Paste the code, land in the dashboard
  4. Copy the auto-generated access key (looks like sf_live_abc123...)

The free tier is 1,000 submissions/month — enough for nearly every portfolio, agency, or SaaS marketing site. No credit card required. The key is safe to expose in client-side code as long as you lock it to your production domain via the Allowed Domains setting in the dashboard. We'll wire that up later. For now, paste it somewhere you can copy from in a minute.

One thing to note about the dashboard: each access key is scoped to a single form, with its own submissions inbox, its own webhook config, and its own allow-list. If you have a marketing site plus a separate support form, create two keys — that way the submissions stay sorted, and revoking one doesn't affect the other. You can swap keys at any time without redeploying by changing the env var and rebuilding.

Step 2: Store the key in .env (Vite-specific)

Vite uses a strict convention for environment variables: only variables prefixed with VITE_ are exposed to client code. This is intentional — it prevents you from leaking DATABASE_URL or STRIPE_SECRET into your JS bundle by accident. Anything else gets stripped at build time.

Create a .env file in your project root:

# .env (in project root, next to vite.config.ts)
VITE_SPLITFORMS_ACCESS_KEY=sf_live_abc123yourkeyhere

Add .env to your .gitignore if it isn't already, then create a .env.example for collaborators:

# .env.example (committed to git)
VITE_SPLITFORMS_ACCESS_KEY=your_access_key_here

In your React code, you read it with import.meta.env — NOT process.env, which doesn't exist in the browser:

const ACCESS_KEY = import.meta.env.VITE_SPLITFORMS_ACCESS_KEY;

Restart the Vite dev server after adding the variable — Vite reads .env at startup and doesn't hot-reload it. If you're using TypeScript, add the type to src/vite-env.d.ts for autocomplete:

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_SPLITFORMS_ACCESS_KEY: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Step 3: Build the <ContactForm /> component

Here's the complete component using vanilla useState. Drop this in src/components/ContactForm.tsx. No external dependencies, no validation library, no fetch wrapper — just React and the platform.

// src/components/ContactForm.tsx
import { useState, FormEvent } from "react";

const ACCESS_KEY = import.meta.env.VITE_SPLITFORMS_ACCESS_KEY;

type Status = "idle" | "submitting" | "success" | "error";

export function ContactForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState("");
  const [botcheck, setBotcheck] = useState(""); // honeypot
  const [status, setStatus] = useState<Status>("idle");
  const [errorMsg, setErrorMsg] = useState("");

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();

    // Honeypot — bots fill hidden fields, humans don't
    if (botcheck) return;

    // Lightweight client-side validation
    if (!name.trim() || !email.trim() || !message.trim()) {
      setStatus("error");
      setErrorMsg("All fields are required.");
      return;
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      setStatus("error");
      setErrorMsg("Please enter a valid email.");
      return;
    }

    setStatus("submitting");
    setErrorMsg("");

    try {
      const body = new URLSearchParams({
        access_key: ACCESS_KEY,
        name,
        email,
        message,
        subject: `New contact form message from ${name}`,
      });

      const res = await fetch("https://splitforms.com/api/submit", {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: body.toString(),
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      setStatus("success");
      setName("");
      setEmail("");
      setMessage("");
    } catch (err) {
      setStatus("error");
      setErrorMsg("Something went wrong. Please try again.");
    }
  }

  if (status === "success") {
    return (
      <div className="rounded-xl bg-green-50 p-6 text-green-900">
        <h3 className="text-lg font-bold">Message sent.</h3>
        <p className="text-sm">Thanks — I'll get back to you within a day.</p>
      </div>
    );
  }

  return (
    <form onSubmit={onSubmit} className="space-y-4">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">Name</label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          required
          className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium">Message</label>
        <textarea
          id="message"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          required
          rows={5}
          className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"
        />
      </div>

      {/* Honeypot — invisible to humans, irresistible to bots */}
      <input
        type="text"
        name="botcheck"
        value={botcheck}
        onChange={(e) => setBotcheck(e.target.value)}
        style={{ position: "absolute", left: "-9999px" }}
        tabIndex={-1}
        autoComplete="off"
        aria-hidden="true"
      />

      {status === "error" && (
        <p className="text-sm text-red-600">{errorMsg}</p>
      )}

      <button
        type="submit"
        disabled={status === "submitting"}
        className="rounded-lg bg-black px-5 py-2.5 text-white font-medium disabled:opacity-50"
      >
        {status === "submitting" ? "Sending..." : "Send message"}
      </button>
    </form>
  );
}

Then drop it into any page:

// src/App.tsx or src/pages/Contact.tsx
import { ContactForm } from "./components/ContactForm";

export default function ContactPage() {
  return (
    <main className="max-w-xl mx-auto py-16 px-4">
      <h1 className="text-3xl font-bold mb-6">Contact me</h1>
      <ContactForm />
    </main>
  );
}

That's a working production contact form in roughly 100 lines. No backend. No Nodemailer. No SMTP credentials. If you want the same setup as a paste-ready template, the free HTML contact form generator on splitforms gives you variants for plain HTML, plus the same React shape used in the React form backend integration guide.

A few details in this component are deliberate. The status state machine — idle, submitting, success, error — is the cleanest way to drive UI off the submission lifecycle. You disable the submit button during submitting so users can't double-click and trigger two requests. You swap the entire form for a success message on success so it's obvious the submission landed. Errors render inline above the button, not in a popup or a toast, because that's where the user's eyes already are. These are small UX choices that matter more than they look on paper.

Note the body encoding: new URLSearchParams(...) produces an application/x-www-form-urlencoded body. That's deliberate. It's a "simple request" under the CORS spec, which means the browser sends it directly without a preflight OPTIONS round-trip. If you switch to JSON.stringify with Content-Type: application/json, you double the request count and add a failure mode where the preflight succeeds but the actual request fails (or vice versa). For a simple contact form, URL-encoded is faster, simpler, and harder to misconfigure.

Validation: vanilla vs react-hook-form

The example above uses vanilla useState plus a regex. That's enough for 90% of contact forms. If your form gets more complex — conditional fields, multi-step flows, async validation — switch to react-hook-form. Here's the same form rewritten with it:

// Install: npm i react-hook-form
import { useForm } from "react-hook-form";

type FormValues = {
  name: string;
  email: string;
  message: string;
  botcheck: string;
};

export function ContactForm() {
  const { register, handleSubmit, reset, formState: { errors, isSubmitting, isSubmitSuccessful } }
    = useForm<FormValues>();

  async function onSubmit(data: FormValues) {
    if (data.botcheck) return; // honeypot
    const body = new URLSearchParams({
      access_key: import.meta.env.VITE_SPLITFORMS_ACCESS_KEY,
      name: data.name,
      email: data.email,
      message: data.message,
    });
    const res = await fetch("https://splitforms.com/api/submit", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: body.toString(),
    });
    if (res.ok) reset();
  }

  if (isSubmitSuccessful) return <p>Thanks — message sent.</p>;

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name", { required: "Name is required" })} />
      {errors.name && <span>{errors.name.message}</span>}

      <input type="email" {...register("email", {
        required: "Email required",
        pattern: { value: /^[^@\s]+@[^@\s]+\.[^@\s]+$/, message: "Invalid email" }
      })} />
      {errors.email && <span>{errors.email.message}</span>}

      <textarea {...register("message", { required: true, minLength: 10 })} />
      <input type="text" {...register("botcheck")} style={{ display: "none" }} />

      <button disabled={isSubmitting}>Send</button>
    </form>
  );
}

react-hook-form adds about 9 KB gzipped to your bundle but cuts your form code roughly in half once you have more than three fields. It also gives you free dirty-state tracking, field-level errors, and revalidation on blur. For a single 3-field contact form, it's overkill. For a multi-step lead-qualification form, it's the right tool. Pick based on form complexity, not vibes.

One more option worth mentioning: native HTML5 validation. Modern browsers will refuse to submit a form with empty required fields or an invalid type="email" value, and they'll surface platform-appropriate error tooltips with no JS at all. If you set noValidate on the form, you opt out and own validation yourself; if you don't set it, the browser's built-in validation runs first and your onSubmit never fires for invalid inputs. For a contact form you can comfortably lean on this and skip half the regex.

Spam protection without reCAPTCHA

The honeypot field in the example is the most under-rated spam filter on the internet. The pattern: render an input that's hidden from humans (CSS positioned off-screen) but visible to bots that parse the HTML and fill every field. When that field is non-empty, you know the submission is bot traffic and you discard it.

This catches roughly 95% of contact form spam without ever showing a captcha to a real user. splitforms also runs AI spam classification on every submission server-side — so even spam that gets past the honeypot gets filtered before it hits your inbox. The deep-dive on the tradeoffs is in honeypot vs reCAPTCHA.

Why not just use reCAPTCHA? Three reasons. It adds 50+ KB of third-party JS to every page that renders your form. It leaks user behavior to Google. And — most importantly for accessibility — it occasionally locks out real users behind image-puzzle challenges that fail on keyboard-only or screen-reader users. A honeypot plus server-side classification gets you the same outcome with none of the costs. The only case where I'd add a captcha back in is if you're running into targeted abuse where spammers are manually filling forms — and in that case, you probably want rate limiting and IP blocking, not captcha anyway.

Three honeypot rules worth following:

  • Use position: absolute; left: -9999px instead of display: none. Some bots skip hidden fields.
  • Add tabIndex=-1 and autoComplete="off" so keyboard users and password managers don't accidentally fill it.
  • Name it something generic like botcheck or website_url — not honeypot. Sophisticated bots check field names.

Deploy to Cloudflare Pages or Vercel

Because the form has no server side, your deploy is the standard static-site flow. Set the VITE_SPLITFORMS_ACCESS_KEY environment variable in your hosting provider's dashboard and ship.

Cloudflare Pages

  1. Connect your Git repo at dash.cloudflare.com/pages
  2. Build command: npm run build
  3. Output directory: dist
  4. Environment variables → add VITE_SPLITFORMS_ACCESS_KEY for both Production and Preview
  5. Save and deploy

Vercel

  1. Import the repo at vercel.com/new
  2. Vercel auto-detects Vite (framework preset: Vite)
  3. Settings → Environment Variables → add VITE_SPLITFORMS_ACCESS_KEY
  4. Deploy

Both hosts give you free SSL, global CDN, and unlimited static traffic on the free tier. The only ongoing cost for the entire contact form stack is the form backend — and splitforms' free tier covers most projects. If you outgrow it, Pro is $5/mo for 5,000 submissions or $59 for 4 years of Pro. See the best free form backend services 2026 roundup for the full landscape.

Locking the access key to your production domain

Once your site is live, go back to the splitforms dashboard and set Allowed Domains to your production hostname. This is the single most important security step in the whole setup. With Allowed Domains enabled, even if someone scrapes your access key out of the bundle, they can't use it from any origin other than the ones you whitelisted. Add both the bare domain (example.com) and the www subdomain if you serve both. For preview deploys on Vercel, you can either whitelist the entire *.vercel.app subdomain or skip preview enforcement and only enforce on production.

If you have a multi-environment workflow — local dev, staging, production — keep three separate access keys, one per environment, each with its own Allowed Domains setting. That way a leak in dev doesn't compromise production, and you can rotate keys per environment without coordinating a redeploy across all of them.

Troubleshooting: CORS, env vars, and dev vs prod

  • "CORS error" in the browser console. If you're POSTing JSON with Content-Type: application/json, the browser issues a preflight OPTIONS request. splitforms handles preflight fine, but if you've enabled Allowed Domains and your dev origin (localhost:5173) isn't in the list, the preflight gets rejected. Easiest fix: stick with application/x-www-form-urlencoded as in the example above — that content type is "simple" per the CORS spec and skips preflight entirely.
  • access_key is undefined in production. You forgot to set VITE_SPLITFORMS_ACCESS_KEY in your hosting provider's env vars. Vite reads env vars at build time on the host, not at request time. Set it in the dashboard and trigger a redeploy.
  • Form works on localhost but returns 403 in production. Allowed Domains is set to a different hostname than where your site is actually served. Check the splitforms dashboard → Security → Allowed Domains. Add yourdomain.com (and www.yourdomain.com if you use it).
  • Submission succeeds but no email arrives. Check spam folder, then check the splitforms dashboard at splitforms.com/dashboard/submissions — if the submission is there, your SMTP config is the issue. The contact form not working guide walks through the deliverability checklist.
  • process.env.VITE_FOO is undefined. You used process.env instead of import.meta.env. process.env only exists in Node, not in browser builds.
  • Env var works in dev, broken in prod. You renamed the variable without restarting the Vite dev server, and it cached the old value. Kill the dev server, run npm run dev again, and double-check the variable name matches exactly in .env and your hosting dashboard.

Most other issues are documented in /docs, /api-reference, and /faq. If you're migrating from a different form backend, the Formspree migration guide covers the field-mapping differences. Also browse the blog for guides covering Next.js, Astro, and other frameworks.

What to ship next

FAQ

Do I need an Express or Node backend for a Vite + React contact form?

No. Vite ships a static SPA — there is no server-side runtime in production unless you add one. Posting form data to splitforms' /api/submit endpoint replaces the Express + Nodemailer stack entirely. Your bundle stays under 200 KB, your deploy is still pure static hosting (Cloudflare Pages, Vercel, Netlify, S3), and email delivery is handled on splitforms' side using your configured SMTP. The only time you'd want a backend is if you need to write submissions to your own database — and even then, you can use webhooks instead of standing up a server.

Why use import.meta.env.VITE_* instead of process.env?

Vite uses ESM at build time and replaces import.meta.env.VITE_* references statically in your bundle. process.env doesn't exist in the browser — it's a Node convention. The VITE_ prefix is mandatory: any env var without it is filtered out so you can't accidentally leak secrets like database URLs. For a contact form access key, that's actually fine — it's a public identifier, not a secret. Lock it down with Allowed Domains in the splitforms dashboard so nobody else can use your key from their site.

Is the access key safe to expose in client-side code?

Yes, by design. The splitforms access key is a public-facing identifier, like a Stripe publishable key or a Google Analytics ID. The security boundary is the Allowed Domains setting in your splitforms dashboard — set it to your production hostname (yourdomain.com) and submissions from any other origin get a 403. Even if someone scrapes the key from your JS bundle, they can't use it to spam your inbox from their own site. If you do want zero key exposure, proxy the submit call through a serverless function, but for 99% of SPAs this is overkill.

react-hook-form or vanilla useState for validation?

Use vanilla useState if your form has 5 or fewer fields. The code is shorter, the bundle is smaller, and you avoid a 9 KB dependency for something HTML5 validation handles natively. Use react-hook-form once you have conditional fields, multi-step flows, or async validation (like checking an email against your CRM). For a typical contact form — name, email, message — vanilla is the right call. This guide shows both so you can pick.

Why is my CORS request failing in dev but working in prod?

Vite's dev server runs on http://localhost:5173 by default. splitforms accepts cross-origin POST requests from any origin during the initial request (no preflight is triggered for application/x-www-form-urlencoded or text/plain content types). If you're sending JSON with Content-Type: application/json, the browser issues an OPTIONS preflight. splitforms handles that, but if you've set Allowed Domains to your production URL only, dev requests get rejected. Add localhost:5173 to the allow-list during development, or use the URL-encoded form submission pattern shown in this guide to skip preflight entirely.

How do I handle file uploads from a Vite + React form?

Use FormData instead of JSON. Create a FormData object, append all your fields plus the File object from the input ref, and POST it with no explicit Content-Type header — the browser will set multipart/form-data with the correct boundary automatically. splitforms accepts attachments up to 10 MB per submission on the free tier and forwards them to your inbox. The /docs page has the full multipart contract. Don't try to base64-encode files into a JSON payload — you'll blow past the size limit and slow everything down.

Can I use this with Vite + React Router?

Yes — the form component is route-agnostic. Drop it into any page component (e.g., src/pages/Contact.tsx) and it works. If you want a /thank-you redirect after submission, either use react-router-dom's useNavigate inside the success branch of your onSubmit handler, or pass a redirect hidden input with the destination URL and let splitforms 302 the browser. The hidden-input approach works without JS enabled, which is rare for an SPA but useful as a progressive-enhancement fallback.

How does this compare to Formspree or Web3Forms in a Vite app?

Same integration pattern — they all accept HTML form POSTs with an access key — but the economics differ. Formspree caps the free tier at 50 submissions/month; Web3Forms is 250; splitforms gives you 1,000. Pro pricing is $10/mo on Formspree, $0 on Web3Forms (with a feature gap), and $5/mo on splitforms for 5,000 submissions plus free webhooks and AI spam classification. For a Vite + React SPA where you control the stack, splitforms gives you the most headroom and the lowest cost. See the side-by-side at /vs/formspree and /vs/web3forms.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open