Custom events are where analytics starts paying for itself, and a Webflow Cloud app can fire them from the browser and from a Route Handler.
Page views tell a marketing team that someone arrived. But they do not say whether the visitor opened the pricing calculator or got a confirmation back from the server.
Google Analytics 4 is built around events for exactly this reason, and a Webflow Cloud app is the right place to send them from because it's where real interaction happens: buttons that call an API and forms that hit a Route Handler.
Two layers of custom event tracking go inside a Next.js app mounted on a Webflow site: client-side events fired with gtag from a tag loaded in the app's root layout, and server-side events posted from a Route Handler through the GA4 Measurement Protocol.
Both layers read the same Measurement ID, and one set of environment variables in the Webflow Cloud environment contains everything they need.
What do you need to track custom events with Google Analytics in Webflow?
You need to prepare these five items:
- Google Analytics 4 property: Create it with a web data stream. The Measurement ID beginning with
G-feeds both the client-side tag and the server-side route. - Webflow site and Cloud environment: Any site plan from Starter up, with a Webflow Cloud project and one environment that already builds and deploys.
- Next.js 15 or higher: Webflow Cloud's docs list 15 as the floor. The App Router is assumed throughout; the Route Handler pattern depends on it.
- npm as the package manager: Webflow Cloud supports only npm, so any
pnpm addoryarn addyou carry over from another project will produce a build that doesn't install. - Environment variable access: You need edit rights for the project environment variables. Store the GA API secret there, never in the repository.
The free Starter site plan includes Webflow Cloud, while a custom domain mount needs Premium or higher. Gather the five requirements below before the basic build. Everything else is a Google Analytics property and a Next.js app that already deploys to Webflow Cloud.
With these prerequisites in place, you can configure the browser and server event paths the build depends on.
6 steps to track custom Google Analytics events in Webflow Cloud
Accurate GA4 tracking in Webflow Cloud requires six connected tasks: create the API secret, load the browser tag, configure environment variables, add a client helper, build a Route Handler, and verify both event paths.
Complete the configuration in this order so each browser and server component has the credentials and routing information it needs.
1. Create a Measurement Protocol API secret in Google Analytics
Create the API secret in the web stream's details panel; it appears only when you create it. Open your GA4 property, go to Admin, then Data collection and modification, then Data streams, and select the web stream for your site.
Copy the Measurement ID shown at the top of the stream details. Further down the same panel, open Measurement Protocol API secrets and create a new secret with a nickname that names the environment, such as webflow-cloud-production.
Copy the secret value immediately. Treat a regenerated secret as invalidating the old one. A Route Handler still holding the previous value can keep returning success while GA quietly drops every payload.
Keep one secret per Webflow Cloud environment if you run staging and production; that way a leaked staging secret can be rotated without touching the production stream. Keep the Measurement ID starting with G- and the API secret in a password manager and out of the codebase.
You now have the Measurement ID and server-only API secret ready for configuration.
2. Load the Google Analytics tag in the Next.js root layout
Load the GA tag in app/layout.tsx, which every page rendered by the mounted app passes through. Read the Measurement ID from NEXT_PUBLIC_GA_MEASUREMENT_ID, which Next.js inlines into the browser bundle at build time, and render nothing if it is absent so local development without the variable does not throw.
Wire the tag in through next/script like this:
// app/layout.tsx
import Script from 'next/script';
const GA_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
{GA_ID && (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="afterInteractive"
/>
<Script id="ga-init" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', '${GA_ID}');
`}
</Script>
</>
)}
</body>
</html>
);
}
The afterInteractive strategy loads the tag after hydration, which keeps it off the critical path without delaying it until the visitor scrolls.
After a local npm run dev with the variable set in .env.local, the Network tab of your browser should show a request to googletagmanager.com/gtag/js with your Measurement ID in the query string, and the GA Realtime report should register a page view.
3. Set the environment variables in Webflow Cloud
Add both credentials to the Webflow Cloud environment that deploys the app. Next.js handles the two credentials differently: the Measurement ID is public and available during the build, while the API secret stays server-only for requests.
Keep them out of the repository. Webflow Cloud makes both secret and non-secret variables available to the build process and the deployed application at runtime, which matters here because NEXT_PUBLIC_ values must exist at build time to be inlined. In contrast, the API secret is read only at request time inside the Route Handler.
Webflow Cloud redacts secret variables from build logs.
The three variables and how each one is scoped:
| Variable | Type | Value | Where it is read |
|---|---|---|---|
NEXT_PUBLIC_GA_MEASUREMENT_ID |
Non-secret | Your G- Measurement ID |
Browser bundle and Route Handler |
GA_API_SECRET |
Secret | The Measurement Protocol API secret for the web stream | Route Handler only |
NEXT_PUBLIC_BASE_PATH |
Non-secret | The mount path of the app, such as /app |
Client-side fetch calls |
| Variable → Type → Value → Where it is read |
|---|
NEXT_PUBLIC_GA_MEASUREMENT_ID |
| Non-secret |
Your G- Measurement ID |
| Browser bundle and Route Handler |
GA_API_SECRET |
| Secret |
| The Measurement Protocol API secret for the web stream |
| Route Handler only |
NEXT_PUBLIC_BASE_PATH |
| Non-secret |
The mount path of the app, such as /app |
| Client-side fetch calls |
The third variable exists because Webflow Cloud injects basePath at build time and does not rewrite client-side fetch calls. A browser request to /api/track from an app mounted at /app resolves against the Webflow site root, so it never reaches the Route Handler.
The docs require client fetches to include the base path manually; passing it through NEXT_PUBLIC_BASE_PATH is the clean way to do that. Use the exact mount path with a leading slash and no trailing slash.
Save the variables and trigger a new deploy, because a value added after the last build is not in the bundle until the app builds again.
Once the deploy finishes, confirm that the mounted app loads the GA tag with the production Measurement ID.
4. Write a client-side event helper and fire a custom event
Create a shared guard that prevents components from calling gtag('event', ...) before the tag is available. A direct window.gtag call scattered across components breaks the moment a page renders on the server or a content blocker blocks the tag, and the helper gives you one place to guard both.
Put it in lib/ga.ts, declare the gtag global so TypeScript stops complaining, and keep the parameter type narrow enough that a component cannot accidentally send an object GA will reject.
Keep the helper short:
// lib/ga.ts
type EventParams = Record<string, string | number | boolean>;
declare global {
interface Window {
gtag?: (...args: unknown[]) => void;
}
}
export function trackEvent(name: string, params: EventParams = {}): void {
if (typeof window === 'undefined' || typeof window.gtag !== 'function') {
return;
}
window.gtag('event', name, params);
}
Event names are your own vocabulary, so pick them the way you would name a database column: lowercase, underscores, and specific enough that a marketer reading the GA Events report knows what happened without opening the code. pricing_sheet_download beats click.
Parameters travel with the event. Registering a parameter as a custom dimension in Admin is what promotes it into standard reports; until you do, the parameter is visible in DebugView and Realtime only. Client components can now emit guarded, typed GA4 events by calling trackEvent.
5. Send server-side events from a Route Handler with the Measurement Protocol
Create a Route Handler at app/api/track/route.ts that accepts a client ID and an event from the browser and forwards them to the Measurement Protocol with the API secret attached. Google's Measurement Protocol reference describes the endpoint and the required payload shape. Keeping the API secret in the Route Handler protects it from browser exposure.
This browser-requested Route Handler records a tracking request. Send verified outcomes from the server code that completes the action or from a webhook handler after the webhook has been authenticated.
The Route Handler reads the secret from GA_API_SECRET, which is not prefixed NEXT_PUBLIC_ and therefore never enters the bundle.
Security warning: The Route Handler below is an unsafe local demonstration. A production tracking endpoint requires authentication and authorization, rate limiting or quota-abuse controls, a strict event allowlist and parameter schema, and a request-size limit.
Do not deploy it as shown. Add all of those controls before exposing a tracking endpoint on a live site; otherwise, anyone who can reach the Route Handler can submit traffic through your GA credentials.
The demonstration Route Handler posts to the Measurement Protocol:
// app/api/track/route.ts
import { NextResponse } from 'next/server';
const GA_ENDPOINT = 'https://www.google-analytics.com/mp/collect';
export async function POST(request: Request) {
const measurementId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
const apiSecret = process.env.GA_API_SECRET;
if (!measurementId || !apiSecret) {
return NextResponse.json(
{ error: 'Analytics is not configured for this environment' },
{ status: 500 },
);
}
const body = await request.json().catch(() => null);
if (!body?.clientId || !body?.name) {
return NextResponse.json(
{ error: 'clientId and name are required' },
{ status: 400 },
);
}
const payload = {
client_id: String(body.clientId),
events: [
{
name: String(body.name),
params: {
...(body.params ?? {}),
// Without these two, Measurement Protocol events routinely
// fail to surface in Realtime and standard reports.
session_id: body.session_id,
engagement_time_msec: 100,
},
},
],
};
const url = `${GA_ENDPOINT}?measurement_id=${measurementId}&api_secret=${apiSecret}`;
const gaResponse = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return NextResponse.json(
{ ok: gaResponse.ok },
{ status: gaResponse.ok ? 200 : 502 },
);
}
The client_id is intended to associate the event with the visitor identified by the browser tag. A successful Measurement Protocol response confirms the request reached Google. GA can still discard the event, so treat gaResponse.ok as a delivery check only. Payload validation requires a separate request to Google's debug endpoint.
The Route Handler sticks to web-standard request and response APIs and never touches node:fs, which is unavailable on Webflow Cloud's pinned compatibility date. For local testing only, a curl POST with a JSON body containing clientId and name against http://localhost:3000/api/track should now return {"ok":true}.
6. Call both layers from a client component and verify in Realtime
Fire the browser event and request the demonstration Route Handler from a single test interaction. You can then compare them side by side in GA during local testing.
The component asks gtag for the current client_id through gtag('get', ...), fires trackEvent for the click, then posts to the Route Handler using the configured NEXT_PUBLIC_BASE_PATH. Production use depends on authentication and authorization, quota-abuse controls, an event allowlist and parameter schema, and a request-size limit.
Here is the component that calls both layers:
// components/PricingDownload.tsx
'use client';
import { trackEvent } from '../lib/ga';
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
const measurementId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID ?? '';
function getClientId(): Promise<string | undefined> {
return new Promise((resolve) => {
if (typeof window.gtag !== 'function' || !measurementId) {
resolve(undefined);
return;
}
window.gtag('get', measurementId, 'client_id', (id: unknown) => {
resolve(typeof id === 'string' ? id : undefined);
});
});
}
export default function PricingDownload() {
async function handleClick() {
trackEvent('pricing_sheet_download', { file_name: 'pricing-2026.pdf' });
const clientId = await getClientId();
if (!clientId) return;
await fetch(`${basePath}/api/track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId,
name: 'pricing_sheet_relay_requested',
params: { source: 'webflow_cloud' },
}),
keepalive: true,
});
}
return <button onClick={handleClick}>Download the pricing sheet</button>;
}
The keepalive flag asks the browser to keep the request active if the click also starts a navigation. Completion and file delivery require separate confirmation. Drop the component onto a local test page, click the button, and watch the GA Realtime report:
pricing_sheet_download is requested from the browser tag, and pricing_sheet_relay_requested is requested through the Route Handler using the browser's client_id. When both names show under Event count by Event name, both requests reach GA.
What causes Google Analytics custom events to fail in Webflow Cloud?
Custom events usually fail because the deployed tag lacks its Measurement ID, client requests omit the mount path, an edge runtime directive breaks the build, or Measurement Protocol payloads reach Google but are discarded.
Match each visible symptom to its Webflow Cloud cause and the configuration change that resolves it.
The tag loads in local development, but the deployed app sends no page views
Cause: The deployed bundle was built without NEXT_PUBLIC_GA_MEASUREMENT_ID, so Next.js inlined undefined, the GA_ID && guard in the layout rendered nothing, and the tag never reached the page. Local development reads the value from .env.local, which is not deployed.
Fix: Confirm the Webflow Cloud environment setting and trigger a fresh deploy. Then open the Network tab and look for a request to googletagmanager.com/gtag/js. Do not reach for view-source here: the afterInteractive strategy injects the tag from the client after hydration, so a correctly configured app has nothing to find in the server-rendered HTML.
Check whether a browser content blocker is stopping the request. The Network tab should also show a request to googletagmanager.com/gtag/js with the production Measurement ID in its query string.
POST /api/track fails on the live site but succeeds under next dev
Cause: The component omitted the configured base path, so the live request never reached the Route Handler. Locally, there is no mount path, so the bare URL works and the bug stays hidden until production.
Fix: Apply the NEXT_PUBLIC_BASE_PATH setting to every client-side fetch. Do not try to read basePath from next.config in client code; the current Webflow Cloud Next.js docs no longer show that pattern, and the injected value is not reliably available to the browser that way.
Redeploy and confirm in the Network tab that the request URL now begins with the mount path. For an app mounted at /app, the live tracking request must target /app/api/track rather than the Webflow site root.
The Webflow Cloud deploy fails at the build step with an edge runtime error
Cause: Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which accepts the Workers platform target and rejects the Next.js edge runtime target. The word "edge" has two meanings in Webflow's documentation. Webflow Cloud runs on Cloudflare Workers, an edge platform, and the docs describe deploying "using the Edge runtime" in that platform sense.
The same bring-your-own-app page still tells readers to add export const runtime = 'edge' to API routes. That directive selects a different Next.js runtime target that the adapter rejects. A developer following that line faithfully ships a broken build.
Fix: Delete the directive from every route, layout, and page. The Measurement Protocol Route Handler runs on Workers because the adapter handles the platform target.
If you also need request-level logic, such as attaching a consent flag before events fire, put it in middleware.ts; the Workers runtime only supports Edge runtime middleware, so don't rename the file to proxy.ts, which runs on the Node runtime and can't run on Webflow Cloud.
The Route Handler returns ok: true, but no server-side events appear in Google Analytics
Cause: The Measurement Protocol returns a success status for any well-formed HTTP request, including payloads with an invalid event name or a client_id that matches no browser session. Your Route Handler's gaResponse.ok check therefore confirms delivery to Google and nothing more.
The most common Webflow Cloud variant is that getClientId resolved undefined because the tag hadn't loaded on the mounted app yet, the component returned early, and the Route Handler was never called. A successful curl test only verifies the test request. Confirm the status of a real button click.
Fix: Google's validation endpoint accepts the same payload at https://www.google-analytics.com/debug/mp/collect and returns a validationMessages array naming the offending field. In the browser, confirm the tag has loaded before the button becomes interactive, or fall back to firing only the client-side event when getClientId resolves empty.
What you can build next with Google Analytics and Webflow
For a broader analytics setup across your site and Cloud app, Google Analytics provides the primary integration route, while Webflow Analyze provides native on-site behavior data inside the platform. Use the Route Handler for authenticated, verified server-side outcomes.
The same Route Handler pattern extends to any server-side outcome worth measuring: a Stripe webhook that confirms payment or another verified server-side outcome. Apply authentication and authorization, quota-abuse controls, a strict event allowlist and parameter schema, and a request-size limit before sending events.
Frequently asked questions
Should the Designer-rendered pages use the same Google Analytics property?
If Google Analytics is already configured on the Designer-rendered pages, use the same Measurement ID for the Next.js app. A visitor moving from a marketing page into the mounted app can then remain one user in GA, while the browser tag supplies the client_id used to associate Route Handler events with that visitor.
Can I use Google Tag Manager instead of gtag in the Webflow Cloud app?
You can load the GTM container through next/script in the same layout slot and push to dataLayer instead of calling gtag. The Route Handler is unaffected because the Measurement Protocol talks to GA directly and never passes through the container. Keep the client_id lookup because GTM-loaded tags still expose it to the component.
Will events from local next dev pollute my production Google Analytics data?
They will if your .env.local holds the production Measurement ID. Isolate local events in a separate GA4 property or a second data stream with separate credentials. Verify server-side events against a deployed staging environment rather than trusting local results, and keep a separate Measurement ID for staging so development traffic never reaches the production property.
Do quotas change what I can track on a cheaper plan?
Yes. Some Webflow Cloud limits vary by site plan, and others are flat across all of them, so read the limits page rather than assuming which kind you are dealing with. Before increasing event volume or adding server-side requests, check the current limits for your specific site. The relevant ceiling depends on that site's plan history, not only its current configuration.




