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
consentisgranted. See 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.
Step 2: Install the packages
@fingerly/nextis the browser side.@fingerly/nodereads events on the server.npm install @fingerly/next @fingerly/nodeFINGERLY_API_KEY=fly_pk_development_… FINGERLY_SECRET_KEY=fly_sk_development_…The public key is read from
process.envon the server render and handed to the browser with the page, so it needs noNEXT_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.
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.'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_levelandvisitor_idin your own rules.'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-Afterthe 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.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.