Skip to main content
New:Build with AI agents

React contact forms
without a backend.

Connect your React form to Form Plume and collect messages, get email notifications, and trigger integrations without having to manage API routes, databases, or email providers.

Free foreverNo credit cardReact endpoint ready in under a minute
ContactForm.tsx
export const ContactForm = () => {
  const [status, setStatus] = useState("idle");

  async function onSubmit(event) {
    event.preventDefault();
    setStatus("sending");

    const response = await fetch("/f/react-contact", {
      method: "POST",
      body: new FormData(event.currentTarget),
    });

    setStatus(response.ok ? "sent" : "error");
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button disabled={status === "sending"}>
        {status === "sending" ? "Sending..." : "Send message"}
      </button>
    </form>
  );
};

The full guide

A complete backend for your React contact form.

Follow the guide below to connect your React form to Form Plume. Your first 500 submissions are free, so you can test the full flow before committing.

  1. 1
    Create your Form Plume accountStart free and give your contact form a hosted endpoint.Start free
  2. 2
    Create your first form and copy the endpoint URLAfter you log in, create a form and copy its endpoint. It looks like https://api.formplume.com/f/your-slug
  3. 3
    Choose your technology belowSee the sections below for your specific setup, then copy the AI prompt or code sample.
React

You can use this AI prompt to speed up your integration.

Use this when you have a normal React component and want the fewest moving parts.

Replace PASTE_YOUR_FORM_PLUME_ENDPOINT_HERE with the endpoint you copied from Form Plume.

import { useState, type FormEvent } from "react";
 
const FORM_PLUME_ENDPOINT = "PASTE_YOUR_FORM_PLUME_ENDPOINT_HERE";
 
type Status = "idle" | "sending" | "sent" | "error";
 
export const ContactForm = () => {
  const [status, setStatus] = useState<Status>("idle");
 
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
 
    const response = await fetch(FORM_PLUME_ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.currentTarget),
    });
 
    setStatus(response.ok ? "sent" : "error");
  }
 
  if (status === "sent") {
    return <p role="status">Thanks, we got your message.</p>;
  }
 
  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
 
      <label htmlFor="email">Email</label>
      <input id="email" type="email" name="email" autoComplete="email" required />
 
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required />
 
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending..." : "Send message"}
      </button>
 
      {status === "error" && (
        <p role="alert">Something went wrong. Try again.</p>
      )}
    </form>
  );
};
Vite React

You can use this AI prompt to speed up your integration.

Use this when your app was created with Vite and your form submits from the browser.

Keep the endpoint in a VITE_ environment variable, or paste it directly while testing.

import { useState, type FormEvent } from "react";
 
const FORM_PLUME_ENDPOINT =
  import.meta.env.VITE_FORM_PLUME_ENDPOINT ??
  "PASTE_YOUR_FORM_PLUME_ENDPOINT_HERE";
 
export const ContactForm = () => {
  const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">(
    "idle",
  );
 
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
 
    const response = await fetch(FORM_PLUME_ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.currentTarget),
    });
 
    setStatus(response.ok ? "sent" : "error");
  }
 
  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" name="email" required />
 
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />
 
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending..." : "Send message"}
      </button>
 
      {status === "sent" && <p role="status">Thanks, we got your message.</p>}
      {status === "error" && <p role="alert">Try again in a moment.</p>}
    </form>
  );
};
React Hook Form and Zod

You can use this AI prompt to speed up your integration.

Use this when the form has real validation rules or more than a few fields.

The schema owns validation. z.infer keeps your TypeScript type and runtime rules together.

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
 
const FORM_PLUME_ENDPOINT = "PASTE_YOUR_FORM_PLUME_ENDPOINT_HERE";
 
const schema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.string().email("Enter a valid email"),
  message: z.string().min(10, "Tell us a little more"),
});
 
type ContactFields = z.infer<typeof schema>;
 
export const ContactForm = () => {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting, isSubmitSuccessful },
  } = useForm<ContactFields>({ resolver: zodResolver(schema) });
 
  async function onSubmit(data: ContactFields) {
    const response = await fetch(FORM_PLUME_ENDPOINT, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify(data),
    });
 
    if (!response.ok) {
      throw new Error("Submission failed");
    }
 
    reset();
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label htmlFor="name">Name</label>
      <input id="name" {...register("name")} aria-invalid={!!errors.name} />
      {errors.name && <p role="alert">{errors.name.message}</p>}
 
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        {...register("email")}
        aria-invalid={!!errors.email}
      />
      {errors.email && <p role="alert">{errors.email.message}</p>}
 
      <label htmlFor="message">Message</label>
      <textarea
        id="message"
        {...register("message")}
        aria-invalid={!!errors.message}
      />
      {errors.message && <p role="alert">{errors.message.message}</p>}
 
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Sending..." : "Send message"}
      </button>
 
      {isSubmitSuccessful && <p role="status">Thanks, we got your message.</p>}
    </form>
  );
};
Next.js App Router

You can use this AI prompt to speed up your integration.

Use this when your contact page lives in app/ and you want the submit to run through a Server Action.

The visitor never calls your Form Plume endpoint directly. The server action forwards the FormData.

// app/contact/actions.ts
"use server";
 
const FORM_PLUME_ENDPOINT = "PASTE_YOUR_FORM_PLUME_ENDPOINT_HERE";
 
export async function submitContact(formData: FormData) {
  const response = await fetch(FORM_PLUME_ENDPOINT, {
    method: "POST",
    body: formData,
  });
 
  if (!response.ok) {
    throw new Error("Submission failed");
  }
}
// app/contact/page.tsx
import { submitContact } from "./actions";
 
export default function ContactPage() {
  return (
    <form action={submitContact}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" name="email" required />
 
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />
 
      <button type="submit">Send message</button>
    </form>
  );
}

Add a small client button with useFormStatus if you want a pending label without turning the whole form into a client component.

Route action frameworks

Use this when your React app handles form submissions through route actions instead of a plain client-side fetch.

Remix and React Router now have dedicated guides with framework-specific examples:

Both patterns keep validation, redirects, and pending state in the router layer while Form Plume handles email notifications, stored submissions, spam filtering, file uploads, integrations, and webhooks.

TanStack Start

You can use this AI prompt to speed up your integration.

Use this when you are shipping a TanStack Start app and want a simple client-side form first.

Keep the endpoint as a constant or environment-backed value, then post FormData from the component.

import { createFileRoute } from "@tanstack/react-router";
import { useState, type FormEvent } from "react";
 
const FORM_PLUME_ENDPOINT = "PASTE_YOUR_FORM_PLUME_ENDPOINT_HERE";
 
export const Route = createFileRoute("/contact")({
  component: ContactPage,
});
 
function ContactPage() {
  const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">(
    "idle",
  );
 
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
 
    const response = await fetch(FORM_PLUME_ENDPOINT, {
      method: "POST",
      body: new FormData(event.currentTarget),
    });
 
    setStatus(response.ok ? "sent" : "error");
  }
 
  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" name="email" required />
 
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />
 
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending..." : "Send message"}
      </button>
 
      {status === "sent" && <p role="status">Thanks, we got your message.</p>}
      {status === "error" && <p role="alert">Try again in a moment.</p>}
    </form>
  );
}
Formik

You can use this AI prompt to speed up your integration.

Use this when your React codebase already standardizes on Formik.

Formik gives you form state and touched fields. Form Plume handles the backend after submit.

import { Form, Field, Formik } from "formik";
 
const FORM_PLUME_ENDPOINT = "PASTE_YOUR_FORM_PLUME_ENDPOINT_HERE";
 
type ContactFields = {
  name: string;
  email: string;
  message: string;
};
 
export const ContactForm = () => {
  return (
    <Formik<ContactFields>
      initialValues={{ name: "", email: "", message: "" }}
      onSubmit={async (values, helpers) => {
        const response = await fetch(FORM_PLUME_ENDPOINT, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Accept: "application/json",
          },
          body: JSON.stringify(values),
        });
 
        if (response.ok) {
          helpers.resetForm();
          helpers.setStatus("sent");
        } else {
          helpers.setStatus("error");
        }
      }}
    >
      {({ isSubmitting, status }) => (
        <Form>
          <label htmlFor="name">Name</label>
          <Field id="name" name="name" required />
 
          <label htmlFor="email">Email</label>
          <Field id="email" name="email" type="email" required />
 
          <label htmlFor="message">Message</label>
          <Field as="textarea" id="message" name="message" required />
 
          <button type="submit" disabled={isSubmitting}>
            {isSubmitting ? "Sending..." : "Send message"}
          </button>
 
          {status === "sent" && <p role="status">Thanks, we got your message.</p>}
          {status === "error" && <p role="alert">Try again in a moment.</p>}
        </Form>
      )}
    </Formik>
  );
};
Gatsby

You can use this AI prompt to speed up your integration.

Use this when your contact page is a Gatsby page or template.

Gatsby can submit straight from the client. Use a GATSBY_ environment variable if you do not want the endpoint hardcoded.

import { useState, type FormEvent } from "react";
 
const FORM_PLUME_ENDPOINT =
  process.env.GATSBY_FORM_PLUME_ENDPOINT ??
  "PASTE_YOUR_FORM_PLUME_ENDPOINT_HERE";
 
export default function ContactPage() {
  const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">(
    "idle",
  );
 
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
 
    const response = await fetch(FORM_PLUME_ENDPOINT, {
      method: "POST",
      body: new FormData(event.currentTarget),
    });
 
    setStatus(response.ok ? "sent" : "error");
  }
 
  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" name="email" required />
 
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />
 
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending..." : "Send message"}
      </button>
 
      {status === "sent" && <p role="status">Thanks, we got your message.</p>}
      {status === "error" && <p role="alert">Try again in a moment.</p>}
    </form>
  );
}

Best practices

Keep your endpoint in one constant so it is easy to replace when you move from a test form to a production form.

Prefer FormData when your form may grow to include files. JSON is fine for text-only forms.

Always show a pending state. A contact form that looks frozen feels broken.

Keep spam fields and hidden fields named. Form Plume can only process fields that arrive in the submission.

File uploads

Use FormData; do not JSON-stringify files.

Add encType="multipart/form-data" when you are using a plain HTML or server-rendered form.

<input type="file" name="attachment" accept=".pdf,.png,.jpg" />

Form Plume stores attachments separately from email delivery, so a large file does not break the notification path.

See File Uploads for limits and stored-file behavior.

Integrations and webhooks

Your React form can do more than send an email.

Form Plume can store the submission, notify your team, and trigger the next step in another system.

Use integrations for common tools like Slack, Discord, Google Sheets, Mailchimp, and ActiveCampaign.

Use webhooks when you want to send accepted submissions to your own API, automation tool, CRM, or internal workflow.

That means your React app can stay simple. The form submits once, and Form Plume handles the fan-out.

Spam protection

Contact forms attract spam as soon as they are public.

Form Plume adds spam controls before the submission reaches your inbox or integrations.

Start with the defaults, then add stronger checks when a form starts getting targeted abuse.

See Spam Protection for honeypots, rate limits, submission timing, and how suspicious messages are handled.

Accessibility

Every field needs a visible label connected with htmlFor and id.

Use role="status" for success messages and role="alert" for errors.

Disable the submit button while sending, but keep the status text visible so the visitor knows what changed.

If validation fails, move focus to the first invalid field or use a form library that already handles it.

Testing locally? Form Plume accepts requests from local React dev servers, including localhost:3000 and localhost:5173, without extra CORS setup on your side.

Primary sources

FAQ

React form questions
before you ship.

For a small contact form, keep the fields as normal inputs and submit the real form element with FormData. Add a status state for idle, sending, sent, and error. Reach for react-hook-form and Zod when you need schema validation, conditional fields, or reusable error handling.

One line. Zero backend.

The form backend you don’t have to build.

Free foreverNo credit cardSet up in under a minute