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.