Guides

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.

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.

Add the SDK and verify the result

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

  2. Step 2: Install the packages

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

    npm install @fingerly/next @fingerly/node
    
    .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.

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

    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>
      )
    }
    
  4. 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.

    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>
      )
    }
    
  5. 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.

    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 explains each check, and the use-case recipes include a helper that does all of them.

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

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>
  )
}

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 has the route and the provider setting, and 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.
  • Run in observe mode first: log what each level would have done before you enforce it. See plan 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.