npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gtmss/ga4-relay

v0.1.1

Published

Server-side GA4 Measurement Protocol relay for Next.js/Vercel, with a first-party cookie/token layer and an offline-capable service worker client.

Readme

GA4 First-Party Server-Side Collection Relay

A self-hosted, first-party GA4 Measurement Protocol v2 relay for Next.js/Vercel — the resilience-and-first-party-hosting layer stape.io sells, not a clone of Google Tag Manager Server-Side. See .omc/plans/ga4-server-side-relay-plan.md for the full design (RALPLAN-DR consensus plan, three Architect + three Critic review rounds) and .omc/progress.txt for the implementation log.

What this is

  • packages/ga4-relay/ — the installable package. Import ./server in Next.js Route Handlers and middleware.ts, ./client in a client component that initializes tracking, and let ./sw be served by createServiceWorkerHandler.
  • apps/demo/ — a reference Next.js install showing the exact wiring.

Installing into your own Next.js app

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;
}

5. CMP / Consent Mode v2 integration contract

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.

Design notes worth knowing before you deploy

Same-request token propagation. withGa4Token's cookie is only visible to the browser's next request. The collect handler reads the token from the x-ga4-relay-token header (GA4_TOKEN_HEADER) that your middleware.ts forwards on the current request, falling back to the cookie for anything that only ever sets it there. If you write your own middleware without the header-forwarding step above, every clean browser's first request will 401.

Service worker scope. The reference wiring serves the SW from /ga4-relay/ga4-sw.js (a nested path), giving it a natural default scope of /ga4-relay/ — narrow enough that it can never take over a host site's existing service worker at a broader scope. Widening this via Service-Worker-Allowed to / is possible but is a deliberate opt-in you should only make after confirming there's no conflicting host SW; two service workers cannot both control the same scope; the second registration replaces the first there.

Dedupe fail-open vs. fail-closed. A dedupe-store outage fails open (forwards the event) for a fresh, never-before-sent live event — silently dropping brand-new traffic while returning 200 would be undetectable data loss. It fails closed (drops the event) for a replayed queued event, where the event has already been attempted once and losing it again is the safer side, matching Principle 5 (retry and dedupe ship together, never separately).

Delivery is best-effort in v1. after()/waitUntil() work is cancelled if the function times out and isn't durable across an instance dying mid-request. At-least-once delivery would require a durable queue (QStash/Vercel Queues) — explicitly out of scope for v1.

Rate limiting requires Upstash in production. The in-memory Store is dev-only; createGa4Relay throws at construction if it's used with rate limiting enabled while NODE_ENV=production.

Known environment-blocked verification gaps

Some acceptance criteria require resources not available in the environment this was built and tested in (no real GA4 property, no outbound network access for Playwright browser binaries). Each is documented with what was verified instead and how to close the gap:

Development

pnpm install
pnpm --filter ga4-relay build   # tsup: dist/server, dist/client, dist/sw
pnpm exec vitest run            # unit + integration tests (mock MP server, fake-indexeddb)
pnpm run typecheck
pnpm exec eslint .              # includes the client/server entrypoint-boundary rule
pnpm --filter demo build        # apps/demo — requires apps/demo/.env.local, see .env.example

Releasing

Versioning and the changelog are managed with Changesets.

pnpm changeset      # add a changeset describing a user-facing change (per PR)
pnpm run version    # consume pending changesets into a version bump + CHANGELOG update
pnpm run build      # build packages/ga4-relay before publishing
pnpm run release    # publish packages/ga4-relay to npm

apps/demo is private and ignored by Changesets — it never gets a changeset prompt or version bump.