Waitlist for a new SaaS7 min read
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.
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 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:
| Item | <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.
- The layout renders the waitlist form on every page, with a hidden ref made from the page's path.
- 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.
- Without JavaScript, the same form posts to /waitlist as a normal navigation.
- Either way, the /waitlist action checks the email and forwards the signup to Ikiform with the secret key.
- A fetcher reads the result from fetcher.data. A plain post lands on the /waitlist page, which shows it from actionData.
Set up the project
The server guide covers creating the form and a secret key. The files on this page:
app/
root.tsx
routes.ts
routes/home.tsx
routes/pricing.tsx
routes/waitlist.tsx
components/waitlist-form.tsx
react-router.config.ts
.envPut 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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
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
Originheader is passed on because Ikiform counts freelocalhosttest submissions by it, and the action's ownfetchsends none. refmust be lowercase letters, digits,-or_, up to 40 characters, the samemax_lengthas the definition. Anything else is stored asdirect.- The default export is the no-JavaScript fallback. It renders the same form with the action's result.
The shared form
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.JoinedNotein the header reads the samefetcher.data, so it changes the moment the footer form succeeds.sourceForturns/blog/launchintoblog-launchand/intohome. The path is known on the server, so it works on prerendered pages too.fetcher.stateissubmittingwhile the post runs, thenloadingwhile 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:
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:
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
- Run
react-router dev, openhttp://localhost:5173/pricingand sign up in the footer. The thank-you line replaces the form, and the header note changes with it. - Open Submissions in the dashboard. The signup has
refset topricing. - Turn JavaScript off and sign up from the home page. You land on
/waitlistwith the thank-you line, and the signup hasrefset tohome. - Submit
a@bfrom the footer. The error shows under the input with the address still typed. - 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. - Posts from
react-router devcarry alocalhostorigin, 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 fromlocalhost. 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.
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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.