Guide

Next.js

Load the script in a client component, then verify the token in an App Router route handler.

Environment

NEXT_PUBLIC_NOLGARD_SITE_KEY=pk_...
NOLGARD_SECRET=sk_...

Only the site key is public. The secret must not get a NEXT_PUBLIC_ prefix.

Form component

// app/signup/signup-form.tsx
'use client';
import Script from 'next/script';
import { useState, type FormEvent } from 'react';

declare global {
  interface Window {
    Nolgard: {
      ready: Promise<void>;
      execute(options: { action: string }): Promise<string>;
    };
  }
}

export function SignupForm() {
  const [ready, setReady] = useState(false);
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    await window.Nolgard.ready;
    const token = await window.Nolgard.execute({ action: 'signup' });
    const response = await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token, email: new FormData(form).get('email') }),
    });
    // Show your own success or error state from response.ok.
  }

  return (
    <>
      <Script src="https://nolgard.com/v1/client.js" data-site-key={process.env.NEXT_PUBLIC_NOLGARD_SITE_KEY} onReady={() => setReady(true)} />
      <form onSubmit={onSubmit}>
        <input name="email" type="email" required />
        <button type="submit" disabled={!ready}>Create account</button>
      </form>
    </>
  );
}

Route handler

// app/api/signup/route.ts
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { token, email } = await request.json();
  // Set by your hosting platform's proxy. Don't trust it on a bare server.
  const remoteIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();

  let result;
  try {
    const response = await fetch('https://nolgard.com/v1/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        secret: process.env.NOLGARD_SECRET,
        token,
        remoteIp,
        action: 'signup',
        account: { email },
      }),
      cache: 'no-store',
      signal: AbortSignal.timeout(3000),
    });
    if (response.status >= 500) throw new Error('nolgard unavailable');
    result = await response.json();
  } catch {
    result = { success: true, decision: 'allow' }; // timeout or 5xx: fail open
  }

  if (!result.success) {
    if (result.error === 'invalid-secret') { // config error: fail closed
      return NextResponse.json({ error: 'try-again' }, { status: 503 });
    }
    if (result.error !== 'over-quota') return NextResponse.json({ error: 'rejected' }, { status: 403 });
    result = { success: true, decision: 'allow' }; // over quota: fail open
  }

  switch (result.decision) {
    case 'allow':
      await createAccount(email);
      return NextResponse.json({ ok: true });
    case 'limit':
      await createAccount(email, { trialDays: 3 });
      return NextResponse.json({ ok: true });
    case 'shadow':
      return NextResponse.json({ ok: true }); // looks the same, creates nothing
    case 'challenge':
      return NextResponse.json({ error: 'confirm-email' }, { status: 403 });
    default:
      return NextResponse.json({ error: 'rejected' }, { status: 403 });
  }
}

Use your own createAccount function. Tokens are single-use. The form fetches a new token on every submit. See decisions and modes for each decision's meaning.