Skip to main content

Installation

This page walks through wiring @gtmss/ga4-relay into your own Next.js app: a route handler, middleware, an optional service worker, a client-side init, and the CMP / Consent Mode v2 integration contract your consent-management platform must implement.

1. Route handlers​

// app/api/ga4/collect/route.ts
import { relay } from "@/lib/relay"; // your own createGa4Relay(config, deps) instance

export const runtime = "nodejs";
export const maxDuration = 10; // RECOMMENDED_MAX_DURATION from the package — not the 300s default

export const POST = relay.createCollectHandler();
// lib/relay.ts
import { after } from "next/server";
import { createGa4Relay, InMemoryStore, UpstashStore } from "@gtmss/ga4-relay/server";
import { Redis } from "@upstash/redis";

export const relay = createGa4Relay(
{
measurementId: process.env.GA4_MEASUREMENT_ID!,
apiSecret: process.env.GA4_API_SECRET!,
tokenSecret: process.env.GA4_TOKEN_SECRET!,
allowedOrigins: process.env.GA4_ALLOWED_ORIGINS!.split(","),
store: process.env.UPSTASH_REDIS_REST_URL
? new UpstashStore(Redis.fromEnv())
: new InMemoryStore(), // dev-only — throws at construction in production, see below
cookieDomain: process.env.GA4_COOKIE_DOMAIN,
},
{ runAfterResponse: (cb) => after(cb) },
);

createGa4Relay throws at construction if apiSecret/tokenSecret are missing, or if rate limiting is left on the in-memory Store while NODE_ENV=production — including during next build, which always sets NODE_ENV=production. Set real UPSTASH_REDIS_REST_URL/ UPSTASH_REDIS_REST_TOKEN before building, not just before deploying. See apps/demo/.env.example.

2. Middleware — do NOT import your relay singleton here​

// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { withGa4Token, GA4_TOKEN_HEADER, serializeSetCookie } from "@gtmss/ga4-relay/server";

const resolveToken = withGa4Token({
tokenSecret: process.env.GA4_TOKEN_SECRET!,
cookieDomain: process.env.GA4_COOKIE_DOMAIN,
});

export async function middleware(request: NextRequest) {
const { token, cookie } = await resolveToken(request);

// Forward via a request header so the ROUTE HANDLER sees the token on
// THIS request — a cookie set only via Set-Cookie is visible to the
// browser's *next* request, not this one. Skipping this step means a
// clean browser's very first request always 401s.
const requestHeaders = new Headers(request.headers);
requestHeaders.set(GA4_TOKEN_HEADER, token);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.append("set-cookie", serializeSetCookie(cookie));
return response;
}

export const config = { matcher: ["/api/ga4/:path*", "/ga4-relay/:path*"] };

Two things matter here: withGa4Token takes only { tokenSecret, cookieDomain }, not your full relay config — importing the full createGa4Relay() object into middleware.ts (which Next.js always runs on the Edge runtime) would pull the Upstash client and other Node-only code into the Edge bundle and break at runtime. And the header-forwarding step above is not optional — see Same-request token propagation below.

3. Service worker (optional — resilience layer only, not required for basic collection)​

// app/ga4-relay/ga4-sw.js/route.ts — served from a NESTED path deliberately
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { createServiceWorkerHandler } from "@gtmss/ga4-relay/server";

export const runtime = "nodejs";

export const GET = createServiceWorkerHandler({
scope: "/ga4-relay/", // narrow — see "Service worker scope" below
loadScriptSource: async () => {
const require = createRequire(import.meta.url);
return readFile(require.resolve("@gtmss/ga4-relay/sw-script"), "utf8");
},
});

4. Client​

"use client";
import { useEffect } from "react";
import { createGa4Client } from "@gtmss/ga4-relay/client";

// Module-level, not component-instance-scoped: App Router's dev-mode
// double-invokes effects (mount → cleanup → mount), which would otherwise
// call createGa4Client() — and register the SW — twice in quick succession.
// A useRef guard wouldn't survive the dev-mode remount; this does. Only
// matters in dev — production builds don't double-invoke effects.
let ga4ClientInitialized = false;

export function Ga4Init() {
useEffect(() => {
if (ga4ClientInitialized) return;
ga4ClientInitialized = true;
const client = createGa4Client({
collectUrl: "/api/ga4/collect",
swScriptUrl: "/ga4-relay/ga4-sw.js", // omit to skip the SW entirely
swScope: "/ga4-relay/",
});
client.track({ event_id: crypto.randomUUID(), name: "page_view", params: {} });
}, []);
return null;
}

This package only consumes consent signals — it does not integrate with any specific CMP. Your CMP integration must:

  • Set window.__ga4RelayConsent to the current { ad_storage, ad_user_data, ad_personalization, analytics_storage } signal (all "granted" or "denied") as early as possible.
  • Dispatch window.dispatchEvent(new CustomEvent("ga4-relay:consent", { detail: signal })) whenever consent changes.

An absent signal, or analytics_storage !== "granted", is treated as fully denied — no cookie, no identity in the response body, no outbound GA4 call. An explicit denial with an existing relay cookie triggers cookie deletion (the relay's cookies are HttpOnly; your CMP's JavaScript cannot delete them itself). An explicit denial also purges the offline queue.

Next steps​

See Design Notes for the behavioral details worth understanding before you deploy this to production.