# 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.

Source: https://whiskerjs.com/guides/nextjs/ (last updated 2026-10-26)

> **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:

```sh
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`:

```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:

```tsx
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](https://whiskerjs.com/guides/verify-node/#the-verification-module), 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):

```ts
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:

```ts
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](https://whiskerjs.com/docs/integration/#recommended-server-checks).

## 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.

## Related pages

- [React](https://whiskerjs.com/guides/react/): The same form explained line by line.
- [Verify in Node](https://whiskerjs.com/guides/verify-node/): The whisker-binding.ts module the route handlers import.
- [All integration guides](https://whiskerjs.com/guides/): The page-to-server contract.
- [Anti-replay binding](https://whiskerjs.com/docs/integration/#anti-replay-binding): What the digest proves and what it does not.
