How to add a waitlist to your Next.js app
A complete guide to adding a production-ready waitlist to Next.js using the Enlist API: route handler, form component, position display, and error handling.
Adding a waitlist to a Next.js app takes about 10 minutes with the Enlist API. Your API key stays on the server. The browser gets the position. Here's the complete integration.
1. Get an API key
Create an account and generate an API key under API keys. Keys look like en_live_… (shown once at creation). Add it to your .env.local:
ENLIST_API_KEY=en_live_your_key_here
WAITLIST_ID=your_waitlist_id_hereCreate a waitlist in the dashboard or via the API and copy its ID.
2. Create the route handler
The route handler runs on the server. Your API key never touches the browser.
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
if (!body?.email) {
return Response.json({ error: 'Email is required' }, { status: 400 });
}
const utmSource = new URL(request.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: body.email, utm_source: utmSource }),
},
);
if (!res.ok) {
const error = await res.json().catch(() => ({}));
return Response.json(
{ error: error.error?.message ?? 'Something went wrong' },
{ status: res.status },
);
}
const { position, total } = await res.json();
return Response.json({ position, total });
}3. Create the form component
The form component is a client component. It posts to the route handler and displays the position.
'use client';
import { useState } from 'react';
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; position: number; total: number }
| { status: 'error'; message: string };
export function WaitlistForm() {
const [state, setState] = useState<State>({ status: 'idle' });
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const email = new FormData(e.currentTarget).get('email') as string;
setState({ status: 'loading' });
try {
const res = await fetch('/api/waitlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!res.ok) {
setState({ status: 'error', message: data.error ?? 'Something went wrong' });
return;
}
setState({ status: 'success', position: data.position, total: data.total });
} catch {
setState({ status: 'error', message: 'Network error. Please try again.' });
}
}
if (state.status === 'success') {
return (
<p>
You're <strong>#{state.position}</strong> of {state.total} on the waitlist.
</p>
);
}
return (
<form onSubmit={handleSubmit}>
<input
name="email"
type="email"
required
placeholder="you@example.com"
disabled={state.status === 'loading'}
/>
<button type="submit" disabled={state.status === 'loading'}>
{state.status === 'loading' ? 'Joining…' : 'Join waitlist'}
</button>
{state.status === 'error' && <p role="alert">{state.message}</p>}
</form>
);
}4. Use it
Drop the form component anywhere on your page:
import { WaitlistForm } from './waitlist-form';
export default function Page() {
return (
<main>
<h1>Coming soon</h1>
<WaitlistForm />
</main>
);
}What happens on signup
- The position is assigned server-side, atomically. No race conditions.
- Re-submitting the same email returns the original position. No duplicate signups, no duplicate email.
utm_sourceis captured from the query parameter on the route handler URL if present.
Environment variables in production
Set ENLIST_API_KEY and WAITLIST_ID as environment variables in your deployment (Vercel, Railway, Fly, etc.). Never commit them.
The App Router exposes server-only env vars (without NEXT_PUBLIC_ prefix) only to server code: route handlers, server components, server actions. The route handler above keeps the key server-side correctly.