Next.js

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.

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, 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).

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

Type to search…

↑↓ navigate open esc close