Back to blog

October 4, 2026 · NBForms Team

Remix Contact Form: The Case Against Remix's Own Form Component

The obvious choice for a Remix contact form is the framework's own <Form> component. The docs themselves explain why that's built for a different job than posting to an external endpoint.

The instinct in a Remix (or React Router Framework Mode) app is to reach for <Form> — capital F, imported from the framework itself — for every form on the page, contact form included. It's the component the docs lead with, it's what every tutorial uses, and it's genuinely the right call for a form that's mutating the app's own data. The case below is about the one place that instinct points the wrong way: a form that isn't talking to the app at all.

A magnifying glass resting on top of a printed document Photo by Vlad Deep on Unsplash

The short version: <Form> is built to talk to a route's own action function, inside the same app. A contact form posting to an external service like NBForms has no route action behind it at all — there's nothing in this app for <Form>'s machinery to coordinate with — so the plain, lowercase <form> element every HTML page has always had turns out to be the more correct tool, not a downgrade from the "proper" framework way.

What "Remix" refers to by the time anyone reads this

Worth settling before anything else: Remix v2, the standalone framework this post's title keyword usually points to, is end of life. Its framework layer — loaders, actions, and <Form> among them — was merged into React Router v7 (now on v8) as "Framework Mode," carrying the same APIs forward under React Router's name. A separate Remix v3 exists, but it drops React entirely and is a different project in practice. Everything below applies the same way whether a project is still running legacy Remix v2 or has moved to React Router's Framework Mode, since the component in question didn't change shape across that move — only which package name it's imported from.

A component built to talk to itself

Here's React Router's own description of what <Form> actually does:

A progressively enhanced HTML <form> that submits data to actions via fetch, activating pending states in useNavigation which enables advanced user interfaces beyond a basic HTML <form>.

"Actions" there means a specific thing: the action export of a route module inside the same app. <Form> isn't a generic "submit this form over HTTP, somewhere" component — it's wired to a specific round trip with the app's own router, and when the action prop is left unset, it defaults to the current route precisely because it assumes that round trip is happening with this app, not a third party.

The revalidation nobody asked for

That round trip doesn't end when the fetch resolves. By design, the framework follows it with a second step: re-running the current route's loaders so the page's data matches whatever the action just changed. That's exactly the right behavior for, say, a comment form that should make a new comment appear in a list already on the page without a reload. It's dead weight for a contact form posting to NBForms — there's no app data a successful submission changed, so the revalidation step runs anyway, fetches loader data that was never stale, and accomplishes nothing except a second network round trip nobody asked for.

It isn't just the one component

useFetcher, the lower-level primitive <Form> is itself built on, draws the identical boundary. React Router's own description of it: fetchers "track their own, independent state and can be used to load data, submit forms, and generally interact with action and loader functions" — action and loader functions being, again, specifically this app's own route exports. Reaching for useFetcher instead of <Form> doesn't change which side of this line a submission falls on; the entire family of submission tools React Router ships is scoped to routes this app defines, and an external endpoint was never going to be one of those regardless of which member of that family gets used to reach it.

The one line the docs never wrote

What the documentation doesn't say is just as telling as what it does: nowhere does it describe behavior for an action prop pointing at a cross-origin URL. Not a warning against it, not an example of it — silence. That's consistent with everything above: a component designed around "this app's own route" has no documented opinion about a domain that was never part of the design, because that case was never the one being solved for.

The plain element that was never deprecated

React Router draws its own line between the two, in its own words:

Because it uses the HTML form API, server rendered pages are interactive at a basic level before JavaScript loads. Instead of React Router managing the submission, the browser manages the submission.

That's a description of <Form> falling back to plain HTML behavior before hydration — which means a literal, lowercase <form> gets that same behavior unconditionally, with no fallback required because there's nothing to fall back from. It was never hijacked by the router to begin with:

// app/routes/contact.tsx
export default function Contact() {
  return (
    <form action="https://api.nbforms.com" method="post">
      <input type="hidden" name="_token" value="YOUR_TOKEN" />
      <label>Name</label>
      <input type="text" name="name" required />
      <label>Email</label>
      <input type="email" name="email" required />
      <label>Message</label>
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  )
}

No action function on this route, no loader revalidation, no fetch for React Router to manage on the way out — the browser sends a native POST straight to NBForms' endpoint, the same way it would from a page with no framework on it at all, because this element was never part of the framework's form-handling layer in the first place. A route loader still earns its keep here, just for something more modest than the pattern above: supplying the hidden _token value from an environment variable server-side, instead of leaving a token sitting in committed source. That loader has nothing to do with <Form>, revalidation, or any of the machinery this post has been describing — it's the same role a loader plays for any other server-supplied value a route needs, unrelated to how the form below it happens to submit.

Where <Form> is still exactly right

None of this is an argument against <Form> generally — it's the better tool the moment a submission needs to change something this app itself tracks: a newsletter signup stored in this app's own database, a comment list rendered from this app's own loader, anything where the automatic revalidation is doing real, needed work instead of idle overhead. The dividing line isn't "Remix forms vs. plain forms" so much as "does this app have a route on the other end of this submission" — and for a contact form whose entire job is handing data to NBForms instead of this app's own backend, the honest answer is no, so the component built around having one isn't the right fit either.

For the fetch-based, inline-success-state version of this same pattern in a plain React SPA with no router actions involved at all, see React form submission without a server. For the field-by-field markup in other frameworks, see the framework-specific snippets.

Frequently asked questions

Is Remix still a separate framework in 2026?

Not in the way it was. Remix v2 reached end of life once its framework layer merged into React Router v7 (now v8) as "Framework Mode" — same loaders, actions, and the same <Form> component, just under React Router's name. A standalone Remix v3 exists too, but it isn't built on React at all, so it's a different project in practice.

Does <Form> from React Router actually fail when action points to an external URL?

Nothing documented says it outright fails — the docs simply don't address cross-origin action URLs at all, because the component isn't designed around that case. What's confirmed is what happens afterward: React Router revalidates the current route's own loaders, which do nothing useful when the destination was never one of the app's own routes.

Does a plain <form> still work inside a React Router / Remix app?

Yes, unmodified — a lowercase <form> is the native HTML element and isn't touched by the router at all. It behaves exactly like it would on a page with no framework behind it, which is exactly why it's the right choice for a submission that isn't going to one of the app's own routes.

Do I lose the pending-state UI (spinners, disabled button) by using a plain <form>?

Only the automatic version. useNavigation's pending state is tied specifically to <Form>'s fetch-based submission; a plain <form> can still get a loading state by listening for the submit event and setting it by hand — a few lines, not a missing feature.

Should the hidden _token field be rendered through a loader instead of hardcoded?

Either works, but a loader is the better fit for a real project — it keeps the token out of committed source and makes it the one place a rotated token needs updating, consistent with how any other server-supplied value would reach a Remix or React Router route.