# Add device fingerprinting to a Next.js app

> Add device fingerprinting to a Next.js App Router app: get a visitor ID in a client component, then verify it in a Server Action with your secret key.

Last updated: 2026-11-12

This tutorial adds **device fingerprinting** to a Next.js app on the App Router. By the end, a sign-up form gets a visitor ID in the browser, passes the request ID to a Server Action, and the Server Action reads the stored result with a secret key before it creates the account. The same shape works for logins, checkouts and promotions.

## Before you start

- Next.js 15 or newer, with React 19. On the Pages Router the steps are the same, with the provider in `_app.tsx`.
- A Fingerly account. New accounts start with credit, and development keys are free.
- A decision about consent. The SDK collects nothing until its `consent` is `granted`. See [privacy and consent](https://docs.fingerly.io/docs/privacy-and-consent).

## Add the SDK and verify the result

### Step 1: Create your keys

In the Fingerly dashboard, open **Integration > SDK keys** and create a **public** key in the **development** environment. Add the origin your app runs on as an allowed origin. A public key with no allowed origins refuses every browser request. Then create a **secret** key in the same environment for your server.

Development keys run the same detection as production and cost nothing. See [API keys and environments](https://docs.fingerly.io/docs/api-keys).

### Step 2: Install the packages

`@fingerly/next` is the browser side. `@fingerly/node` reads events on the server.

```bash npm
npm install @fingerly/next @fingerly/node
```

```bash pnpm
pnpm add @fingerly/next @fingerly/node
```

```bash yarn
yarn add @fingerly/next @fingerly/node
```

```bash .env.local
FINGERLY_API_KEY=fly_pk_development_…
FINGERLY_SECRET_KEY=fly_sk_development_…
```

The public key is read from `process.env` on the server render and handed to the browser with the page, so it needs no `NEXT_PUBLIC_` prefix. The secret key never leaves your server.

### Step 3: Add the provider to your root layout

The layout stays a Server Component. The provider is the client boundary, collects nothing when it mounts, and shares one identification across the page.

```tsx app/layout.tsx
import { FingerlyProvider } from '@fingerly/next'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {/* consent="granted" only once you have consent (or another lawful basis) */}
        <FingerlyProvider apiKey={process.env.FINGERLY_API_KEY} consent="granted">
          {children}
        </FingerlyProvider>
      </body>
    </html>
  )
}
```

### Step 4: Identify when the visitor acts

Call `identify()` at the moment worth protecting, with a tag that names the action. The tag binds the result to this action, so your server can refuse a request ID that came from somewhere else.

```tsx app/signup/SignUpForm.tsx
'use client'
import { useIdentify } from '@fingerly/next'
import { signUp } from './actions'

export function SignUpForm() {
  const { identify, isLoading } = useIdentify({ tag: 'signup' })

  async function onSubmit(formData: FormData) {
    let requestId: string | undefined
    try {
      ;({ requestId } = await identify())
    } catch {
      // Carry on: your server treats a missing request ID as missing evidence.
    }
    await signUp(String(formData.get('email')), requestId)
  }

  return (
    <form action={onSubmit}>
      <input name="email" type="email" required />
      <button disabled={isLoading}>Create account</button>
    </form>
  )
}
```

### Step 5: Verify in the Server Action

Read the stored event with the request ID, and check it exists, belongs to this action, and is recent. Then use `suspect_level` and `visitor_id` in your own rules.

```ts app/signup/actions.ts
'use server'
import { FingerlyAPIError, load } from '@fingerly/node'

const fingerly = load({ secretKey: process.env.FINGERLY_SECRET_KEY! })

async function readEvent(requestId: string) {
  try {
    return await fingerly.events.get(requestId)
  } catch (error) {
    // Never issued in this environment, or past retention.
    if (error instanceof FingerlyAPIError && error.code === 'event_not_found') return null
    throw error // still pending, or unexpected: ask the visitor to retry, never refuse on it
  }
}

export async function signUp(email: string, requestId: string | undefined) {
  const event = requestId ? await readEvent(requestId) : null

  // No usable identification is missing evidence, not proof of fraud.
  if (!event || event.tag !== 'signup') return { status: 'verify' as const }
  if (Date.now() - Date.parse(event.occurred_at) > 2 * 60 * 1000) return { status: 'verify' as const }

  if (event.suspect_level === 'high') return { status: 'review' as const }

  await createAccount({ email, visitorId: event.visitor_id }) // your own code
  return { status: 'created' as const }
}
```

An event that is not recorded yet comes back as pending, which is not a verdict. Wait the `Retry-After` the error carries and read again, and also check the request ID has not been used before. [Server-side verification](https://docs.fingerly.io/docs/server-side-verification) explains each check, and the [use-case recipes](https://docs.fingerly.io/docs/use-cases) include a helper that does all of them.

### Step 6: Try it

Run the app, submit the form, then open **Identification > Events** in the dashboard. The identification is there with its visitor, score and signals. Now try a private window, a VPN or an automated browser, and watch the score change.

## Carry the request ID in a hidden field instead

For a form that posts straight to a Server Action, `FingerlyRequestId` renders a hidden input that fills itself once the identification finishes, and `readRequestId` reads it back on the server.

```tsx app/contact/page.tsx
import { FingerlyRequestId } from '@fingerly/next'
import { submit } from './actions'

export default function Page() {
  return (
    <form action={submit}>
      <input name="email" />
      <FingerlyRequestId />
      <button>Send</button>
    </form>
  )
}
```

```ts app/contact/actions.ts
'use server'
import { readRequestId } from '@fingerly/next/server'

export async function submit(formData: FormData) {
  const requestId = readRequestId(formData)
  // read the stored event for requestId with a secret key, then decide
}
```

The field carries the page's one identification, so it is not bound to an action by a tag. Check the event is recent and unused, or call `identify` with a tag as above when the action needs it.

## Get past content blockers

Content blockers refuse requests to third-party hosts. Serve the SDK's submissions from your own origin through a route handler, so they travel as ordinary first-party requests. The [Next.js SDK page](https://docs.fingerly.io/docs/sdks/nextjs#proxy-through-a-route-handler) has the route and the provider setting, and [proxy integrations](https://docs.fingerly.io/docs/proxy-integrations) explains the model.

## Going to production

- Create **production** public and secret keys, with your real origins on the public key.
- Identify only at the moments that carry risk or value. Each production identification is billed, and requests that could not identify a device are free. See [billing](https://docs.fingerly.io/docs/billing).
- Run in observe mode first: log what each level would have done before you enforce it. See [plan your integration](https://docs.fingerly.io/docs/planning-your-integration).

## Common questions

### Does device fingerprinting work with the Next.js App Router and the Pages Router?

Yes. `@fingerly/next` supports both. On the App Router the provider goes in the root layout, which stays a Server Component. On the Pages Router, render the same provider in `_app.tsx`.

### Can I fingerprint a visitor in a Server Component or on the server?

No. The identification happens in the browser. During a server render the hooks return the unidentified state so hydration always matches, and calling `identify()` there rejects with `FingerlyServerError`. Call it from an event handler or an effect.

### Do I need a NEXT_PUBLIC variable for the key?

Not if you pass the key to the provider as a prop, as above. A server render reads `process.env` at runtime, so any variable name works. The provider can also read `NEXT_PUBLIC_FINGERLY_API_KEY` itself.

### Where does the secret key go?

On your server only. Secret keys are refused when a request carries a browser `Origin` header, so they cannot be used from client code even by mistake.

### What happens if identification fails?

Let the visitor continue. `identify()` rejects with a `TransportError` or, until consent is granted, a `ConsentError`. Your server receives no request ID and treats that as missing evidence, for example by asking for verification, rather than blocking.

### How do I stop ad blockers from interfering?

Serve the SDK from your own domain with a first-party proxy, mounted as a route handler. The path is random and unique to your proxy, so no rule written for another site matches it.

- [Next.js SDK](https://docs.fingerly.io/docs/sdks/nextjs): Every export, option and entry point.
- [Server-side verification](https://docs.fingerly.io/docs/server-side-verification): The four checks before you trust a result.
- [Sign-up abuse](https://docs.fingerly.io/docs/use-cases/sign-up-abuse): Limit accounts per device.
- [What is device fingerprinting?](https://docs.fingerly.io/docs/device-fingerprinting): The idea, and what it cannot do.
