URL: /guides/nextjs-clerk

---
title: Next.js + Clerk
description: Wire the FormShield beacon and the sessions/resolve bind into a Next.js 15/16 App Router app with Clerk — next/script, cookies(), Route Handlers, and the Clerk webhook.
---

This is the copy-pasteable Next.js App Router version of the [Clerk integration](/guides/clerk-integration) guide. You add the beacon with `next/script`, read the `_fs` cookie with `next/headers`, and call `POST /v1/sessions/resolve` from a Route Handler, a server action wrapped in `after()`, or the Clerk webhook route — binding the Clerk `userId` to FormShield's edge verdict.

Targets Next.js 15/16, the App Router, and `@clerk/nextjs`. You need a publishable key (`fs_pub_live_…`) for the beacon and a secret key with the `sessions` scope (`fs_live_…`) for the resolve call.

## Environment variables

```bash
# .env.local
NEXT_PUBLIC_FORMSHIELD_PROJECT_KEY=fs_pub_live_xxx  # beacon, safe in the browser
FORMSHIELD_SECRET_KEY=fs_live_xxx                   # sessions scope — SERVER ONLY
```

<Warning>
  `FORMSHIELD_SECRET_KEY` has no `NEXT_PUBLIC_` prefix on purpose. The resolve call is anti-tamper because the secret key — and the verdict it returns — never touch client code. Keep it server-side.
</Warning>

## Add the beacon

Load the beacon with `next/script` using `strategy="afterInteractive"` in your root layout, so it runs once per document across every page including Clerk's sign-up and sign-in routes.

```tsx
// app/layout.tsx
import Script from "next/script"
import { ClerkProvider } from "@clerk/nextjs"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          {children}
          <Script
            src="https://api.formshield.dev/js/formshield.js"
            strategy="afterInteractive"
            data-fs-project-key={process.env.NEXT_PUBLIC_FORMSHIELD_PROJECT_KEY}
            data-fs-mode="pageload"
          />
        </body>
      </html>
    </ClerkProvider>
  )
}
```

The beacon sets a first-party `_fs` cookie on its first `/v1/collect` call and scores the visitor at the edge. `strategy="afterInteractive"` keeps it off the render path.

## Read the `_fs` cookie server-side

Read the `_fs` cookie from the incoming request with `cookies()` from `next/headers`. In Next.js 15/16, `cookies()` is async — `await` it.

```ts
// lib/formshield.ts
import { cookies, headers } from "next/headers"

export async function readFsSession(): Promise<string | undefined> {
  const cookieStore = await cookies()
  return cookieStore.get("_fs")?.value
}

export async function clientIp(): Promise<string | undefined> {
  const h = await headers()
  return h.get("x-forwarded-for")?.split(",")[0]?.trim()
}
```

## The resolve helper

A single best-effort helper both the Route Handler and the webhook can call. It never throws — on any failure it returns `null` so auth proceeds.

```ts
// lib/formshield.ts (continued)
type ResolveInput = {
  sessionId: string | undefined
  externalUserId: string // Clerk userId
  ip?: string
  email?: string
  observationType: "auth.signup" | "auth.login"
}

export type ResolveData = {
  request_id: string
  bound: boolean
  verdict: { score: number; decision: "allow" | "review" | "block"; reasons: string[] }
  continuity: {
    found: boolean
    first_seen: number
    last_seen: number
    age_seconds: number
    observation_count: number
    is_fresh: boolean
  }
  correlation: {
    accounts_sharing_session: number
    accounts_sharing_fingerprint: number
    accounts_sharing_ip: number
    linked_emails: number
  }
  processing_time_ms: number
}

export async function resolveSession(input: ResolveInput): Promise<ResolveData | null> {
  try {
    const res = await fetch("https://api.formshield.dev/v1/sessions/resolve", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.FORMSHIELD_SECRET_KEY}`,
      },
      body: JSON.stringify({
        session_id: input.sessionId,
        external_user_id: input.externalUserId,
        ip: input.ip,
        email: input.email,
        observation_type: input.observationType,
      }),
      signal: AbortSignal.timeout(800), // never block auth on a slow lookup
    })
    if (!res.ok) return null
    const json = (await res.json()) as { data: ResolveData | null }
    return json.data
  } catch {
    return null // best-effort, non-blocking
  }
}
```

## Bind from a Route Handler

Call this Route Handler from your client right after Clerk establishes a session (post-signup and post-login). It reads the cookie and the current Clerk user server-side, resolves, and acts on the verdict.

```ts
// app/api/auth/bind/route.ts
import { NextResponse } from "next/server"
import { auth, currentUser } from "@clerk/nextjs/server"
import { readFsSession, clientIp, resolveSession } from "@/lib/formshield"

export async function POST(request: Request) {
  const { userId } = await auth()
  if (!userId) return NextResponse.json({ ok: false }, { status: 401 })

  const { kind } = (await request.json().catch(() => ({}))) as {
    kind?: "auth.signup" | "auth.login"
  }
  const user = await currentUser()

  const data = await resolveSession({
    sessionId: await readFsSession(),
    externalUserId: userId,
    ip: await clientIp(),
    email: user?.primaryEmailAddress?.emailAddress,
    observationType: kind ?? "auth.login",
  })

  if (data) {
    const { verdict, correlation } = data
    if (verdict.decision === "block" || correlation.accounts_sharing_fingerprint > 10) {
      await flagUserForReview(userId, data.request_id)
    } else if (verdict.decision === "review" || correlation.accounts_sharing_ip > 5) {
      await requireStepUpVerification(userId)
    }
  }

  // Always 200 — the bind is advisory and must never block the user.
  return NextResponse.json({ ok: true })
}

async function flagUserForReview(_userId: string, _requestId: string) {}
async function requireStepUpVerification(_userId: string) {}
```

## Bind from a server action with `after()`

If you create the account inside a server action, do the resolve in `after()` so it runs *after* the response is flushed — zero added latency on the user's signup.

```ts
// app/actions/finish-signup.ts
"use server"

import { after } from "next/server"
import { auth, currentUser } from "@clerk/nextjs/server"
import { readFsSession, clientIp, resolveSession } from "@/lib/formshield"

export async function finishSignup() {
  const { userId } = await auth()
  if (!userId) return

  // Capture request-scoped values now; after() runs post-response.
  const sessionId = await readFsSession()
  const ip = await clientIp()
  const email = (await currentUser())?.primaryEmailAddress?.emailAddress

  after(async () => {
    const data = await resolveSession({
      sessionId,
      externalUserId: userId,
      ip,
      email,
      observationType: "auth.signup",
    })
    if (data && (data.verdict.decision !== "allow" ||
        data.correlation.accounts_sharing_fingerprint > 10)) {
      // enqueue review / step-up out of band
    }
  })
}
```

<Note>
  Read `cookies()` and `headers()` *before* the `after()` callback. Inside `after()` the response is already sent and the request context may no longer be readable — capture the `_fs` value and IP up front.
</Note>

## Bind from the Clerk webhook

Clerk's `user.created` webhook (delivered via Svix) is the catch-all: it fires for every new account regardless of how it was created. Verify the Svix signature, then resolve with the Clerk `userId`.

```ts
// app/api/webhooks/clerk/route.ts
import { Webhook } from "svix"
import { headers } from "next/headers"
import { resolveSession } from "@/lib/formshield"

export async function POST(request: Request) {
  const payload = await request.text()
  const h = await headers()

  let evt: {
    type: string
    data: { id: string; email_addresses: { email_address: string }[] }
    event_attributes?: { http_request?: { client_ip?: string } }
  }
  try {
    const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!)
    evt = wh.verify(payload, {
      "svix-id": h.get("svix-id")!,
      "svix-timestamp": h.get("svix-timestamp")!,
      "svix-signature": h.get("svix-signature")!,
    }) as typeof evt
  } catch {
    return new Response("invalid signature", { status: 400 })
  }

  if (evt.type === "user.created") {
    // Server-to-server webhook: no _fs cookie. Clerk includes the originating
    // request IP, so bind on that (an IP join). Resolve needs at least one of
    // session_id / fingerprint / ip — without the IP there is nothing to bind on.
    const ip = evt.event_attributes?.http_request?.client_ip
    if (ip) {
      const data = await resolveSession({
        sessionId: undefined,
        ip,
        externalUserId: evt.data.id,
        email: evt.data.email_addresses[0]?.email_address,
        observationType: "auth.signup",
      })
      if (data && data.verdict.decision !== "allow") {
        // flag / review out of band — never block the webhook
      }
    }
  }

  return new Response("ok", { status: 200 })
}
```

<Note>
  The webhook request does not carry the visitor's `_fs` cookie, so it can only do an **IP join** via `event_attributes.http_request.client_ip`. Prefer the Route Handler / server-action bind (it has the cookie) as your primary path. To get cookie-strength correlation in the webhook, persist the `_fs` value at signup (e.g. stash it server-side keyed by the Clerk session id) and pass it as `session_id`.
</Note>

## Reading the response

Every resolve returns `{ version, data, error, metadata }`; the helper above hands you `data` (typed as `ResolveData`). The fake-signup tell lives in `correlation` — counts of accounts sharing a fingerprint, IP, or session — read alongside `verdict.decision`. See [Act on the response](/guides/clerk-integration#4-act-on-the-response) for the full payload and branching logic, and [Auth Protection](/products/auth-protection) for every field and the credit weights.

## Next steps

<CardGroup cols={2}>
  <Card title="Clerk integration" icon="key" href="/guides/clerk-integration">
    The framework-agnostic version — the full contract, the verdict and correlation fields, and the security model.
  </Card>
  <Card title="Auth Protection" icon="user-shield" href="/products/auth-protection">
    The product reference: resolve fields, credits, and the `/v1/auth/check` pre-flight.
  </Card>
  <Card title="Next.js" icon="react" href="/guides/nextjs">
    The base Next.js beacon + server reporting guide for pageview tracking.
  </Card>
  <Card title="Server reporting" icon="server" href="/guides/server-reporting">
    Capture full requests — UA and IP together — including crawlers and AI bots.
  </Card>
</CardGroup>
