Framework guide

React / Next.js

Two options: a plain form POST (simplest), or fetch() so you can show an inline success state without a page navigation.

Prefer a typed hook?: Install @shipmyform/react for a useSubmit hook that tracks submitting / succeeded / error for you, see the JavaScript SDK. The no-dependency approach below works identically.

A React contact form does not need a backend. You do not write an API route, a Server Action, or a database. Point the form at your ShipMyForm endpoint and every submission is stored, spam-filtered, and forwarded to your connectors. There are two ways to wire it up, and both are below.

Option A: plain form POST

The simplest React form backend is no JavaScript at all. Set theaction and method and let the browser submit it. On success the visitor lands on a hosted thank-you page (or your own, see redirects below).

ContactForm.tsx
export function ContactForm() {
  return (
    <form action="https://shipmyform.com/f/frm_8Kx2mQ9pL4vN" method="POST">
      <input type="email" name="email" required />
      <textarea name="message" required />
      <input type="text" name="_gotcha" style={{ display: "none" }} tabIndex={-1} />
      <button type="submit">Send</button>
    </form>
  );
}

Option B: fetch() with a JSON response

Send Accept: application/json and ShipMyForm responds with { ok: true } instead of redirecting, perfect for an inline success message. This variant is a client component, so it opens with "use client".

ContactForm.tsx
"use client";
import { useState } from "react";

export function ContactForm() {
  const [sent, setSent] = useState(false);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
    const res = await fetch("https://shipmyform.com/f/frm_8Kx2mQ9pL4vN", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: form,
    });
    if (res.ok) setSent(true);
  }

  if (sent) return <p>Thanks, we&apos;ll be in touch!</p>;

  return (
    <form onSubmit={onSubmit}>
      <input type="email" name="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}

Sending JSON works too, POST { "email": "…", "message": "…" } with Content-Type: application/json.

Validation and error states

Start with the browser. The required, type, andpattern attributes give you free client-side validation with no library, and they work in the plain-POST form too. For the fetch variant you also get to inspect the server response and show a real error message when something goes wrong (a bad email, a rate limit, a network blip). Track an error string alongside the submitting flag and render it near the form.

ContactForm.tsx
"use client";
import { useState } from "react";

export function ContactForm() {
  const [error, setError] = useState<string | null>(null);
  const [sent, setSent] = useState(false);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setError(null);
    const res = await fetch("https://shipmyform.com/f/frm_8Kx2mQ9pL4vN", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(e.currentTarget),
    });
    if (res.ok) {
      setSent(true);
    } else {
      const data = await res.json().catch(() => null);
      setError(data?.message ?? "Something went wrong. Please try again.");
    }
  }

  if (sent) return <p>Thanks, we&apos;ll be in touch!</p>;

  return (
    <form onSubmit={onSubmit}>
      <input type="email" name="email" required />
      <textarea name="message" required minLength={10} />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Send</button>
    </form>
  );
}

Loading and success UX

Track three states: submitting, succeeded, and error. Disable the button while the request is in flight so nobody double-submits, then swap in a thank-you message when it resolves. This is the same pattern the useSubmit hook from @shipmyform/react gives you out of the box.

ContactForm.tsx
"use client";
import { useState } from "react";

export function ContactForm() {
  const [status, setStatus] = useState<"idle" | "submitting" | "sent" | "error">(
    "idle",
  );

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("submitting");
    const res = await fetch("https://shipmyform.com/f/frm_8Kx2mQ9pL4vN", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(e.currentTarget),
    });
    setStatus(res.ok ? "sent" : "error");
  }

  if (status === "sent") return <p>Thanks, we&apos;ll be in touch!</p>;

  return (
    <form onSubmit={onSubmit}>
      <input type="email" name="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={status === "submitting"}>
        {status === "submitting" ? "Sending…" : "Send"}
      </button>
      {status === "error" && <p role="alert">Could not send. Try again.</p>}
    </form>
  );
}

App Router vs Pages Router

Both routers work, and neither needs anything special. Because the form posts directly to ShipMyForm, you do not need a Server Action, a route.ts handler, or an API route under pages/api. Drop the component onto any page in either router and you are done.

The plain-POST form is a Server Component by default in the App Router, which is fine because it renders no interactive JavaScript. The fetch variant uses useState and an event handler, so it must be a Client Component: add "use client" at the top of that file. In the Pages Router every component is a client component already, so there is nothing extra to add.

File uploads

Add an <input type="file"> with a name and submit as multipart/form-data. In a plain form set the encType; with fetch, a FormData body already sends the right content type and boundary, so file uploads work with no code changes. See file uploads for size limits and storage details.

UploadForm.tsx
export function UploadForm() {
  return (
    <form action="https://shipmyform.com/f/frm_8Kx2mQ9pL4vN" method="POST" encType="multipart/form-data">
      <input type="email" name="email" required />
      <input type="file" name="attachment" />
      <button type="submit">Send</button>
    </form>
  );
}

Redirect vs AJAX

The two approaches differ in what happens after submit. A plain form POST triggers a full navigation: the browser leaves the page and lands on a thank-you page. To send visitors to your own page, add a hidden _redirectfield (its URL must match one of the form's allowed domains) or set a default redirect in the form's Settings tab. You can also set a _subject field to control the email notification subject line.

ContactForm.tsx
<form action="https://shipmyform.com/f/frm_8Kx2mQ9pL4vN" method="POST">
  <input type="hidden" name="_redirect" value="https://yoursite.com/thank-you" />
  <input type="hidden" name="_subject" value="New contact from the website" />
  <input type="email" name="email" required />
  <textarea name="message" required />
  <button type="submit">Send</button>
</form>

The fetch approach never reloads. You handle the JSON response yourself and render an inline success state, which keeps the visitor on the same page. The _redirect field is ignored when you request JSON, because there is no navigation to redirect.

Spam protection

A honeypot field is handled automatically. Add a hidden _gotcha input (as in Option A) and bots that fill it are dropped before they reach your inbox. No CAPTCHA, no third-party script, no friction for real people. For the full picture, read how to stop form spam without reCAPTCHA.

Troubleshooting

  • 404 on the endpoint. Double-check the form ID in the URL (/f/frm_...) and make sure the request is a POST. A GET to the endpoint will not match.
  • CORS errors with fetch. Send Accept: application/json so ShipMyForm returns a JSON response instead of a redirect. A cross-origin redirect is what usually trips the browser.
  • Silent failures. If a submit seems to do nothing, check the submission inbox in your dashboard. The entry may have been caught by spam filtering rather than lost.
  • Fields not showing up. Every input needs a name. Inputs without one are never sent by the browser, so they never arrive.

FAQ

Do I need a backend or an API route for a React contact form?

No. Point your form's action (or a fetch call) at your ShipMyForm endpoint and submissions are stored, spam-filtered, and routed to your connectors. You do not write a server, a route handler, or a Server Action. The form talks to ShipMyForm directly from the browser.

Does this work with the Next.js App Router?

Yes. It works identically in the App Router and the Pages Router. A plain form POST needs no client component. The fetch() variant needs a client component, so add "use client" at the top of that file because it uses useState and an onSubmit handler.

How do I show a success message without a page reload?

Use the fetch() approach and send the header Accept: application/json. ShipMyForm returns { ok: true } as JSON instead of redirecting, so you can flip a piece of React state and render an inline thank-you message with no navigation.

Can I upload files from a React form?

Yes. Add an <input type="file" name="attachment"> and submit the form as multipart/form-data. With a plain form set enctype="multipart/form-data"; with fetch, pass a FormData body and the browser sets the boundary for you.

Why is one of my fields missing from the submission?

Every input you want captured needs a name attribute. Inputs without a name are never sent by the browser, so they never reach ShipMyForm. Check the name on each field, including selects, checkboxes, and textareas.

Next steps