Remix merged into React Router 7 in November 2024 — "Remix" today means React Router running in framework mode, and Remix v2 apps sit directly on top of it. Whether you found this page searching "remix contact form," "react router 7 form," or "remix form action," the answer is the same: two solid ways to wire a contact form, and neither one requires writing your own email or storage backend.
Both paths below are plain code — no client library, no wrapper component. Path A posts straight from the browser to a hosted endpoint. Path B keeps the submission inside a React Router action()and forwards it server-side. Pick based on whether you have server logic of your own to run, covered honestly in the "which to pick" section below.
The two paths: route action() vs. direct-to-endpoint
React Router 7's framework mode gives every route an optional action() — a function that runs on the server (or whatever runtime your adapter targets) when a Form submits with method="post". It's the framework's own data-mutation primitive, the same role Next.js Server Actions play — see server actions vs. form backend for the Next.js version of this exact decision. An action() is routing plumbing, not a backend: it still gives you no storage, no email delivery, and no spam filtering unless you wire those yourself or forward to something that already has them.
You don't have to use it at all. A form is still just a form — pointing its action attribute at a URL works with zero React Router involvement, the same plain-HTML technique covered in Add a Contact Form to React, since React Router 7 is still React underneath. Direct-to-endpoint wins when the form's only job is reaching an inbox and a dashboard: no server runtime required, so it works in SPA mode and static builds too. A route action() wins once you have logic that has to run inside your own app — validating against your own rules, writing a database row, or keeping the visitor on the page instead of navigating away.
Path A: a plain HTML form, no action() at all
Point the form's action attribute straight at a hosted endpoint and skip React Router's data APIs entirely. This is a normal route component — no action export, no Form import:
// app/routes/contact.tsx
export default function Contact() {
return (
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="hidden" name="redirect" value="https://yoursite.com/thanks" />
<input name="name" type="text" required />
<input name="email" type="email" required />
<textarea name="message" required />
{/* Honeypot — real visitors never see or check this */}
<input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
<button type="submit">Send</button>
</form>
);
}The browser POSTs directly to splitforms.com and follows the redirect URL on success — React Router never sees the request. That means this works identically whether react-router.config.ts sets ssr: true (full framework mode) or ssr: false (SPA mode, prerendered to static HTML): there's no server route to run either way, the same reason this pattern is the default recommendation in jamstack form backend. That's the whole job of a Remix form backend: reachable inbox, searchable dashboard, spam filtering — storage, email delivery, and the honeypot/time-trap/rate-limiting stack all run on splitforms' side, free on every plan, including the 500-submission/month Free tier.
Path B: Form + an action() that forwards with fetch
When you want to run your own code before the submission leaves your server — validate a field your own way, log the attempt, or simply avoid a page navigation — write an action() that reads the posted FormData and forwards it:
// app/routes/contact.tsx
import type { ActionFunctionArgs } from "react-router";
import { Form, useActionData, useNavigation } from "react-router";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const response = await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: formData,
headers: { Accept: "application/json" },
});
const result = await response.json();
if (!response.ok) {
return { ok: false as const, error: result.message ?? "Something went wrong. Try again." };
}
return { ok: true as const };
}
export default function Contact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const submitting = navigation.state === "submitting";
return (
<Form method="post">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input name="name" type="text" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit" disabled={submitting}>
{submitting ? "Sending…" : "Send"}
</button>
{actionData?.ok === true && <p>Thanks — we got your message.</p>}
{actionData?.ok === false && <p role="alert">{actionData.error}</p>}
</Form>
);
}That's the whole pattern: await request.formData(), forward it with fetch, return a small JSON-serializable result. React Router's Form component intercepts the submit, posts to the route's own action(), and re-renders with actionDatainstead of doing a full navigation — that's what keeps the visitor on the page.
React Router 7's framework-mode API has been stable since its November 2024 release. If you're on an older Remix v2 app that hasn't adopted the react-router package yet, check your installed version before copying the imports above verbatim.
Progressive enhancement: both paths work without JavaScript
Every code sample above degrades gracefully. Path A is a plain form element — browsers have POSTed forms since before JavaScript existed, so it works with scripting fully disabled. Path B's Form component renders a real HTML form with method="post" in the markup it sends to the browser; without JavaScript, clicking submit triggers a normal full-page POST to the route, the action() still runs, and the response renders the page server-side. JavaScript only adds the on-page re-render via useActionData and the pending state via useNavigation — neither is required for the form to function.
That's a deliberate design choice inherited from Remix: build on standard browser behavior first, then layer client-side enhancement on top, rather than requiring JavaScript to make a form work at all.
Validation and error UX
To validate before forwarding, check the field inside the action() and return early — no extra library required for a simple contact form:
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "");
if (!email.includes("@")) {
return { ok: false as const, error: "Enter a valid email address." };
}
const response = await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: formData,
});
return { ok: response.ok };
}useActionData surfaces whatever the action returned — success or your custom error — without a redirect. useNavigation().state flips to "submitting" the instant the form posts, so the disabled-button pending state in the Path B component needs no extra state variable of its own. Both hooks come from the same react-router package as Form.
Which to pick
Honest version: most contact forms don't need an action(). If the form's job is "deliver this to my inbox and let me search past submissions," Path A does that in fewer lines, works in SPA mode, and never touches your server. Write an action()when you have server logic that has to live inside your own app — writing to a database you own, calling another internal API, or enforcing validation rules a client-side check can't be trusted to enforce alone.
Either way, the delivery problem is the same one splitforms solves regardless of which path calls it: the honeypot, time-trap, and rate-limiting spam stack runs server-side by default on every form, and email notifications are free on every plan, including Free (500 submissions/month, unlimited forms, forever). Nothing about choosing Path A over Path B — or the reverse — changes what happens after splitforms receives the POST.
Next steps
- Building with plain React and skipping React Router's data APIs entirely? The same two paths apply — see contact form for React.
- Want the full list of what runs server-side on every submission by default? See the spam-protection feature page — honeypot, time-trap, and rate limiting, included free on every plan.
FAQ
Is Remix the same as React Router 7?
Effectively yes for new projects. React Router 7 (released November 2024) absorbed Remix's framework-mode capabilities — file-based routing, loaders, actions, and server rendering. Remix itself now sits on top of React Router 7, so code written against React Router 7's framework mode and recent Remix versions is largely interchangeable.
Do I need a route action() to handle a contact form?
No. A plain HTML form posting directly to a hosted endpoint works with zero action() code — see Path A above. Write an action() only when you have server-side work of your own to do: custom validation, a database write, or keeping the visitor on the page instead of redirecting.
Does a plain HTML form work in React Router 7 SPA mode?
Yes — that's exactly where it shines. SPA mode (ssr: false) and static prerendering have no server runtime to run an action() in, but a form posting straight to an external URL doesn't need one; the browser handles the POST and the redirect on its own.
How do I show validation errors with useActionData?
Return an object like { ok: false, error: '...' } from your action(), then read it in the component with useActionData<typeof action>(). React Router re-renders the route with the new action data after the POST completes, without a full page reload.
Can I use fetch inside an action() to forward form data?
Yes — that's the whole pattern in Path B. Call await request.formData() to read the submitted fields, then fetch() a POST to https://splitforms.com/api/submit with that FormData as the body. The action can inspect or transform fields before forwarding them.
Does splitforms work with React Router 7 or Remix?
Yes. splitforms is just an HTTP POST endpoint, so it doesn't care which framework sent the request — a plain form action, a React Router action(), a Remix v2 action, or a fetch call from anywhere all work identically.
Which approach should I use for a simple contact form?
Path A — the plain HTML form with no action(). It's less code, works in SPA mode, and splitforms already handles storage, email, and spam filtering. Add an action() only once you need logic that has to run inside your own app.
Want the delivery, dashboard, and spam filtering handled for you? Get a free splitforms access key — point either path above at the endpoint and you're done.