fix(analytics): identify page views via fetch metadata, not router headers

Next.js strips the router-internal headers (RSC, next-router-prefetch)
before the request proxy runs, so prefetch detection based on them never
fired: link prefetches were counted as page views. A page view is now a
browser navigation, identified by the standard sec-fetch-dest: document
header, which survives into the proxy. Prefetch payloads, subresource
fetches and metadata-less clients are skipped as non-navigation
requests.
This commit is contained in:
2026-10-04 12:51:54 +02:00
parent a54df524c7
commit 1e3590ced7
3 changed files with 40 additions and 33 deletions
+5 -7
View File
@@ -3,6 +3,10 @@
* the pure domain decision, and records a page view without touching the
* response. All analytics happen server-side; no tracking script ships to
* the browser.
*
* Page views are browser navigations (`sec-fetch-dest: document`).
* Router-internal headers (RSC, next-router-prefetch) are stripped by the
* server before this shell runs and cannot be used here.
*/
import { NextResponse, type NextRequest } from "next/server";
@@ -18,14 +22,10 @@ const firstForwardedIp = (forwardedFor: string | null): string =>
const hostnameWithoutPort = (host: string): string => host.replace(/:\d+$/, "");
const isPrefetchRequest = (request: NextRequest): boolean =>
request.headers.get("next-router-prefetch") === "1" ||
request.headers.get("purpose") === "prefetch" ||
request.headers.get("sec-purpose") === "prefetch";
export default function proxy(request: NextRequest): NextResponse {
const decision = decidePageview({
method: request.method,
secFetchDest: request.headers.get("sec-fetch-dest") ?? "",
pathname: request.nextUrl.pathname,
search: request.nextUrl.search,
hostname: hostnameWithoutPort(
@@ -37,8 +37,6 @@ export default function proxy(request: NextRequest): NextResponse {
language: primaryLanguage(request.headers.get("accept-language")),
userAgent: request.headers.get("user-agent") ?? "",
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
isRscNavigation: request.headers.get("rsc") === "1",
isPrefetch: isPrefetchRequest(request),
});
if (decision.track) {
+20 -18
View File
@@ -8,6 +8,7 @@ const humanRequest = (
overrides: Partial<IncomingPageviewRequest> = {},
): IncomingPageviewRequest => ({
method: "GET",
secFetchDest: "document",
pathname: "/",
search: "",
hostname: "mintel.me",
@@ -15,13 +16,11 @@ const humanRequest = (
language: "de-DE",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0",
clientIp: "84.117.2.55",
isRscNavigation: false,
isPrefetch: false,
...overrides,
});
describe("decidePageview", () => {
it("tracks a document request for the homepage", () => {
it("tracks a browser navigation for the homepage", () => {
const decision = decidePageview(humanRequest());
expect(decision).toEqual({
track: true,
@@ -60,11 +59,6 @@ describe("decidePageview", () => {
expect(decision).toMatchObject({ track: true });
});
it("tracks a client-side route change (RSC navigation without prefetch)", () => {
const decision = decidePageview(humanRequest({ isRscNavigation: true }));
expect(decision).toMatchObject({ track: true });
});
it("accepts the method in lower case", () => {
const decision = decidePageview(humanRequest({ method: "get" }));
expect(decision).toMatchObject({ track: true });
@@ -79,16 +73,17 @@ describe("decidePageview", () => {
expect(decision).toEqual({ track: false, reason: "non-get-method" });
});
it("skips a router prefetch", () => {
const decision = decidePageview(humanRequest({ isPrefetch: true }));
expect(decision).toEqual({ track: false, reason: "prefetch" });
});
it("skips a prefetch even when the agent is a bot", () => {
const decision = decidePageview(
humanRequest({ isPrefetch: true, userAgent: "curl/8.1.2" }),
);
expect(decision).toEqual({ track: false, reason: "prefetch" });
it.each([
["a router prefetch payload", "empty"],
["a script subresource", "script"],
["an image subresource", "image"],
["a request without fetch metadata", ""],
])("skips %s", (_label, secFetchDest) => {
const decision = decidePageview(humanRequest({ secFetchDest }));
expect(decision).toEqual({
track: false,
reason: "non-navigation-request",
});
});
it.each([
@@ -135,4 +130,11 @@ describe("decidePageview", () => {
const decision = decidePageview(humanRequest({ userAgent }));
expect(decision).toEqual({ track: false, reason: "bot-user-agent" });
});
it("skips a bot even when its request looks like a navigation", () => {
const decision = decidePageview(
humanRequest({ userAgent: "curl/8.1.2", secFetchDest: "document" }),
);
expect(decision).toEqual({ track: false, reason: "bot-user-agent" });
});
});
+15 -8
View File
@@ -1,11 +1,18 @@
/**
* Decides whether an incoming HTTP request represents a human page view,
* and distils the request into the facts a page view consists of.
* Pure classification for use in the request shell (src/proxy.ts).
* Pure classification for use in the request shell (proxy.ts).
*
* A page view is a browser navigation, identified by the Fetch Metadata
* header `sec-fetch-dest: document`. Router-internal headers (RSC,
* next-router-prefetch) are stripped by the server before the request
* shell runs and can therefore not be used.
*/
export type IncomingPageviewRequest = {
readonly method: string;
/** Value of the sec-fetch-dest request header; "" when absent. */
readonly secFetchDest: string;
readonly pathname: string;
readonly search: string;
readonly hostname: string;
@@ -13,8 +20,6 @@ export type IncomingPageviewRequest = {
readonly language: string;
readonly userAgent: string;
readonly clientIp: string;
readonly isRscNavigation: boolean;
readonly isPrefetch: boolean;
};
export type PageviewFacts = {
@@ -28,15 +33,17 @@ export type PageviewFacts = {
export type PageviewSkipReason =
| "non-get-method"
| "prefetch"
| "non-navigation-request"
| "bot-user-agent"
| "static-asset"
| "infrastructure-path"
| "bot-user-agent";
| "infrastructure-path";
export type PageviewDecision =
| { readonly track: true; readonly facts: PageviewFacts }
| { readonly track: false; readonly reason: PageviewSkipReason };
const NAVIGATION_DESTINATION = "document";
const NEXT_INTERNAL_PREFIX = "/_next/";
const STATIC_FILE_PATTERN =
@@ -69,8 +76,8 @@ export function decidePageview(
if (request.method.toUpperCase() !== "GET") {
return { track: false, reason: "non-get-method" };
}
if (request.isPrefetch) {
return { track: false, reason: "prefetch" };
if (request.secFetchDest !== NAVIGATION_DESTINATION) {
return { track: false, reason: "non-navigation-request" };
}
if (BOT_USER_AGENT_PATTERN.test(request.userAgent)) {
return { track: false, reason: "bot-user-agent" };