# Build a waitlist in React Router

> A React Router waitlist in the footer of every page: a fetcher posts to one route action, tags each signup with its page and works without JavaScript.

Published 2026-10-25 · https://ikiform.com/guides/waitlist/react-router

Give the waitlist its own route with an `action` that validates the email and forwards it to your form backend with a secret key. Render the form in your layout with `useFetcher`, so a signup from any page stays on that page and is tagged with its path; without JavaScript the same form posts to the waitlist route and its page shows the result.

The [main guide](https://ikiform.com/guides/waitlist.md#form) has a React Router tab with the form and its `action` on the home route. That suits a one-page site. Most SaaS sites before launch have a few pages, such as pricing, a blog post and the home page, and you want the signup on all of them and to know which one worked. This page puts the form in the layout and posts every copy to one action.

## What's different in React Router

In framework mode, an action belongs to a route, and a `<Form>` posts to the route it sits in. A form in the layout sits in every route, so the choice of component decides what happens on submit:

|   | `<Form method="post">` | `<fetcher.Form action="/waitlist">` |
| --- | --- | --- |
| Where it posts | The current route, unless you set `action` | The `/waitlist` route's action |
| After submit | A navigation. The URL and history change | No navigation. The visitor stays on the page |
| The result | `actionData` of the route that ran | `fetcher.data`, shared by every fetcher with the same `key` |
| Without JavaScript | A normal post | A normal post to `/waitlist`, which renders its own page |

A fetcher fits a footer signup: nobody wants to leave the pricing page to join a list. It also degrades cleanly. Before hydration, `fetcher.Form` is an ordinary `<form>` that posts to `/waitlist`, so that route needs a page of its own to show the result.

Diagram: Footer signups from every React Router page go through one route action

1. The layout renders the waitlist form on every page, with a hidden ref made from the page's path.
2. With JavaScript, fetcher.Form posts to /waitlist without leaving the page. The fetcher's key is shared, so a note in the header sees the same result.
3. Without JavaScript, the same form posts to /waitlist as a normal navigation.
4. Either way, the /waitlist action checks the email and forwards the signup to Ikiform with the secret key.
5. A fetcher reads the result from fetcher.data. A plain post lands on the /waitlist page, which shows it from actionData.

One action, any number of forms. The path tells you which page earned the signup.

## Set up the project

The [server guide](https://ikiform.com/docs/guides/server.md) covers creating the form and a secret key. The files on this page:

`Project`:

```text
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env
```

Put the key in `.env` and read it from `process.env` in the action. Route modules are split for the browser, but `action` only runs on the server, so the key never ships to the client. Never import it into a component.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

The action sends `email` and `ref`. If the form has a field definition on its **Fields** tab, it must list both:

`Fields`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## Build it

### Register the route

`app/routes.ts`:

```ts
import { index, type RouteConfig, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("pricing", "routes/pricing.tsx"),
  route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;
```

### The waitlist route

`app/routes/waitlist.tsx`:

```tsx
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) {
    return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
  }
  const ref = String(form.get("ref") ?? "");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      Origin: request.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
  return data(
    {
      ok: false,
      message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      email,
    },
    { status: response?.status === 429 ? 429 : 400 },
  );
}

export default function Waitlist({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Join the Acme waitlist</h1>
      <WaitlistForm initial={actionData} />
    </main>
  );
}
```

- `data()` sets the status of an error without turning it into a thrown response, so the component still renders the message.
- Every answer carries the email back, so the input can show it again after an error.
- The browser's `Origin` header is passed on because Ikiform counts free `localhost` test submissions by it, and the action's own `fetch` sends none.
- `ref` must be lowercase letters, digits, `-` or `_`, up to 40 characters, the same `max_length` as the definition. Anything else is stored as `direct`.
- The default export is the no-JavaScript fallback. It renders the same form with the action's result.

### The shared form

`app/components/waitlist-form.tsx`:

```tsx
import { useFetcher, useLocation } from "react-router";

interface Outcome {
  ok: boolean;
  message: string;
  email: string;
}

export function sourceFor(pathname: string) {
  const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
  return (slug || "home").slice(0, 40);
}

export function WaitlistForm({ initial }: { initial?: Outcome }) {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  const { pathname } = useLocation();
  const outcome = fetcher.data ?? initial;
  const pending = fetcher.state !== "idle";

  if (outcome?.ok) return <p role="status">{outcome.message}</p>;

  return (
    <fetcher.Form method="post" action="/waitlist">
      <label>
        Email
        <input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
      </label>
      <input type="hidden" name="ref" value={sourceFor(pathname)} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{outcome?.message}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </fetcher.Form>
  );
}

export function JoinedNote() {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}
```

- `key: "waitlist"` makes every fetcher with that key one shared fetcher. `JoinedNote` in the header reads the same `fetcher.data`, so it changes the moment the footer form succeeds.
- `sourceFor` turns `/blog/launch` into `blog-launch` and `/` into `home`. The path is known on the server, so it works on prerendered pages too.
- `fetcher.state` is `submitting` while the post runs, then `loading` while React Router revalidates, so the button stays disabled until both finish.
- The label wraps the input, so the hero and footer copies on one page don't share an `id`.

Render `<WaitlistForm />` in the `Layout` of `app/root.tsx`, inside a footer with `id="waitlist"`, and `<JoinedNote />` in your header. The honeypot rides along and reaches Ikiform untouched. Posts with the secret key skip Ikiform's human check, so turning **Spam check** on adds nothing for this form.

### Skip the reload of every loader

After an action, React Router reloads the data of every route on the page. A signup changes none of it. Add this to routes whose loaders are slow or cost money:

`app/routes/pricing.tsx`:

```ts
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}
```

## Rendering and deploy

Marketing pages don't change per visitor, so prerender them and keep the server for the action:

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: ["/", "/pricing"],
} satisfies Config;
```

Keep `ssr: true`. With `ssr: false` there is no runtime server, and React Router fails the build for any route that exports an `action`. With `ssr: true`, `/` and `/pricing` are written as HTML at build time, and `/waitlist` still runs on the server.

| Host | What to add | Reading the key |
| --- | --- | --- |
| Vercel | `@vercel/react-router`, with `presets: [vercelPreset()]` in `react-router.config.ts` | `process.env`, set under **Environment Variables** |
| Netlify | `@netlify/vite-plugin-react-router`, as `netlifyReactRouter()` in the Vite plugins | `process.env`, set under **Environment variables** |
| Cloudflare Workers | `@cloudflare/vite-plugin`, as `cloudflare()` before `reactRouter()` | `env.IKIFORM_SECRET_KEY` from `cloudflare:workers`, set with `wrangler secret put` |

On Cloudflare, `import { env } from "cloudflare:workers"` and read the key from it instead of `process.env`. In React Router 8 the action's `context` is a `RouterContextProvider`, so the v7 `context.cloudflare.env` pattern no longer applies. The rest of the action stays the same.

## Check it works

1. Run `react-router dev`, open `http://localhost:5173/pricing` and sign up in the footer. The thank-you line replaces the form, and the header note changes with it.
2. Open **Submissions** in the dashboard. The signup has `ref` set to `pricing`.
3. Turn JavaScript off and sign up from the home page. You land on `/waitlist` with the thank-you line, and the signup has `ref` set to `home`.
4. Submit `a@b` from the footer. The error shows under the input with the address still typed.
5. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
6. Posts from `react-router dev` carry a `localhost` origin, so they count as test submissions. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from `localhost`. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See [pricing](https://ikiform.com/pricing).

## Gotchas

| Problem | What to do |
| --- | --- |
| A footer signup navigates to `/waitlist` even with JavaScript on | The form is a `<Form>` or a plain `<form>`, not `fetcher.Form`. Use the fetcher, and don't return a `redirect()` from the action; a redirect from a fetcher's action navigates. |
| The build fails with an error about `action` exports | `ssr` is `false` in `react-router.config.ts`. Set it to `true`, or the waitlist has no server to run on. |
| Every signup's `ref` is `waitlist` | The posts come from the `/waitlist` page itself. That's the fallback page; links should point at your real pages. |
| `422` with `unknown_field` | The field definition doesn't list `ref`. Add it as in the definition above. |
| `402 plan_required` | The workspace has no plan and the post came from a live site. It is held, not lost. Pick a plan or buy a pack under **Settings › Plan and usage** and held submissions arrive. |

## Questions

### How do I submit a React Router form without navigating?

Use useFetcher and render fetcher.Form with the action of the route that handles it. The visitor stays on the page and the result arrives in fetcher.data. Before hydration it posts as a normal form.

### Can prerendered React Router pages have a working form?

Yes, when ssr is true. Prerendered pages are served as HTML, and their forms post to an action that runs on the server. With ssr set to false, actions aren't allowed.

### How do I know which page a React Router signup came from?

Put the page's path in a hidden ref input, as sourceFor does above. The path is known on the server and the client, so it works on prerendered pages and before hydration.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
- [Next.js waitlist](https://ikiform.com/guides/waitlist/nextjs.md): A Next.js App Router waitlist: a server action that forwards signups with a secret key, useActionState, an optimistic thank-you and a per-visitor limit.
- [TanStack Start waitlist](https://ikiform.com/guides/waitlist/tanstack-start.md): A TanStack Start waitlist: one server route answers both a plain form post and fetch, and validateSearch carries the source and the result in the URL.
- [Plain HTML waitlist](https://ikiform.com/guides/waitlist/html.md): A complete no-build waitlist page in plain HTML that works with JavaScript off, shows an inline thank-you when it loads, and falls back when it must.
- [Product Hunt launch list](https://ikiform.com/guides/waitlist/product-hunt.md): Collect a launch list weeks before Product Hunt, tag each signup by channel, and send the launch-day email in two waves timed to the Pacific Time reset.
- [Beta signup with screening](https://ikiform.com/guides/waitlist/beta-signup.md): A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.
