Out of the box, your Google tag loads from googletagmanager.com and sends hits to google-analytics.com. Ad blockers and browser tracking protection know both hostnames, so some of your real visitors never show up in GA4 or Google Ads.
Google tag gateway gets around this by routing the tag through a path on your own domain, like example.com/metrics. Your server passes that traffic on to Google, so the tag and some of its measurement requests look first-party.
The short version: pick a path, forward it to Google, and load your tag from it. On Cloudflare, Akamai, Fastly or Google Cloud, that’s a few clicks in Google’s admin. On Vercel, you add one small proxy route and change a script URL.
What it does, and what it doesn’t
Let’s start with what you’re actually getting.
It does:
- Load your Google tag from your own domain instead of
googletagmanager.com. - Send some measurement requests to Google through that same domain. Google says this helps recover signals lost to blockers and browser restrictions.
- Work with Google Analytics, Google Ads and Search Ads 360, using either a standalone Google tag or a Tag Manager container.
- Ask for no new tracking code. You swap one script URL and add a route or CDN rule.
It doesn’t:
- Change what you collect or your legal basis for collecting it. Consent rules still apply, and Google warns that turning on the gateway changes when your tag fires. If consent affects your tags, look at your Consent Mode setup first.
- Make your measurement block-proof. Some filter lists and privacy tools will still catch it, so expect to win back a share of lost traffic, not all of it. Google doesn’t publish a recovery figure, so you’ll have to measure your own.
- Replace server-side tagging. Google treats them as two separate setups, and the gateway doesn’t give you a server container to process events.
- Support the
<noscript>snippet. Tag Manager’s noscript iframe still points atwww.googletagmanager.com.
Is it worth it?
Honestly, it depends on who visits your site. If your audience is technical or heavy on ad blockers, or you run Google Ads and care about conversion measurement, it’s well worth an afternoon. If you run a small site with a mostly non-technical audience, you may not notice much.
The only way to know is to compare a week of traffic before and after.
Which setup do you need?
Google’s one-click setup only supports Cloudflare, Akamai, Fastly and Google Cloud. Vercel isn’t on the list.
- Is your domain already proxied through one of those four? Go with automatic setup.
- Hosted on Vercel without one of them in front? Go with manual setup on Vercel. It covers Astro and Next.js.
Automatic setup
This happens inside Google’s tools, not in your code.
- Open your Google tag or Tag Manager container and go to Admin.
- Click Google tag gateway.
- Choose a measurement path, for example
/metrics. It can’t already be used by your site. - Pick your provider and sign in so Google can configure it.
- Google adds the routing rules for you. Your site’s code stays untouched.
From Google Ads you can reach the same screen through Tools → Data manager → Google tag → Manage → Admin.
One caveat: Akamai support is still labelled beta, and you need admin rights on the Akamai account.
Once that’s done, jump to Verify it works.
Manual setup on Vercel
The idea is simple. You add a small route that forwards everything under /metrics/* to Google, then point your tag at it. There are three steps, plus a check at the end.
1. Find your tag ID and pick a path
- Tag ID: the
G-XXXXXXXXXXfrom GA4, orGTM-XXXXXXXfor Tag Manager. - Path: anything your site doesn’t already use, and not
/. I’m using/metricshere. Obvious names like/analyticsor/trackingare easy for filter lists to learn, so a less obvious word may last longer. That’s a trade-off, not a guarantee.
Google’s gateway endpoint is your tag ID in lowercase plus .fps.goog, for example g-12345.fps.goog. Add the ID to your Vercel project as an environment variable, for both Production and Preview:
GOOGLE_TAG_ID=g-12345
The same ID also goes into your page snippet in step 3 (G-12345), and the two have to match.
2. Add the proxy
Why not just a rewrite?
A rewrite in vercel.json can forward the script, but Google also wants the visitor’s country and region on every request, and a static rewrite can’t add those. Without them, location data in GA4 shows up as (not set). Vercel already gives you that data in the x-vercel-ip-country and x-vercel-ip-country-region headers, so a small function can pass it along.
The shared function
Both frameworks can use the same function, since it only relies on the standard Request and Response. Here’s what it does before you read it:
- Builds the upstream URL on Google’s side and forwards the request there.
- Passes the visitor’s IP and location along, so Google doesn’t think everyone is in Vercel’s data center.
- Sends only analytics cookies upstream, never your own session or login cookies.
Create it once:
// src/lib/google-tag-gateway.ts (or lib/ in Next.js)
const TAG_ID = (process.env.GOOGLE_TAG_ID ?? "").toLowerCase();
const GATEWAY_DOMAIN = "fps.goog";
const UPSTREAM = `https://${TAG_ID}.${GATEWAY_DOMAIN}`;
// Sites allowed to send data, e.g. "https://example.com,https://www.example.com".
// Leave unset to skip the check.
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
// Request headers copied as-is. Host is left out on purpose:
// fetch() sets it to the upstream hostname, which Google requires.
const FORWARDED_HEADERS = ["accept", "accept-language", "content-type", "user-agent", "referer"];
// Only cookies with these prefixes reach Google. Your own session or login
// cookies are sent to this path too and must never leave your site.
const ANALYTICS_COOKIE = /^(_ga|_gcl|_gac|FPID|FPLC)/;
// Response headers that describe the old body encoding. fetch() already decoded it.
// Everything else, Content-Type included, passes through: Google's scripts can break without it.
const STALE_RESPONSE_HEADERS = ["content-encoding", "content-length", "transfer-encoding", "connection"];
// Browsers always send Origin on POST, which is how hits arrive. GET stays open
// so the tag script and the curl checks below keep working.
function isAllowed(request: Request): boolean {
if (request.method === "GET" || request.method === "HEAD") return true;
if (!ALLOWED_ORIGINS.length) return true;
const origin = request.headers.get("origin") ?? request.headers.get("referer");
if (!origin) return false;
try {
return ALLOWED_ORIGINS.includes(new URL(origin).origin);
} catch {
return false;
}
}
function buildUpstreamUrl(request: Request): URL {
const url = new URL(request.url);
return new URL(url.pathname + url.search, UPSTREAM);
}
function analyticsCookies(request: Request): string {
return (request.headers.get("cookie") ?? "")
.split(";")
.map((cookie) => cookie.trim())
.filter((cookie) => ANALYTICS_COOKIE.test(cookie))
.join("; ");
}
function buildUpstreamHeaders(request: Request): Headers {
const headers = new Headers();
for (const name of FORWARDED_HEADERS) {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}
const cookie = analyticsCookies(request);
if (cookie) headers.set("cookie", cookie);
// Visitor IP, so Google doesn't see every hit as coming from Vercel.
const ip = request.headers.get("x-forwarded-for");
if (ip) headers.set("x-forwarded-for", ip);
// Location, so GA4 reports geography correctly.
const country = request.headers.get("x-vercel-ip-country");
const region = request.headers.get("x-vercel-ip-country-region");
if (country) headers.set("x-forwarded-country", country);
if (region) headers.set("x-forwarded-region", region);
return headers;
}
function buildResponseHeaders(upstream: Response): Headers {
const headers = new Headers(upstream.headers);
for (const name of STALE_RESPONSE_HEADERS) headers.delete(name);
return headers;
}
export async function proxyToGoogle(request: Request): Promise<Response> {
if (!TAG_ID) return new Response("GOOGLE_TAG_ID is not set", { status: 500 });
if (!isAllowed(request)) return new Response("Forbidden", { status: 403 });
const hasBody = request.method !== "GET" && request.method !== "HEAD";
const upstream = await fetch(buildUpstreamUrl(request), {
method: request.method,
headers: buildUpstreamHeaders(request),
body: hasBody ? await request.arrayBuffer() : undefined,
redirect: "manual",
});
return new Response(upstream.body, {
status: upstream.status,
headers: buildResponseHeaders(upstream),
});
}
If you want to change what gets forwarded, the constants at the top are the lists to edit. Google’s public docs don’t spell out which headers a custom proxy must forward, so mine is a conservative list. Google’s Set-Cookie responses pass through untouched, which means the analytics cookies end up on your domain. If hits arrive but your reports look off, compare against Google’s setup guide first.
Optional: restrict who can send data
If you don’t want other websites pushing fake hits through your gateway, add the sites you trust:
ALLOWED_ORIGINS=https://example.com,https://www.example.com
Think of it as a deterrent, not real authentication. Browsers set Origin honestly, but a script can fake it, so this stops casual abuse and not a determined attacker. Leave it unset on preview deployments or add their URLs, otherwise their hits get a 403.
3. Add the route and load the tag
This is the only part that depends on your framework. Pick yours:
Add the route. Astro sites are static by default, but a proxy has to run on every request. Add the Vercel adapter, then switch off prerendering for just this one route. The rest of your site stays static.
bunx astro add vercel// src/pages/metrics/[...path].ts
import type { APIRoute } from "astro";
import { proxyToGoogle } from "../../lib/google-tag-gateway";
export const prerender = false;
export const ALL: APIRoute = ({ request }) => proxyToGoogle(request);Load the tag from your path. Replace Google’s default snippet in your layout. is:inline keeps Astro from bundling or rewriting the script.
---
// src/layouts/PageLayout.astro
const GA_ID = "G-XXXXXXXXXX";
---
<script is:inline async src="/metrics/"></script>
<script is:inline define:vars={{ GA_ID }}>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag("js", new Date());
gtag("config", GA_ID);
</script>Add the route. Create a catch-all route under your path. The double brackets in [[...path]] make it match /metrics itself too.
// app/metrics/[[...path]]/route.ts
import { proxyToGoogle } from "@/lib/google-tag-gateway";
export const dynamic = "force-dynamic";
export const GET = (request: Request) => proxyToGoogle(request);
export const POST = (request: Request) => proxyToGoogle(request);The tag URL ends in a trailing slash (/metrics/), and Next.js redirects those away by default. That adds a pointless redirect to every tag load, so turn it off:
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
skipTrailingSlashRedirect: true,
};
export default config;Load the tag from your path. Replace Google’s default snippet in your root layout.
// app/layout.tsx
import Script from "next/script";
const GA_ID = "G-XXXXXXXXXX";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="/metrics/" strategy="afterInteractive" />
<Script id="gtag-init" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_ID}');
`}
</Script>
</body>
</html>
);
}Remember to remove Google’s original snippet when you swap. Leave it in and the tag loads twice, which double-counts your pageviews.
Using Tag Manager instead?
Load /metrics/?id=GTM-XXXXXXX as the container script. Keep the standard <noscript> snippet pointing at www.googletagmanager.com, because the gateway doesn’t support <noscript>.
Verify it works
Push to Vercel first, then run these against the deployed site, swapping in your own domain:
curl -s https://example.com/metrics/healthy
curl -s "https://example.com/metrics/?validate_geo=healthy"
- The first should print
ok, which means routing works. - The second should also print
ok, which means location headers are reaching Google.
Don’t test on localhost. The x-vercel-ip-* headers only exist behind Vercel.
Next, open Tag Assistant, preview your site, and under Summary → Output → Hits Sent confirm the hits go to /metrics and not google-analytics.com.
You can also check by hand. Open your browser’s Network tab and filter for metrics. You should see the tag script load from /metrics/ with a 200, followed by hit requests to the same path, and nothing going to googletagmanager.com or google-analytics.com. In GA4, your own visit should show up under Reports → Realtime within a minute or so.
Troubleshooting
/metrics/healthyreturns 404. The route isn’t deployed. On Astro, check the Vercel adapter is installed andprerender = falseis set. On Next.js, check the file isapp/metrics/[[...path]]/route.ts.- It returns 500 with
GOOGLE_TAG_ID is not set. Add the env variable in Vercel for the right environment, then redeploy. Env changes don’t reach existing deployments. /metrics/healthyworks butvalidate_geodoesn’t. You’re probably onlocalhost, or behind another proxy that strips thex-vercel-ip-*headers.- The script loads but no hits arrive. Check that the tag ID in the snippet matches
GOOGLE_TAG_ID, and that nothing else is blocking the path, like a CDN rule or a CSPscript-srcorconnect-srcrule. - Location shows
(not set)in GA4. The country and region headers aren’t reaching Google. Run thevalidate_geocheck again on the deployed site. - Hits return 403. The page’s origin isn’t in
ALLOWED_ORIGINS. Checkwwwversus the bare domain,httpversushttps, and preview URLs.
Gotchas
- Don’t cache
/metrics/*. No CDN rule in front of your app should cache it. - One path per container or tag. A GTM container needs one path for everything inside it. A separate standalone tag like
G-12345needs its own. - It costs function invocations. Every tag load and every hit now runs your function, so a single pageview can mean several invocations. Check your Vercel usage after a few days on a busy site.
- Preview deployments send hits too. Unless you use a separate GA4 property or a filter, preview traffic mixes into your reports.
- Backing out is easy. Restore Google’s original snippet and delete the
/metricsroute.