Skip to main content
AnalyticsLearner

Module 6

GA4 Measurement Protocol: server-side event from a Next.js route handler

Send a GA4 event directly from a Next.js route handler using the Measurement Protocol. Sessions stitch correctly when client_id is read from the _ga cookie.

ga4measurement-protocolnextjsserver-side

What this does

The GA4 Measurement Protocol lets you send events directly from your server to Google Analytics, bypassing the browser entirely. This is useful for tracking server-side conversions (lead form submissions, payment confirmations) where the browser event may be blocked or unreliable.

The key implementation detail: read the _ga cookie from the request to get client_id, which stitches the server-side hit to the existing browser session.

The route handler

// app/api/track/route.ts
import { cookies } from 'next/headers';
 
const GA4_MEASUREMENT_ID = process.env.GA4_MEASUREMENT_ID;
const GA4_MP_API_SECRET = process.env.GA4_MP_API_SECRET;
 
/**
 * Extract the GA4 client_id from the _ga cookie.
 * The _ga cookie format is: GA1.1.{client_id_part1}.{client_id_part2}
 * client_id = part1 + '.' + part2
 */
function extractClientId(gaCookie: string | undefined): string {
  if (!gaCookie) return 'unknown';
  const parts = gaCookie.split('.');
  if (parts.length >= 4) {
    return `${parts[2]}.${parts[3]}`;
  }
  return gaCookie;
}
 
export async function POST(request: Request) {
  // Guard: no-op when env is absent
  if (!GA4_MEASUREMENT_ID || !GA4_MP_API_SECRET) {
    return Response.json({ ok: true, sent: false });
  }
 
  const cookieStore = await cookies();
  const clientId = extractClientId(cookieStore.get('_ga')?.value);
 
  const body = (await request.json()) as { eventName: string; params?: Record<string, unknown> };
 
  const payload = {
    client_id: clientId,
    events: [
      {
        name: body.eventName,
        params: {
          engagement_time_msec: 100,
          session_id: Date.now().toString(),
          ...body.params,
        },
      },
    ],
  };
 
  try {
    const res = await fetch(
      `https://www.google-analytics.com/mp/collect` +
        `?measurement_id=${GA4_MEASUREMENT_ID}` +
        `&api_secret=${GA4_MP_API_SECRET}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      }
    );
 
    return Response.json({ ok: true, sent: true, status: res.status });
  } catch (err) {
    console.error('[ga4-mp] send failed:', err);
    return Response.json({ ok: true, sent: false }, { status: 500 });
  }
}

Required environment variables

# .env.local
GA4_MEASUREMENT_ID=G-XXXXXXXXXX
GA4_MP_API_SECRET=your_api_secret_here

The API secret is created in GA4 Admin: Data Streams → your stream → Measurement Protocol API secrets.

Verifying in GA4 DebugView

Add ?measurement_debug_mode=1 to the payload's params to route hits to DebugView:

params: {
  ...body.params,
  debug_mode: true,  // Routes to GA4 DebugView
}

DebugView shows the event in real time within 1–2 seconds of the server send.

Consent guard

Never send Measurement Protocol events without verifying consent first. Read the consent cookie server-side and check before sending:

// Only send when analytics_storage is granted
const consentCookie = cookieStore.get('al_consent')?.value;
const params = new URLSearchParams(decodeURIComponent(consentCookie ?? ''));
const analyticsConsented = params.get('a') === '1';
 
if (!analyticsConsented) {
  return Response.json({ ok: true, sent: false, reason: 'consent_denied' });
}

The free Server-Side GTM Base Container shows how to receive these hits through sGTM instead of sending directly.

Free container for this module

Server-Side GTM Base Container

A production-ready server-side GTM container configuration. Includes a GA4 client, GA4 event tag, and the routing setup needed to receive web container hits at your custom domain.

Get the container →