Guides · WhiskerJS 0.1.0

Next.js

A 'use client' login form that collects on submit, plus two route handlers in the same app that issue the nonce and check the binding.

In plain words

In Next.js the sign-in form is a client component: it is drawn on the server first, then comes alive in the browser. WhiskerJS must only run in the browser part, so the form calls it only when the visitor presses the button. Two small route handlers in the same app hand out the one-time code and check the result when the form arrives.

Install: build from source

WhiskerJS is not on npm yet; the npm package is coming. Build it and install the tarball:

cd whiskerjs
pnpm install && pnpm build && pnpm pack    # writes whiskerjs-0.1.0.tgz
cd ../my-next-app
npm install ../whiskerjs/whiskerjs-0.1.0.tgz

The client component

components/LoginForm.tsx:

'use client';

import { useState, type FormEvent } from 'react';
import { load, type Agent } from 'whiskerjs';

// One agent per page, created on first use. load() is cheap and keeps no state.
let agent: Promise<Agent> | undefined;
const getAgent = () => (agent ??= load());

export function LoginForm() {
  const [status, setStatus] = useState('');
  const [busy, setBusy] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    setBusy(true);
    try {
      // A fresh nonce from your server, then collect. collect() never throws.
      const binding = await (await fetch('/api/whisker-nonce')).json();
      const device = await (await getAgent()).collect({ timeoutMs: 3000, binding });
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ email: form.get('email'), password: form.get('password'), device }),
      });
      setStatus(response.ok ? 'Signed in' : `Refused (${response.status})`);
    } catch {
      setStatus('Network error, please try again');
    } finally {
      setBusy(false);
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <label>Email <input name="email" type="email" required /></label>
      <label>Password <input name="password" type="password" required /></label>
      <button type="submit" disabled={busy}>Sign in</button>
      <output id="status">{status}</output>
    </form>
  );
}

app/page.tsx stays a server component and only renders the form:

import { LoginForm } from '../components/LoginForm';

// A server component: it renders the form; WhiskerJS runs only inside LoginForm, in the browser.
export default function Page() {
  return <LoginForm />;
}

Never during server rendering

'use client' does not mean “browser only”: Next.js renders client components on the server too, to produce the first HTML. Code at the top of the component body runs there. That is why load() and collect() appear only inside onSubmit, which runs when the visitor submits the form in the browser. The module-level agent variable is only assigned inside that handler, so it stays empty on the server.

If you ever need to collect without a user action, do it in a useEffect, which also runs only in the browser.

The route handlers

Both import lib/whisker-binding.ts, the Node module from Verify in Node, unchanged. WhiskerJS’s bindingInput and COMPONENT_NAMES are plain code with no browser dependency, so they work in a route handler.

app/api/whisker-nonce/route.ts issues a nonce for this browser’s session (a random sid cookie here; use your own session if you have one):

import { randomBytes } from 'node:crypto';
import { cookies } from 'next/headers';
import { issueNonce } from '../../../lib/whisker-binding';

export async function GET() {
  const jar = await cookies();
  let sid = jar.get('sid')?.value;
  if (!sid) {
    sid = randomBytes(16).toString('hex');
    jar.set('sid', sid, { httpOnly: true, sameSite: 'lax', path: '/' });
  }
  return Response.json(issueNonce(sid));
}

app/api/login/route.ts verifies the binding before anything else:

import { cookies } from 'next/headers';
import { verifyBinding } from '../../../lib/whisker-binding';

export async function POST(request: Request) {
  const sid = (await cookies()).get('sid')?.value ?? '';
  const body = await request.json().catch(() => null);
  const verdict = verifyBinding(body?.device, sid);
  if (!verdict.ok) return Response.json({ error: verdict.reason }, { status: 400 });
  // Fresh and unchanged since collection. Now check the password and use
  // body.device as one piece of evidence among others.
  return Response.json({ visitorId: body.device.visitorId, flags: body.device.integrityFlags });
}

Where the nonces live

whisker-binding.ts keeps issued nonces in a Map. That is correct only when one long-running process handles every request, as with a single next start. On a serverless platform, or with several instances, the nonce request and the login may reach different processes: keep the nonces in a shared store (your database or a cache) with an expiry of about a minute, and delete each one in the same step that reads it.

Things to know

  • Secure context. next dev on localhost and any https deployment work. On plain http elsewhere the ids are null.
  • TypeScript settings. Next.js creates a tsconfig.json with strict: false; the samples type-check with it as generated.
  • The result is evidence, not a verdict. After the binding passes, use the components, ids and flags in your own rules. See Integration.

Tested

Tested with WhiskerJS 0.1.0 (signalsVersion 5, idVersion 2), Next.js 16.4.3 (App Router, Turbopack build), React 19.3.1 and TypeScript 5.9.3 on 2026-10-26: built with next build, run with next start on Node 22.18 and submitted in headless Chromium 140 (Playwright 1.55.0). The route handlers accepted the binding and refused the same result sent a second time.

Questions and answers

Is 'use client' enough to keep WhiskerJS off the server?

No. Client components are still rendered on the server for the first HTML. What keeps WhiskerJS in the browser is calling load() and collect() only from an event handler or an effect, as this component does.

Can I store nonces in a Map in a route handler?

Only when one long-running Node process serves every request, as with a single next start. On serverless or several instances the nonce request and the login can reach different processes, so keep nonces in a shared store such as your database or a cache, with expiry.

Does this work with the Pages Router?

The form component works the same in the Pages Router without the 'use client' line. The server checks then go in API routes; this guide was tested with the App Router only.