One small Webflow Cloud app can give every site the same one-line tracking tag, with each site's scripts and consent rule set in its own environment.
Tracking tags pasted into each site's head code can drift apart. Every vendor change becomes a round of edits across projects, and before long nobody can say which version of a snippet, or of the consent check, each site carries.
This build replaces those snippets with one line of head code that loads a script from a small Next.js app on Webflow Cloud. The same repository deploys to every site, and each site's environment variables determine which vendor scripts it loads and whether it requires a consent cookie first, checked on the server before any vendor URL reaches the browser.
What do you need to manage tracking scripts in Webflow?
You need six things for this build:
- Webflow sites: One Webflow site for each property you want to cover with the shared repository and its own configuration.
- Next.js 15: The bring-your-own-app page lists Next.js 15 or higher, Webflow's docs call for Node.js 22 or later, and this build pins Next.js 15.
- npm: Webflow Cloud supports only npm, so use npm to install dependencies and commit its lockfile.
- Git repository: One repository holds the site-agnostic loader app and goes to every covered site unchanged.
- Per-site script list: The script URLs each site loads, and whether that site must wait for consent before anything fires. This list becomes the environment variables.
- Consent banner: One that can set a first-party
tracking_consentcookie when a visitor accepts.
The loader is a small app. Most of the work goes into agreeing what each site should load.
6 steps to manage tracking scripts across sites in Webflow Cloud
Use a Route Handler to assemble each site's loader from environment variables and middleware to check consent on the server before any vendor URL is sent; the repository setup takes six steps.
Steps 1 to 4 happen once in the repository. Steps 5 and 6 repeat for each site you cover.
1. Scaffold the loader with npm
Scaffold the loader with the default npm setup, which leaves next.config alone and matches Webflow Cloud's configuration page: "You don't need to add an adapter, a base path, or an output mode." Every line you add to the config can conflict with the platform's build.
Run the scaffold from the directory where you keep client repositories:
npx create-next-app@15 tracking-loader --ts --app --use-npm
cd tracking-loader
The @15 tag pins the major version because Next.js 16 recommends renaming middleware.ts to proxy.ts, and the consent gate relies on staying on middleware.ts.
Accept the defaults for the remaining prompts. If you accept the src directory, remember that middleware.ts later goes in src/; the rest of this build assumes no src directory. The scaffold's sample page can remain until you write the check page.
Omit basePath from next.config. Webflow Cloud injects the mount path at build time, so reading it from the config is the wrong model. After npm run dev, http://localhost:3000 serves the default Next.js page, and git status shows a clean scaffold with an npm lockfile and no edits to the config.
2. Write the Route Handler that serves the tag
Create app/tag/route.ts to assemble the loader on every request. The handler reads two variables: TRACKING_SITE_NAME, a label the loader exposes as window.__trackingSite so you can quickly identify which configuration a page received, and TRACKING_SCRIPTS, a JSON object with a scripts array of URLs and an optional inline string.
The output is a plain immediately invoked function expression (IIFE) that appends one async script tag per URL. Treat those variables as code: anyone who can edit the environment can run JavaScript on every page of the site, so limit who has that access and list only https:// vendor URLs.
Route handler considerations and limits
If the site sends a Content-Security-Policy, add each vendor host to script-src. Cloudflare also caps each variable at 5 KB, which bounds how long inline can grow.
Leave export const runtime = 'edge' out of this file. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support that runtime target, and the build breaks. The handler already runs on Cloudflare Workers without any directive.
The ETag uses SHA-256 through crypto.subtle. I pick the standard Web Crypto algorithm on purpose. Some runtime APIs behave differently under local next dev and on the deployed Workers runtime, and a hash that only exists on one side is a bug you find after deploy.
/tag is a public endpoint with no authentication or rate limit, which is fine for a script that holds nothing secret. The handler also repeats the consent check that step 3 puts in middleware, so the loader fails closed: if the middleware ever stops running, visitors without consent still get the empty script.
Route handler code
Here is the complete handler:
// app/tag/route.ts
import { NextRequest, NextResponse } from 'next/server';
type Registry = {
scripts: string[];
inline: string;
};
function readRegistry(): Registry {
const raw = process.env.TRACKING_SCRIPTS;
if (!raw) return { scripts: [], inline: '' };
try {
const parsed = JSON.parse(raw) as { scripts?: unknown; inline?: unknown };
const scripts = Array.isArray(parsed.scripts)
? parsed.scripts.filter((s): s is string => typeof s === 'string')
: [];
const inline = typeof parsed.inline === 'string' ? parsed.inline : '';
return { scripts, inline };
} catch {
return { scripts: [], inline: '' };
}
}
function buildLoader(registry: Registry, siteName: string): string {
const appends = registry.scripts
.map(
(src) =>
`var s=d.createElement('script');s.async=true;s.src=${JSON.stringify(src)};d.head.appendChild(s);`
)
.join('\n');
return [
'(function(w,d){',
`w.__trackingSite=${JSON.stringify(siteName)};`,
appends,
registry.inline,
'})(window,document);',
].join('\n');
}
async function etagFor(body: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body));
const hex = Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return `"${hex.slice(0, 32)}"`;
}
export async function GET(request: NextRequest) {
// Repeat the consent check here so the loader fails closed if middleware ever stops running.
const consentRequired = process.env.TRACKING_CONSENT_REQUIRED === 'true';
if (consentRequired && request.cookies.get('tracking_consent')?.value !== 'granted') {
return new NextResponse('/* tracking disabled: consent not granted */', {
status: 200,
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'private, no-cache',
},
});
}
const registry = readRegistry();
const siteName = process.env.TRACKING_SITE_NAME ?? 'unnamed';
const body = buildLoader(registry, siteName);
const etag = await etagFor(body);
if (request.headers.get('if-none-match') === etag) {
return new NextResponse(null, { status: 304, headers: { ETag: etag } });
}
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'private, no-cache',
Vary: 'Cookie',
ETag: etag,
'X-Tracking-Scripts': String(registry.scripts.length),
},
});
}
Access to the full loader depends on the visitor's consent cookie, so the response is marked private with Vary: Cookie to keep a shared cache from handing the full loader to a visitor who has not consented.
The handler handles bad input without throwing: a missing or malformed TRACKING_SCRIPTS produces an empty loader and avoids a 500, and the X-Tracking-Scripts header tells you how many URLs it found. That header is the fastest diagnostic you will have once the app is live on several sites.
Testing the handler locally
To exercise it locally, give the handler something to read:
# .env.local
TRACKING_SITE_NAME=marketing
TRACKING_SCRIPTS='{"scripts":["https://cdn.example.com/analytics.js","https://cdn.example.com/pixel.js"],"inline":"w.__trackingReady=true;"}'
TRACKING_CONSENT_REQUIRED=false
Restart npm run dev and run curl -i http://localhost:3000/tag. The response is a 200 with Content-Type: application/javascript, X-Tracking-Scripts: 2, and a body that sets w.__trackingSite="marketing" before appending the two example URLs.
3. Gate the tag behind a consent cookie in middleware.ts
Add middleware.ts at the project root to answer /tag with an empty script whenever the site requires consent and the visitor has not granted it. The gate reads TRACKING_CONSENT_REQUIRED and a first-party cookie named tracking_consent, which your banner's accept handler sets.
Webflow's framework-customization page sets the runtime rule: "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime." Keep middleware on the Edge runtime, and leave the file named middleware.ts.
The empty response requests Cache-Control: private, no-cache, which Webflow Cloud enforces on every response, so the browser revalidates after the visitor accepts. The matcher is written as /tag without the mount prefix because Webflow Cloud injects the mount path at build time, so the source refers to routes without it.
Consent gate middleware code
Drop this in beside package.json:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const EMPTY_SCRIPT = '/* tracking disabled: consent not granted */';
export function middleware(request: NextRequest) {
const consentRequired = process.env.TRACKING_CONSENT_REQUIRED === 'true';
if (!consentRequired) return NextResponse.next();
const consent = request.cookies.get('tracking_consent')?.value;
if (consent === 'granted') return NextResponse.next();
return new NextResponse(EMPTY_SCRIPT, {
status: 200,
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'private, no-cache',
},
});
}
export const config = {
matcher: ['/tag'],
};
The gate intercepts the request before it reaches the handler, so a site can switch between open and consent-required by changing one variable.
Flip TRACKING_CONSENT_REQUIRED to true in .env.local, restart, and curl -i http://localhost:3000/tag twice: once bare, once with -H 'Cookie: tracking_consent=granted'. The first returns the single comment line; the second returns the full loader.
4. Add a check page that respects the mount path
Replace app/page.tsx with a page that requests the tag through NEXT_PUBLIC_BASE_PATH. The bring-your-own-app page is explicit that "Client-side fetch calls must manually include the base path," and a script tag rendered by your own app has the same requirement.
Once the app is mounted at /tracking, a hardcoded /tag requests the Webflow site's root and misses the mounted app.
The page reads the variable at build time and falls back to an empty string locally, where no mount path exists. That gives you one page that works under next dev and on each deployed site configured with the same mount path without a branch in the code.
Check page code
Replace app/page.tsx with this page:
// app/page.tsx
export default function Page() {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
const tagUrl = `${basePath}/tag`;
return (
<main style={{ fontFamily: 'sans-serif', padding: 24 }}>
<h1>Tracking loader check</h1>
<p>
This page requests <code>{tagUrl}</code>. Open the Network tab and confirm the
request returns 200. When consent allows the full loader, confirm it has an{' '}
<code>X-Tracking-Scripts</code> header.
</p>
<script src={tagUrl} async />
</main>
);
}
Open this page first after each deploy to confirm, in one request, that the mount path resolves and the handler answers through the gate. Commit everything, including the lockfile, and push. The repository is now complete; later site-specific changes belong in configuration.
Locally, the page prints /tag because the variable is unset. On a deployed site, it prints /tracking/tag, and the Network tab shows that the request completes with the X-Tracking-Scripts header.
5. Deploy the same repository to each site with its own variables
Deploy the same repository to each site with its own variables. For the first site, I create a Webflow Cloud project, connect it to the git repository, set the mount path to /tracking, and add the four variables to the environment I deploy. Then I repeat the process on every other site.
Webflow's environment variables page states: "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime."
Because of that, NEXT_PUBLIC_BASE_PATH can be consumed at build time, while the handler and middleware read the three tracking variables per request.
Environment variables list
Set these four on each site's environment:
- TRACKING_SITE_NAME: A short label such as
marketingordocs, unique per site, so a page'swindow.__trackingSitetells you which configuration it received. - TRACKING_SCRIPTS: The JSON object with
scriptsandinlinefor that site. One variable per site lets the list grow without code changes. - TRACKING_CONSENT_REQUIRED: The string
trueon sites that serve visitors who need a consent gate,falseelsewhere. The middleware compares the string exactly. - NEXT_PUBLIC_BASE_PATH: The mount path,
/tracking, with a leading slash and no trailing one. TheNEXT_PUBLIC_prefix inlines it, so never use it for secrets.
These values are readable, non-credential configuration. During handoff, the next person receives the script list, and the agency developer retains no token.
Before adding head code, confirm the site's plan can publish custom code, and that it is on Premium or higher if the app is mounted on a custom domain. Deploy, and keep the mount path identical everywhere, because the head-code line is identical on every site.
After each deploy, curl -i https://<that site's domain>/tracking/tag returns a 200 whose X-Tracking-Scripts count matches that site's list, and /tracking renders the check page with /tracking/tag printed in it.
6. Reference the tag from each site's head code
Add one script tag in Site settings > Custom code > Head code before you publish. The path is relative, so the same line goes into every site without edits.
It is the last time you touch head code for tracking on that site:
<script src="/tracking/tag" async></script>
Because the request is same-origin, the tracking_consent cookie your banner sets travels with it. A per-site deployment lets each site's gate read its own cookie.
Configure the cookie so requests to /tracking/tag include it; set it in the banner's accept handler and append the tag immediately. Neither response is cached without revalidation, so a fresh request after acceptance passes the gate.
Consent banner handler implementation
Wire the banner's accept button to a function like this:
function grantTrackingConsent() {
document.cookie = 'tracking_consent=granted; Path=/; Max-Age=15552000; Secure; SameSite=Lax';
if (window.__trackingSite) return;
var s = document.createElement('script');
s.src = '/tracking/tag';
s.async = true;
document.head.appendChild(s);
}
If the head-code tag already ran the full loader, either on a site without a consent requirement or for a visitor whose consent cookie is still present, window.__trackingSite is set, and the function returns early.
This prevents the function from appending the loader a second time. The loader withholds vendor scripts until consent. Acceptance loads them on the same page view before a reload. The cookie lasts 180 days, so a returning visitor whose banner stays hidden still gets the full loader.
Withdrawal works the same way in reverse: clear tracking_consent and reload, but scripts already on the page keep running until then, and each vendor's own cookies need their own cleanup. Most consent platforms do not set this cookie for you, so call grantTrackingConsent from your platform's accept callback.
On a published page, use the Network tab to confirm that /tracking/tag returns 200, inspect whether requests for each listed vendor URL appear after it, and confirm that window.__trackingSite in the console returns that site's label.
What causes tracking scripts on Webflow Cloud to fail?
I start with the mount path and runtime settings, including the middleware file name. A site that doesn't meet the custom-domain plan requirement, or a missing environment variable on one site, can cause other failures.
The following symptoms separate mount, runtime, middleware, and environment failures.
/tracking/tag returns 404 on the live site but /tag works under next dev
Cause: The request may be reaching the Webflow site and missing the mounted app because of the base-path behavior covered in step 4. A hardcoded /tag URL or a NEXT_PUBLIC_BASE_PATH left unset on that environment can send the request outside the mounted app and return a 404.
The local server has no mount path, so the same code looks correct until deployment. When behavior differs between sites' custom domains, check whether the failing site meets the custom-domain plan requirement.
Fix: Confirm the head-code line is /tracking/tag, and confirm NEXT_PUBLIC_BASE_PATH is set to /tracking on the environment that is failing (variables are per environment, so a value on the marketing site's environment does nothing for the docs site).
Leave basePath out of next.config; Webflow's bring-your-own-app page says Webflow Cloud sets it at build time from the mount path and overwrites whatever you commit. Redeploy and open /tracking first: when the check page prints /tracking/tag, the variable is right, so check the head code next.
If the plan is the cause, upgrade the site to Premium or higher, redeploy, and run curl -i against /tracking/tag on the custom domain; a 200 whose X-Tracking-Scripts count matches that site's list confirms the mount.
The deploy build fails on app/tag/route.ts after you follow the bring-your-own-app docs
Cause: The route file may contain the runtime = 'edge' export covered in step 2. The bring-your-own-app page still instructs you to add that directive to API routes, and following it produces a build that the OpenNext Cloudflare adapter cannot complete. The confusion comes from one word carrying two meanings on the same page.
Webflow Cloud runs on Cloudflare Workers, an edge platform, and the docs describe that as deploying "using the Edge runtime." The Next.js edge runtime target selected by the directive is a separate concept that OpenNext does not support. The platform sentence can therefore imply that the directive is required, even though it's incompatible.
Fix: Delete the runtime export from every route file and redeploy. The handler already executes on Workers because that is where Webflow Cloud puts every route. If the build still fails, search the repository for the string runtime in case the scaffold or a copied snippet placed it in a layout or a second route.
The consent check stops working after a Next.js upgrade
Cause: Next 16's middleware-to-proxy rename conflicts with the Workers runtime rule in step 3. proxy runs on the Node runtime. At the same time, the framework-customization page says only Edge runtime middleware works on the Workers runtime.
The renamed file never runs on Webflow Cloud, so the gate silently stops. The route's consent check from step 2 still holds, which is why it is there; without it, every visitor would get the full loader.
Fix: Keep the file named middleware.ts and keep it on the default Edge runtime. Decline an automated codemod's rename during a Next.js upgrade, and check the deployed behavior after any major-version bump by requesting /tracking/tag without the cookie on a consent-required site; the comment-only body confirms the gate is running.
Keep the file name in the README for exactly this reason: the next person to upgrade Next.js will see the rename recommended and have no reason to doubt it.
The loader returns 200 with an empty body on one site and works on another
Cause: The handler is using the environment it received. An absent TRACKING_SCRIPTS value produces an empty-looking response. Invalid JSON has the same effect; a stray quote from pasting is the usual cause, and readRegistry swallows the parse error by design.
The gate also returns an empty-looking response when TRACKING_CONSENT_REQUIRED is true, and the site's banner never sets the cookie. Because every site carries its own environment, check each site's values independently.
Fix: Read the headers before the body. Run curl -i against that site's /tracking/tag with no cookie. A body that is the comment line means the gate fired, so either set TRACKING_CONSENT_REQUIRED to false or fix the banner.
A full IIFE with X-Tracking-Scripts: 0 means the registry parsed empty: paste the JSON into a scratch file and parse it with node -e first, then re-enter TRACKING_SCRIPTS on that environment, then redeploy. If the count is right and the vendor still shows no data, investigate the vendor script itself.
What you can build next with tracking scripts and Webflow
Vendor scripts that need a setup snippet as well as a URL, such as Klaviyo's on-site script, fit the same loader: the URL goes in scripts and the snippet in inline.
For on-site behavior and performance data without a third-party script in the list at all, Webflow Analyze is the integrated Webflow option; a Google Analytics integration may still go deeper, and either can sit alongside this loader.
Frequently asked questions
Can one Webflow Cloud app on a single site serve the tag for all my other sites?
Yes, a cross-origin script tag can load the script. The consent gate requires a per-site deployment because the tracking_consent cookie set on site B is not sent with a request to site A. I deploy per site to keep every request same-origin and the cookie attached before any vendor URL is sent.
Do I need a separate repository for each site?
No. Use one repository for the handler, the middleware, and the check page; the four variables on each site's environment carry everything that differs. To roll out a change to the loader logic, push once and redeploy each site. To change a site's script list, edit only that site's TRACKING_SCRIPTS value and redeploy that one project.
Are the tracking variables visible in build logs?
Webflow Cloud redacts secret variables from build logs. Store the four current non-credential variables as plain values. Any future vendor key that must stay private should be marked secret and kept out of every NEXT_PUBLIC_ name. That keeps credentials out of client-side values while leaving site configuration available at build and runtime.
Does this work with Astro or Vite instead of Next.js?
Yes. Webflow Cloud supports Astro 6 or 7 and Vite 6.1 or higher alongside Next.js, per Webflow's framework docs, and Astro 6 and 7 require Node.js 22.12 or later. The Route Handler and middleware.ts conventions are Next.js-specific, so you rebuild the endpoint and the gate with your framework's own server endpoint and middleware APIs.





