# Waitlist in Next.js

> Add a pre-launch waitlist form to your Next.js app using the Nordva Launch Waitlist API.

## Prerequisites

- Nordva Launch account with a project and publishable API key (`nv_pub_...`)
- Next.js 13+ (App Router or Pages Router both work)

## Get your publishable key

In the [Nordva Launch dashboard](https://app.nordva.dev), open **API Keys** and click **New publishable key**.
Name it "Homepage" or similar. Copy the key: it is shown only once.

Add it to your `.env.local`:

```ini
NEXT_PUBLIC_NORDVA_KEY=nv_pub_live_YOUR_KEY
```

## The component

Create `components/WaitlistForm.tsx`:

```typescript
'use client';

  const [email, setEmail] = useState('');
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'duplicate' | 'error'>('idle');
  const [position, setPosition] = useState<number | null>(null);

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    setStatus('loading');

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

    if (!res.ok) { setStatus('error'); return; }

    // Signup is idempotent: 201 = new, 200 with data.already_registered = duplicate.
    const { data } = await res.json();
    setPosition(data.position);
    setStatus(data.already_registered ? 'duplicate' : 'success');
  }

  if (status === 'success') return (
    <p>You&apos;re #{position} on the list. We&apos;ll be in touch.</p>
  );

  if (status === 'duplicate') return (
    <p>You&apos;re already on the list (#{position}).</p>
  );

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email" value={email} required
        onChange={e => setEmail(e.target.value)}
        placeholder="your@email.com"
        disabled={status === 'loading'}
      />
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Joining...' : 'Join waitlist'}
      </button>
      {status === 'error' && <p>Something went wrong. Please try again.</p>}
    </form>
  );
}
```

## Use it in your page

```tsx
// app/page.tsx

  return (
    <main>
      <h1>Coming soon</h1>
      <WaitlistForm />
    </main>
  );
}
```

## Zero-code alternative: script-tag widget

Drop this into any HTML page to get a working form with no React needed:

```html
<script src="https://cdn.nordva.dev/v1/waitlist.js"
  data-key="nv_pub_live_YOUR_KEY"
  data-placeholder="Enter your email"
  data-button="Join waitlist"
  data-success="You're in."
  data-theme="auto">
</script>
<nordva-waitlist></nordva-waitlist>
```

If your site sends a `Content-Security-Policy` header, allow `https://cdn.nordva.dev` in `script-src` and `https://api.nordva.dev` in `connect-src`: [Content Security Policy](/guides/content-security-policy).

## Where signups came from

Every signup can carry its first-touch **source**: the referring site, UTM parameters and the page the form was on. The script-tag widget and the hosted page at `launch.nordva.dev/waitlist/<slug>` collect it automatically. No cookie or browser storage is used, so it needs no consent banner. The values are read once from `document.referrer` and the page URL.

From your own component, pass it yourself:

```ts
const params = new URLSearchParams(window.location.search);
const referrer = document.referrer ? new URL(document.referrer).hostname : undefined;

body: JSON.stringify({
  email,
  source: {
    referrer: referrer !== window.location.hostname ? referrer : undefined,
    utm_source: params.get('utm_source') ?? undefined,
    utm_medium: params.get('utm_medium') ?? undefined,
    utm_campaign: params.get('utm_campaign') ?? undefined,
    landing_path: window.location.pathname,
  },
}),
```

| Field | Stored as | Notes |
|---|---|---|
| `source.referrer` | `source_referrer` | A URL or a hostname. Only the hostname is kept (`www.` removed), because full referrer URLs can carry tokens and personal data |
| `source.utm_source`, `utm_medium`, `utm_campaign` | same names | Max 255 characters each |
| `source.landing_path` | `landing_path` | Path only. Any query string or fragment is dropped |
| `source.visitor_id` | `visitor_id` | Optional. The analytics visitor id (the `nv_vid` cookie's value before the dot), when your site runs Launch's analytics script. Links the signup to the visits before it. The script-tag widget adds it for you; anything that is not an id is dropped |

All of `source` is optional, and a value that cannot be parsed is stored as empty instead of rejecting the signup. Only the first signup for an email records a source; a repeat signup never overwrites it. The source shows in the dashboard's **Waitlist** table and as the last five columns of the CSV export.

It only covers people who signed up through a Nordva Launch form. It is not page analytics.

If you embed the iframe widget from `widget.nordva.dev`, the iframe cannot see your page's URL or referrer. Forward the UTM values on the iframe URL (`/waitlist/<slug>?utm_source=...`) and they are recorded.

## Moving an existing list here

If you already have a list in another tool, import it. In the dashboard, open **Waitlist** and choose **Import CSV**. The file needs an `email` column; a signup date column (`created_at`, `joined`, `date`) and a `source` column are picked up when present, and a plain list with one email per line works too.

Through the API (secret key only, at most 1000 rows per call):

```bash
curl -X POST https://api.nordva.dev/v1/waitlist/import \
  -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "consent_confirmed": true,
    "rows": [
      { "email": "ada@example.com", "created_at": "2026-03-01T09:00:00Z" },
      { "email": "lin@example.com", "source": { "utm_source": "old-tool" } }
    ]
  }'
```

`consent_confirmed` must be `true`: you are confirming these people agreed to hear from you about this product. Imported rows are stored with `consent_source: "imported"`.

- Nobody is emailed by an import.
- A bad row never fails the file. The response reports `inserted`, `skipped`, and `skipped_by_reason` (`invalid_row`, `duplicate_in_file`, `already_on_list`, `disposable_email`, `plan_limit_reached`), plus the first 100 skipped rows with their index.
- Running the same file twice is safe. People already on the list are skipped.
- `created_at` keeps the original signup order, so positions survive the move.
- Imports count toward your plan's signup limit. What does not fit is reported, not dropped silently.

From an MCP client, ask your assistant to import the list; it uses the `import_waitlist` tool and will ask you to confirm consent first.

## Launch day: announce to the waitlist

One plain email to everyone waiting, from the dashboard (**Announce**) or over the API:

```bash
curl -X POST https://api.nordva.dev/v1/waitlist/announcements \
  -H "Authorization: Bearer nv_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "{{project_name}} is live",
    "body": "You joined the {{project_name}} waitlist. Today it opens.\n\nYour link: {{link}}",
    "audience": "waiting",
    "dry_run": true
  }'
```

`dry_run: true` returns the recipient count and the rendered preview without sending; drop it to send. `{{link}}` is the waitlist's `welcome_url` when you have set one, otherwise the person's hosted "you're in" page. Indie and up. One announcement per project in any 24 hours; people who unsubscribed never receive it; every message carries the one-click unsubscribe link. Sending is queued in batches and shares the platform's daily sending limit, so a large list takes a while; `GET /v1/waitlist/announcements` shows progress. An AI agent can do the same with `send_waitlist_announcement`, which previews first.

This is the one bulk email Launch sends. It is not a campaign tool: no sequences, reminders or segments.

## What you should see

On first submit for an email: `201 Created`. The form shows `You're #1 on the list.`

On a repeat submit of the same email: `200 OK` with `data.already_registered: true` (and the existing position). The form shows the duplicate message: signup is idempotent, so duplicates are never an error.

## View your signups

In the [Nordva Launch dashboard](https://app.nordva.dev) → **Waitlist**, or via the API:

```bash
curl https://api.nordva.dev/v1/waitlist/signups \
  -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY"
```

## Common errors

| Error | Cause | Fix |
|---|---|---|
| `401 INVALID_API_KEY` | Wrong key or key not found | Re-copy key from dashboard |
| `403 KEY_INSUFFICIENT_PERMISSIONS` | Secret key used in browser code | Use `nv_pub_...` key, not `nv_live_...` |
| CORS error in console | Wrong endpoint URL | Verify URL is `https://api.nordva.dev/v1/waitlist/signups` |
| `422 WAITLIST_PLAN_LIMIT_REACHED` | Plan cap hit (Free: 100, Indie: 2,500, Builder: 25,000) | Upgrade plan |

Source: https://docs.nordva.dev/guides/waitlist-nextjs/
