Tutorial

How to add a waitlist to your Remix app

A complete guide to adding a production-ready waitlist to Remix using the Enlist API: action function, form with useFetcher, and position display.

RemixintegrationReact

Remix's action functions run on the server, making them the right place to call the Enlist API. Here's the complete integration.

1. Get an API key

Create an account and generate a key under API keys. Add to your .env:

ENLIST_API_KEY=en_live_your_key_here
WAITLIST_ID=your_waitlist_id_here

2. Create the action

app/routes/waitlist.ts
import { json } from '@remix-run/node';
import type { ActionFunctionArgs } from '@remix-run/node';

export async function action({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  const email = form.get('email');

  if (!email || typeof email !== 'string') {
    return json({ error: 'Email is required' }, { status: 400 });
  }

  const url = new URL(request.url);
  const utmSource = url.searchParams.get('utm_source') ?? undefined;

  const res = await fetch(
    `https://api.enlist.dev/v1/waitlists/${process.env.WAITLIST_ID}/signups`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.ENLIST_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email, utm_source: utmSource }),
    },
  );

  if (!res.ok) {
    const data = await res.json().catch(() => ({}));
    return json({ error: data.error?.message ?? 'Something went wrong' }, { status: res.status });
  }

  const { position, total } = await res.json();
  return json({ position, total });
}

3. Create the form component

useFetcher keeps the user on the page, avoiding a full-page navigation on submit.

app/components/WaitlistForm.tsx
import { useFetcher } from '@remix-run/react';

type ActionData =
  | { position: number; total: number; error?: never }
  | { error: string; position?: never; total?: never };

export function WaitlistForm() {
  const fetcher = useFetcher<ActionData>();
  const data = fetcher.data;
  const isLoading = fetcher.state !== 'idle';

  if (data && !data.error) {
    return (
      <p>
        You&apos;re <strong>#{data.position}</strong> of {data.total} on the waitlist.
      </p>
    );
  }

  return (
    <fetcher.Form method="post" action="/waitlist">
      <input
        name="email"
        type="email"
        required
        placeholder="you@example.com"
        disabled={isLoading}
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Joining…' : 'Join waitlist'}
      </button>
      {data?.error && <p role="alert">{data.error}</p>}
    </fetcher.Form>
  );
}

4. Use it

app/routes/_index.tsx
import { WaitlistForm } from '~/components/WaitlistForm';

export default function Index() {
  return (
    <main>
      <h1>Coming soon</h1>
      <WaitlistForm />
    </main>
  );
}

Notes

Remix's process.env in action functions is server-only, so the key never reaches the client bundle. The useFetcher approach avoids a page navigation on submit, which gives you fine-grained control over the success and error states in React.

Re-submitting the same email returns the existing position with no duplicate signup or email.

Related posts

How to add a waitlist to your Remix app · Enlist