Skip to content
FFormhook
All guides

Guide

Remix contact form (no backend needed)

Remix is built around server routes, but a contact form doesn't have to be one. You can post directly to Formhook from the browser using a plain form action — or, if you want Remix's progressive-enhancement story, use useFetcher to manage the submission state.

Use a plain HTML form

You do not need an API route. The form POSTs directly to Formhook, and your framework never has to touch the submission.

contact.html
<form action="https://formhook.app/f/YOUR_API_KEY" method="POST">
  <label>
    Email
    <input type="email" name="email" required />
  </label>

  <label>
    Message
    <textarea name="message" required></textarea>
  </label>

  <!-- honeypot: bots fill this; humans don't see it -->
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" hidden />

  <!-- optional: send users to a thank-you page after a native POST -->
  <input type="hidden" name="_redirect" value="https://example.com/thanks" />

  <button type="submit">Send</button>
</form>

Or submit with fetch in Remix

Use this version if you want client-side success/error states without a full page reload.

app/routes/contact.tsx
// app/routes/contact.tsx
import { useFetcher } from "@remix-run/react";

export default function Contact() {
  const fetcher = useFetcher();
  const status = fetcher.state === "submitting" ? "sending" : fetcher.data?.ok ? "ok" : "idle";

  if (status === "ok") return <p>Thanks — we'll be in touch.</p>;

  return (
    <fetcher.Form
      method="post"
      action="https://formhook.app/f/YOUR_API_KEY"
      encType="application/x-www-form-urlencoded"
    >
      <input type="email" name="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" required />
      <input type="text" name="_gotcha" tabIndex={-1} hidden />
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send"}
      </button>
    </fetcher.Form>
  );
}

How it works

The plain form snippet works as a standard HTML form — Remix's <Form> would route the submission to a Remix action, but pointing the action at formhook.app/f/YOUR_API_KEY bypasses Remix entirely and sends the data straight to Formhook. The useFetcher version keeps Remix's progressive enhancement intact while still POSTing externally: the browser does an XHR submission, and you get fetcher.state to drive your UI.

What happens next

  • Submission lands in your Formhook dashboard within a second.
  • Push notification fires on every device you've enabled it on.
  • Reply directly from the dashboard (Pro and above) — no Resend/SendGrid integration required in your Remix app.

Ship your Remix contact form today.

Sign up free, create a form, paste the API key. Five minutes.

Start free

Free tier: 5 forms · 250 submissions/month · No credit card.

Remix contact form — FAQ

Do I need to write a Remix action function?
No. Pointing the form's action at formhook.app bypasses Remix routing entirely. If you'd rather proxy through a Remix action for logging or shaping the payload, you can — but it's not required.
Will useFetcher still work if the action is on a different origin?
Yes. useFetcher uses fetch under the hood, so cross-origin requests work as long as your Formhook form's allowed-origins list includes your Remix app's origin.
Does this work with Remix's `unstable_singleFetch` or React Router 7?
Yes — the form submission goes directly to Formhook, so it's unaffected by Remix's data-loading or routing changes.

Guides for other frameworks