flowsk.com
Next.js

Next.js install guide

Middleware mints a durable id before React ships, a route handler proxies events through your own origin, and server actions confirm conversions. This is the ideal stack for it.

Aug 4, 2026· 3 min read ·Docs
Quick answer

middleware.ts sets the id → /api/events proxies → webhook confirms

Next.js middleware runs on every request, before rendering, on your own domain — which is exactly where a durable first-party identity should be minted. Most Next.js sites instead drop a client-side tag in layout.tsx and inherit every ITP problem for free.

App Router navigations are not document loads, so a naive tag records only the first page of every visit.

Next.js has the best primitives of any stack for this, and the ecosystem’s default advice ignores all of them.

1. Middleware mints the id

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

export function middleware(request: NextRequest) {
  const response = NextResponse.next()

  if (!request.cookies.get("_fsk_id")) {
    response.cookies.set("_fsk_id", crypto.randomUUID(), {
      maxAge: 60 * 60 * 24 * 730,  // 2 years
      sameSite: "lax",
      path: "/",
      httpOnly: false,             // the snippet reads the same id
      secure: process.env.NODE_ENV === "production"
    })
  }

  return response
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"]
}

This runs before rendering, on every request, including for statically generated pages. Because the cookie arrives in a response header rather than from document.cookie, Safari’s seven-day cap does not apply to it.

2. Proxy events through your own origin

// app/api/events/route.ts
export async function POST(request: Request) {
  const { events } = await request.json()
  const anonymousId = request.headers.get("cookie")?.match(/_fsk_id=([^;]+)/)?.[1]

  // Fire and forget — never make the visitor wait on our API.
  fetch("https://flowsk.com/api/events", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      write_key: process.env.FLOWSK_WRITE_KEY,
      anonymous_id: anonymousId,
      events
    })
  }).catch(() => {})

  return new Response(null, { status: 204 })
}

Read the id from the cookie header server-side, not from the request body — the browser cannot lie about it, and it stays correct even if the snippet failed to load.

3. The snippet, pointed at your endpoint

// 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://flowsk.com/flowsk.js"
          data-write-key={process.env.NEXT_PUBLIC_FLOWSK_WRITE_KEY}
          data-endpoint="/api/events"
          strategy="afterInteractive"
        />
      </body>
    </html>
  )
}

4. Track App Router navigations

Client-side navigation does not fire a document load, so without this you record the first page of every visit and nothing else.

// app/flowsk-pageviews.tsx
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect } from "react"

export function FlowskPageviews() {
  const pathname = usePathname()
  const searchParams = useSearchParams()

  useEffect(() => {
    window.flowsk?.page()
  }, [pathname, searchParams])

  return null
}

Render it inside a <Suspense> boundary in the root layout — useSearchParams requires one.

5. Confirm conversions server-side

A server action for signups:

"use server"
import { cookies } from "next/headers"

export async function createAccount(formData: FormData) {
  const email = formData.get("email") as string
  const user = await db.user.create({ data: { email } })

  await fetch("https://flowsk.com/api/events", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      write_key: process.env.FLOWSK_WRITE_KEY,
      anonymous_id: cookies().get("_fsk_id")?.value,
      events: [{
        source: "server",
        category: "identification",
        name: "signup_started",
        email,
        dedup_key: `user_${user.id}`
      }]
    })
  })
}

A Stripe webhook for purchases:

// app/api/webhooks/stripe/route.ts
if (event.type === "checkout.session.completed") {
  const session = event.data.object
  await postToFlowsk({
    anonymous_id: session.metadata.fsk_id,   // set at checkout creation
    events: [{
      source: "server",
      category: "purchase",
      name: "purchase",
      email: session.customer_details?.email,
      value_cents: session.amount_total,
      dedup_key: session.id                   // near-perfect key
    }]
  })
}

Put the visitor id into metadata.fsk_id when you create the Checkout Session — that is the join between browsing and payment.

Gotchas

Strict Mode double-fires. Effects run twice in development. Guard with a module-level flag; the dedup key makes it harmless in production regardless.

useSearchParams needs Suspense. Without it, the whole route opts out of static rendering.

Middleware matcher. Exclude /api and _next/static or you will mint ids for asset requests and inflate your visitor count.

Env vars. The write key is public and safe in NEXT_PUBLIC_. Keep the server-side one un-prefixed anyway — same value, but it keeps the two call sites clearly separated.

Frequently asked questions

Does middleware run for statically generated pages?

Yes. That is the point — a static page cannot set a per-visitor cookie, but middleware runs per request regardless of how the page is rendered.

Why proxy events through my own API route?

A POST to your own origin is not a cross-origin request, is on no tracker blocklist, and cannot be dropped by third-party request filtering. You forward server-to-server from there.

How do I avoid double events in React Strict Mode?

Effects run twice in development. Guard with a module-level flag, and send a dedup key so duplicates collapse regardless.

Does this work with the Pages Router?

Yes. Use middleware.ts identically and hook router.events.on('routeChangeComplete') for navigations.

What about Edge vs Node runtime?

Middleware runs on the Edge runtime, which has crypto.randomUUID and cookie APIs — everything this needs. The forwarding route handler can run on either.

Verify the cookie is durable

Run the cookie inspector against your deployed site. The _fsk_id cookie should show a long lifetime and Survives ITP: yes.

Inspect my cookies

Stop guessing which ad made the sale.

Flowsk Signals stitches the anonymous click to the email to the purchase — first-party, server-side, de-duplicated. One snippet, $29/mo, and every conversion comes with a receipt you can inspect.

Keep reading