Waitlist for a new SaaS

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.

Preet SutharFounder, Ikiform

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 postsThe current route, unless you set actionThe /waitlist route's action
After submitA navigation. The URL and history changeNo navigation. The visitor stays on the page
The resultactionData of the route that ranfetcher.data, shared by every fetcher with the same key
Without JavaScriptA normal postA 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.

Footer signups from every React Router page go through one route action/pricingref = pricingfetcher.Formkey: waitlist/waitlistactionforwards toIkiformfetcher.datasame page,header updates/blog/launchref =blog-launchplain formJavaScript notloaded/waitlistactionthe same code/waitlistpageactionDataPOSTPOSTpageformactionresultFooter signups from every React Router page go through one route action/pricingref = pricingfetcher.Formkey: waitlist/waitlist actionforwards to Ikiformfetcher.datasame page, header updates/blog/launchref = blog-launchplain formJavaScript not loaded/waitlist actionthe same code/waitlist pageactionDataPOSTPOST
  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 covers creating the form and a secret key. The files on this page:

Project
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
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 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
{
  "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
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
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
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
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
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.

HostWhat to addReading the key
Vercel@vercel/react-router, with presets: [vercelPreset()] in react-router.config.tsprocess.env, set under Environment Variables
Netlify@netlify/vite-plugin-react-router, as netlifyReactRouter() in the Vite pluginsprocess.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.

Gotchas

ProblemWhat to do
A footer signup navigates to /waitlist even with JavaScript onThe 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 exportsssr 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 waitlistThe posts come from the /waitlist page itself. That's the fallback page; links should point at your real pages.
422 with unknown_fieldThe field definition doesn't list ref. Add it as in the definition above.
402 plan_requiredThe 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.