URL: /guides/nextjs

---
title: Next.js
description: Add the FormShield beacon and optional server reporting to a Next.js app
---

Integrate FormShield in Next.js in two layers: the beacon for client pageviews, and optional server-side reporting from middleware to catch crawlers that never run JavaScript. The beacon alone is enough to start.

This guide uses the App Router. You need a publishable key (`fs_pub_live_…`) from your project's Settings.

## Add the beacon

Load the beacon with `next/script` using `strategy="afterInteractive"`. Put it in your root layout so it loads once per document.

```tsx
// app/layout.tsx
import Script from "next/script"

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

`strategy="afterInteractive"` loads the beacon after the page is interactive, so it never blocks render. Replace `fs_pub_live_…` with your key. Load any page and the pageview appears in the dashboard Logs.

<Note>
  The publishable key is safe in client code. Putting it in a `NEXT_PUBLIC_` environment variable is optional and only helps you swap keys between environments.
</Note>

### Route changes

`data-fs-mode="pageload"` records a pageview on the initial document load. Client-side navigations within the App Router do not reload the document, so they are not yet recorded as separate pageviews — that is a follow-up. For per-route analytics today, also use [server reporting](#capture-crawlers-with-server-reporting), which sees every request the server handles.

## Capture crawlers with server reporting

The beacon never runs for crawlers and AI agents. Report requests from `middleware.ts` to capture them. Next.js middleware runs on the Edge Runtime, which gives you a `waitUntil` on the middleware event — send the report in the background so it adds no latency, and swallow errors so a FormShield outage never breaks a request.

```ts
// middleware.ts
import { NextResponse, type NextRequest } from "next/server"

// Skip static assets so you only report real page requests.
export const config = { matcher: ["/((?!_next/|favicon.ico).*)"] }

async function reportToFormShield(request: NextRequest): Promise<void> {
  try {
    await fetch("https://api.formshield.dev/v1/report", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.FORMSHIELD_KEY}`,
      },
      body: JSON.stringify({
        ua: request.headers.get("user-agent") ?? undefined,
        ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
        hostname: request.nextUrl.hostname,
        path: request.nextUrl.pathname,
        action: "pageview",
      }),
    })
  } catch {
    // reporting must never break the request
  }
}

export default function middleware(
  request: NextRequest,
  event: { waitUntil(promise: Promise<unknown>): void },
) {
  // waitUntil keeps the report alive after the response returns — zero added latency.
  event.waitUntil(reportToFormShield(request))
  return NextResponse.next()
}
```

Store your key as `FORMSHIELD_KEY` in `.env.local` (or your host's secrets).

<Warning>
  Outside the Edge Runtime, an unawaited fetch can be killed when the function returns. Either run middleware on the Edge Runtime (default for Next.js middleware) and use `event.waitUntil`, or await the fetch with a short timeout (`AbortSignal.timeout(500)`). See the [server reporting guide](/guides/server-reporting#how-it-behaves) for the full trade-off.
</Warning>

### Report from a route handler

To report only specific routes, call the same helper from a route handler instead of middleware. Do not block your response on it:

```ts
// app/api/track/route.ts
import { NextResponse, type NextRequest } from "next/server"

export async function POST(request: NextRequest) {
  void fetch("https://api.formshield.dev/v1/report", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.FORMSHIELD_KEY}`,
    },
    body: JSON.stringify({
      ua: request.headers.get("user-agent") ?? undefined,
      ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
      hostname: request.nextUrl.hostname,
      path: request.nextUrl.pathname,
      action: "pageview",
    }),
  }).catch(() => {})

  return NextResponse.json({ ok: true })
}
```

## Next steps

<CardGroup cols={2}>
  <Card title="Pageview tracking" icon="chart-line" href="/guides/pageview-tracking">
    Every beacon attribute and the observation shape.
  </Card>
  <Card title="Server reporting" icon="server" href="/guides/server-reporting">
    The full `/v1/report` reference and fire-and-forget pattern.
  </Card>
</CardGroup>
