# We built a cookie banner that makes your scripts start sooner

*A returning visitor's consent is already on the server. c15t now uses it to start their allowed tags from the HTML in Next.js, Nuxt, SvelteKit and TanStack Start, before the page hydrates, without slowing the page down.*

Engineering | 2026-10-29T16:49:02.311Z

We build the c15t cookie banner, but this change is about the scripts it gates. The server already knows a returning visitor's consent, so it now starts their allowed tags before the page hydrates. On our Next.js bench the first tag starts at 1,073 ms, against 1,773 ms with `next/script`, which has no consent check. That's about 700 ms sooner. Meta Pixel and TikTok Pixel also load sooner, by 87 ms and 197 ms. The Google tag is the exception. `next/script` still loads it first, at 1,986 ms against our 2,631, and the Meta and TikTok beacons go out about 170 ms earlier.

c15t only loads a third-party tag after it checks the visitor's consent. In a React app that check lived in the app's JavaScript. So every tag waited for the page to hydrate, even for a returning visitor who had already said yes. On a throttled mobile profile, that visitor's first tag went in 2 seconds after navigation.

The server already reads the consent cookie to render the page, so now it starts those tags too. On the same bench the first tag goes in at 1,073 ms instead of 2,000, about 750 ms before the page hydrates. First and largest contentful paint stay where they were.

It works in Next.js, TanStack Start, Nuxt and SvelteKit, for the vendor helpers in `@c15t/integrations`. On the Nuxt, SvelteKit and TanStack Start bench apps, the last vendor ran 572 ms to 1,163 ms sooner. Below we cover how it works and how it compares with `next/script`, `@next/third-parties` and `@nuxt/scripts`. We also cover the versions that made the page slower and why a tag that starts 900 ms sooner only finishes about 430 ms sooner.

![Animated timeline of a returning visitor's page load on the Next.js bench. Before: the app hydrates at 1,944 ms and the loader inserts tags at 2,000 ms. Now: the server reads the consent cookie, an inline script inserts tags at 1,073 ms, and the loader adopts them when the app hydrates at 1,824 ms. The first tag goes in 927 ms sooner and the last vendor finishes 434 ms sooner.](https://wcm2vewl2bdc5rhh.public.blob.vercel-storage.com/media/usr_111j6an5hma4xy568s9rcj4c2jevc/fa58460e-40d9-4ced-ba54-5a7ebd7dabfd/loading-scripts-from-the-server-paths-poster.webp)

*The same returning visitor on the Next.js bench, before and after the server start. Medians of 10 loads, slowed down about 3x.*

## Why tags waited for hydration

Before a tag runs, c15t's script loader checks the visitor allowed it, runs the vendor's setup, like `gtag('consent', 'default', ...)` and then inserts the tag. The loader is part of the app bundle, so in React none of that happens until the bundle has downloaded and hydrated.

For a first visit that's right. Nothing optional should run before the visitor chooses. For a returning visitor it's wasted time. With `resolveConsent()`, the server reads the visitor's stored choice on every request, before it sends any HTML.

> The server already reads the consent cookie to render the page. It can start the tags too.

The browser still runs the consent checks before any tag loads.

## Why not next/script

Our first idea was to let the framework load the tags. `next/script` preloads a tag during server rendering and inserts it after hydration, and Nuxt and the others have something similar. Reading Next's `script.js` ruled it out for consent-gated tags:

- The vendor's setup has to run before its script. Google's consent defaults must be set before `gtag.js` loads, and `next/script` has nowhere to do that.
- `next/script` never removes a tag it has loaded, so withdrawing consent couldn't take a tag back out.
- Its element ids change on every page, so c15t's loader couldn't recognize a tag the server had inserted.
- Every framework c15t supports shares one script loader. A Next.js-only path would be a second loader to keep in sync.

Nuxt has `@nuxt/scripts`, with a trigger that waits for consent. We ran it against the server start on the same Nuxt page, further down.

## How the server start works

c15t's vendor integrations were already declarative. Each one is a manifest listing the globals to create, the setup steps to run and the consent signal to send. Nothing has to execute to describe a vendor, so the server can write those steps into an inline script.

In Next.js you don't change anything. The usual root layout already passes the server's state to `ConsentRoot`:

```tsx
// app/layout.tsx
import { ConsentBanner, ConsentRoot } from 'c15t/next';
import { resolveConsent } from 'c15t/next/server';
import type { ReactNode } from 'react';

const RootLayout = ({ children }: { children: ReactNode }) => (
  <html lang="en">
    <body>
      <ConsentRoot state={resolveConsent()}>
        {children}
        <ConsentBanner />
      </ConsentRoot>
    </body>
  </html>
);
```

On a returning visit, this happens:

1. **The server reads the cookie.** \`resolveConsent()\` reads the visitor's stored choice before any HTML goes out.
2. **It renders an inline script.** If that choice allows some \`scripts\` entries, \`ConsentRoot\` renders a small script after the page content.
3. **The browser checks again.** After first paint, the script checks Global Privacy Control, expiry and the cookie itself. If a check fails, it does nothing.
4. **Setup runs, then the tags go in.** Each vendor's setup runs first, so calls like \`fbq('track', ...)\` queue as they always did. The tags wait for any \`fetchpriority="high"\` image that's still loading.
5. **The loader adopts the tags.** When the app hydrates, c15t's script loader finds the tags by their resource key and doesn't insert its own.

The inline script checks the same things the loader does, so it can't start a script the loader wouldn't. It does nothing if the browser sends a Global Privacy Control signal the server didn't read, if the stored decision has expired, or if the consent cookie has a field the browser's decoder would reject. A parity test runs the server start and then the runtime for every consent state, and checks the page ends with the same vendor elements as a page that ran only the runtime, never two of one.

Nothing starts from the HTML in these cases:

- The policy asks the visitor to choose, like a first visit in the EU. Not even `alwaysLoad` entries start.
- The HTML is shared: static, ISR, prerendered or `'use cache'` pages. They get no inline script and stay static.
- A Content Security Policy blocks the script. It takes your nonce, and without one the loader starts the tags after hydration as before.

Nuxt, SvelteKit and TanStack Start work the same way from their own server state. The Nuxt module adds the script at the end of `<body>`. In SvelteKit and TanStack Start, `ConsentRoot` renders it from the state your root load passes in.

To turn it off, so every `scripts` entry starts after hydration:

| Framework | Setting |
| --- | --- |
| Next.js | `scriptLoader: { serverStart: false }` in `c15t.config.ts` |
| Nuxt | `scriptLoader: { serverStart: false }` under `c15t` in `nuxt.config.ts` or `app.config.ts` |
| SvelteKit, TanStack Start | `scriptLoader={{ serverStart: false }}` on `ConsentRoot` |

## Results in Next.js

The bench is a Next.js site for a London pigeon courier, with a 306 KB hero image and the real Google tag, Meta Pixel and TikTok Pixel scripts, loaded with test IDs. Playwright runs it with 4x CPU throttling, 170 ms latency and 1.125 MB/s over HTTP/2. The arms are interleaved, and each number is the median of 10 loads, in ms from navigation start, for a returning visitor who accepted everything.

![Range chart of when the first vendor tag goes in and when Meta Pixel has loaded, in ms. c15t server start: 1,073 to 2,411, hydrated at 1,824. next/script with no consent check: 1,773 to 2,498, hydrated at 1,745. c15t after hydration: 2,000 to 2,862, hydrated at 1,944. OneTrust: 2,406 to 3,260, hydrated at 1,616.](https://wcm2vewl2bdc5rhh.public.blob.vercel-storage.com/media/usr_111j6an5hma4xy568s9rcj4c2jevc/f6da121d-3bcf-462b-8a9e-48192a163c8c/loading-scripts-from-the-server-next-chart.webp)

*TikTok Pixel lands in the same order. The Google tag is the exception, covered below the table.*

| Metric (ms) | next/script, no consent check | OneTrust | c15t, after hydration | c15t, server start |
| --- | --- | --- | --- | --- |
| Largest contentful paint | 1,464 | 1,388 | 1,544 | 1,504 |
| Hydrated | 1,745 | 1,616 | 1,944 | 1,824 |
| First vendor tag inserted | 1,773 | 2,406 | 2,000 | 1,073 |
| Meta Pixel loaded | 2,498 | 3,260 | 2,862 | 2,411 |
| TikTok Pixel loaded | 2,140 | 2,680 | 2,304 | 1,943 |
| Google tag loaded | 1,986 | 3,476 | 3,065 | 2,631 |
| Meta beacon sent | 2,903 | 3,939 | 3,462 | 3,067 |
| TikTok beacon sent | 2,857 | 3,795 | 3,526 | 3,030 |

Against c15t's old path, the first tag goes in 46% sooner and each vendor loads 14% to 16% sooner.

Against OneTrust, the first tag goes in 55% sooner and the vendor beacons fire 20% to 22% sooner. OneTrust is a script tag, so it never waits for hydration. Its page even hydrated first, at 1,616 ms. Its first tag still went in at 2,406 ms, later than c15t's old path that waits for hydration. Before OneTrust runs a tag, it loads a stub, then its config, then a geolocation lookup, then `otBannerSdk.js` and only then calls `OptanonWrapper`. Each request waits for the one before it. We timed those from Resource Timing, the way [CookieBench](https://github.com/inthhq/cookiebench) does.

Most CMPs are built the same way. CookieYes, Usercentrics, Didomi, iubenda, Ketch and Osano are each a script from their own CDN, so no tag runs until that script has downloaded from another origin and run. Most fetch more before they're done. In [CookieBench's request log](https://github.com/inthhq/cookiebench/blob/main/docs/evidence/competitors/initial-browser-probe.json), Usercentrics fetched six files, including its ruleset and settings, and Ketch fetched a boot script, its SDK, an IP lookup and its config. CookieYes was the shortest, at one file. Either way, the visitor's choice is already in their browser, and these scripts can only read it once they've loaded there. We only timed OneTrust on this bench, so there are no numbers for the others here.

`next/script` doesn't check consent at all, and it still inserts its first tag 700 ms after the server start. Meta Pixel and TikTok Pixel load sooner with the server start too, by 87 ms and 197 ms. The Google tag is the one vendor `next/script` loads sooner, at 1,986 ms against 2,631, because it preloads `gtag.js` in `<head>`, so that request starts at about 77 ms. We tried preloads like that for c15t and dropped them, because they delayed the hero image. That's covered below. The beacons also go out about 170 ms sooner with `next/script`: 2,903 ms against 3,067 for Meta, and 2,857 against 3,030 for TikTok.

### Against @next/third-parties

`@next/third-parties` loads the Google tag through its `GoogleAnalytics` component, which renders `next/script`. It has no component for Meta or TikTok, so that arm loads them through `next/script`, as Next's guide shows. In a separate run with the same setup, it inserted its first tag at 1,663 ms and loaded the last vendor at 2,309, next to `next/script` at 1,672 and 2,340. The server start did both sooner, at 946 ms and 2,079, with the Google tag included.

![Range chart from a separate Next.js run of when the first tag goes in and the last vendor loads, Google tag included. c15t server start: 946 to 2,079 ms. @next/third-parties: 1,663 to 2,309. next/script: 1,672 to 2,340. Neither Next.js option checks consent.](https://wcm2vewl2bdc5rhh.public.blob.vercel-storage.com/media/usr_111j6an5hma4xy568s9rcj4c2jevc/e60234a5-a20d-419f-a7ad-37248586ecc5/loading-scripts-from-the-server-third-parties-chart.webp)

*From the five-arm run in c15t#1526. Only these three arms were reported.*

## Nuxt, SvelteKit and TanStack Start

For the other frameworks we added a returning-visitor page to each bench app, with the server start on and off in the same build. The vendor scripts are small stand-ins for Google tag, Meta Pixel and TikTok Pixel, served from a separate origin the way a vendor's CDN is. The throttling matches the Next.js bench. Each number is the median of 15 loads, with the arms interleaved.

![Dot chart of when the last vendor ran with c15t's server start off and on. Nuxt: 1,628 ms off, 465 ms on, 1,163 ms sooner. SvelteKit: 1,274 off, 451 on, 823 sooner. TanStack Start: 1,262 off, 690 on, 572 sooner. Astro, which has no server start: 1,001 ms.](https://wcm2vewl2bdc5rhh.public.blob.vercel-storage.com/media/usr_111j6an5hma4xy568s9rcj4c2jevc/89c19659-c578-487f-8e9b-56ef42792a16/loading-scripts-from-the-server-frameworks-chart.webp)

*Astro has no server start. Its gated scripts start from the client module, so it has one arm, as a reference.*

| Last vendor ran (ms) | Off | On | Sooner | Hydrated, on |
| --- | --- | --- | --- | --- |
| Nuxt | 1,628 | 465 | 1,163 | 1,173 |
| SvelteKit | 1,274 | 451 | 823 | 1,025 |
| TanStack Start | 1,262 | 690 | 572 | 952 |

With the server start on, all three send the first vendor request about 10 ms to 20 ms after first paint. The gain depends on how long each framework takes to hydrate and read consent. Nuxt takes the longest, so it gains the most.

First paint and hydration stayed within noise on Nuxt and SvelteKit. TanStack Start paid a little: the `load` event came 55 ms later and hydration 30 ms later, or 88 ms at the 90th percentile. Its vendor downloads now land before `load` and compete with the app's chunks.

Where the vendor scripts come from matters. In an earlier run we served the stand-ins from the app's own origin. The gain shrank to 120 ms to 400 ms, and `load` moved 250 ms to 350 ms later on Nuxt and TanStack Start, because the vendor requests queued behind the app's chunks for the same six connections. Real vendors load from their own origins, so the separate origin is the fairer test. A tag you self-host or proxy through your own domain behaves like the first run.

## Against @nuxt/scripts

Nuxt sites often load tags with `@nuxt/scripts`, so we added version 1.3.12 to the Nuxt bench as three more arms in the same build: its consent trigger, fed by c15t's consent state, its default trigger and `trigger: 'server'`. Every arm loads the same stand-ins from the same origin. This run was on a busier machine, so every arm hydrated at about 1.8 s. Compare the arms with each other, not with the table above.

![Bar chart of when the last vendor ran on one Nuxt page. c15t server start: 499 ms, waits for consent, 3 tags. @nuxt/scripts with trigger server: 440 ms, loads for every visitor, 6 tags. @nuxt/scripts default trigger: 2,039 ms, loads for every visitor. @nuxt/scripts consent trigger: 2,121 ms. c15t with the server start off: 2,249 ms. Every arm hydrated at about 1.8 s.](https://wcm2vewl2bdc5rhh.public.blob.vercel-storage.com/media/usr_111j6an5hma4xy568s9rcj4c2jevc/bb849bb2-ee5a-4c35-9bf3-de82913995a3/loading-scripts-from-the-server-nuxt-scripts-chart.webp)

*Striped bars load for every visitor, whatever they chose.*

| Arm (ms) | Waits for consent | First vendor request | Last vendor ran | Tags |
| --- | --- | --- | --- | --- |
| c15t, server start | Yes | 313 | 499 | 3 |
| `@nuxt/scripts`, `trigger: 'server'` | No | 247 | 440 | 6 |
| `@nuxt/scripts`, default trigger | No | 206 | 2,039 | 3 |
| `@nuxt/scripts`, consent trigger | Yes | 1,933 | 2,121 | 3 |
| c15t, server start off | Yes | 2,064 | 2,249 | 3 |

- **Consent trigger.** On the server it never fires, so nothing goes into the HTML. In the browser it fires during hydration, about 130 ms ahead of c15t with the server start off.
- **Default trigger.** It puts a `<link rel="preload">` for each vendor in the HTML. The downloads finish by about 400 ms, but the scripts don't run until after hydration. Every visitor sends those requests, including visitors who later decline.
- **`trigger: 'server'`.** The fastest arm, 60 ms ahead of the server start, which is within this run's noise. Its tags are in the HTML, so every vendor loads for every visitor. In this version each vendor also loads and runs twice, because the browser inserts a second copy of each tag the server rendered.

Of the options we measured, the server start is the only one that waits for consent and still starts before hydration.

## Starting sooner isn't finishing sooner

The first tag goes in 927 ms sooner, but the last vendor only finishes 434 ms sooner. From insert to loaded now takes about 1,560 ms, against 1,065 ms before.

Before, the tags downloaded after the app's JavaScript had arrived, so they had the connection to themselves. Now they download alongside it on the same throttled link, and they still have to run on a main thread that's busy hydrating.

The obvious fix is to start the downloads even sooner with a preload. We tried that first.

## What we tried and threw out

The rule from the start was that we don't trade how fast the page shows up for how soon tags start. In the shipped version, first paint lands within 48 ms of c15t's old path, which is noise, and so does largest contentful paint. Getting there meant finding every place an earlier version broke that rule.

### Preloading the vendor scripts

A `<link rel="preload">` in `<head>` for each allowed vendor loads them about 700 ms sooner. It also delays the hero image by 300 ms, largest contentful paint by 200 ms and hydration by 600 ms. `fetchpriority="low"` doesn't help, because the vendor downloads still share the connection with the page's image and JavaScript. A `preconnect` to the vendor origins did nothing measurable in either direction over 30 rounds. Both are out.

### Blocking first paint, twice

The first version rendered the inline script before the page content. That put it at the top of `<body>`, after two stylesheets, and an inline script after a stylesheet can't run until the stylesheet loads. The parser stopped there, and first paint went from 388 ms to 444 ms. Rendering the script after the content brought it down to 408 ms.

The second time showed up on the bigger page, as about 40 ms of extra first paint. A trace showed about 28 ms of the script's setup running in the same task as first paint. It now waits for `requestAnimationFrame` and then a `setTimeout`, so it runs after first paint and still well before hydration.

### Preloading c15t's own loader

For a while the recommended setup also preloaded the 15 KB chunk that holds c15t's script loader, so hydration would find it sooner. In a 15-round A/B it cost about 36 ms of first contentful paint and didn't make vendors start any sooner. We haven't worked out why it costs that much, but it's gone.

### Competing with the hero image

On a page with a large hero, starting three vendors early made largest contentful paint 248 ms worse, in all 15 rounds. The inline script now runs vendor setup straight away, but holds the tags until every `<img fetchpriority="high">` that's still loading has finished, for at most 3 seconds. With that wait, largest contentful paint is within noise of the old path, and the server start keeps 60% to 75% of its gain.

It only knows about images marked `fetchpriority="high"`. A hero set as a CSS background, or a largest contentful paint that's text in a web font, isn't covered.

### Taps on the banner

Google tag is an `alwaysLoad` entry, so the first version started it from the HTML on first visits too, while the banner was asking for a choice. It held the main thread right when the visitor tapped. A tap at 1 second took 268 ms to hide the banner, against 114 ms before. Now nothing starts from the HTML while the banner is showing, and the same tap takes 84 ms.

### A lazy banner

A returning visitor never sees the banner, so we tried loading the banner's code only when it might show. It saved returning visitors 4 KB. It cost first-time visitors 7 KB and made the banner clickable 181 ms later, and review found a case where an early Reject tap could get lost. That's the wrong trade for a consent banner, so we [closed the PR](https://github.com/c15t/c15t/pull/1525).

## Does it cost main-thread time?

The vendor code is the same either way. It just runs sooner. In a final 20-load run with Long Animation Frame attribution, the last vendor loaded 609 ms sooner, 2,481 ms against 3,091 (p \< 0.01). Total blocking time was 1,485 ms against 1,483. Each vendor cost the same main-thread time in both arms: about 285 ms for Google tag, 350 ms for Meta and 300 ms for TikTok. The inline script averaged 2 ms and never showed up in a slow frame. Largest contentful paint was 68 ms later with p = 0.47, which is noise at this sample size.

One load in that run went badly. The network stalled, the hero still hadn't loaded at 3.9 seconds, the 3-second image wait ran out, and about 1.2 seconds of vendor code ran before hydration. That load hydrated at 6.2 seconds. It's one load in 19, and it's the case where the image wait makes things worse instead of better.

## Who gets it

The server has to render per request and already know the answer:

- Returning visitors whose stored choice allows some scripts.
- First visits under an opt-out policy, like c15t's US presets, where no banner shows and categories start granted. A browser that sends GPC still gets nothing.
- Next.js (App Router and Pages Router, on React 18 or 19), TanStack Start, Nuxt and SvelteKit. Astro isn't covered: its gated scripts start from the client module.

First visits under opt-in rules, like the EU and UK, don't change. Neither do static pages, or `scripts` entries you write yourself, which still start after hydration.

## What the bench can't tell you

This is lab data from one machine. Chrome's throttling ignores fetch priority and doesn't model connection setup, so the priority and `preconnect` results are unproven rather than disproven. The vendor scripts are real but run with test IDs, on a page we wrote for the bench. The `next/script` arm skips the consent check, and the server start still puts its first tag in sooner. And the gain moves between runs. Across the Next.js pages and runs in this work, the last vendor finished between about 430 ms and 660 ms sooner.

The Nuxt, SvelteKit and TanStack Start numbers come from small stand-in scripts, so they time when vendors start, not what real vendor code costs when it runs before hydration. Real Google tag or Meta code could slow hydration more than those numbers show. That machine was busy with other work the whole time. Interleaving the arms keeps the gaps between them fair, but not the absolute times. The `@nuxt/scripts` arms use plain `useScript`, not its registry composables, so the bundling and proxying those add aren't measured.

We also haven't counted events. Earlier tags should catch more of the visitors who leave in the first second or two, along with their `gclid` and UTM parameters. Until someone counts beacons on a real site that's a guess, so there's no conversion number here.

## Was it worth it?

Yes. For returning visitors every vendor finishes sooner, normal loads don't get slower and the inline script runs the same consent checks as the loader. Tags start sooner because the server already knows the answer, not because it skips the check. Most of the work, and most of the review comments, went into the cases where the inline script must not run.

It's in [c15t#1524](https://github.com/c15t/c15t/pull/1524) through [#1539](https://github.com/c15t/c15t/pull/1539): the [Next.js server start](https://github.com/c15t/c15t/pull/1526), then [TanStack Start](https://github.com/c15t/c15t/pull/1529), [Nuxt](https://github.com/c15t/c15t/pull/1530), [SvelteKit](https://github.com/c15t/c15t/pull/1531), [the other vendors](https://github.com/c15t/c15t/pull/1532), [React 18](https://github.com/c15t/c15t/pull/1534) and [the off switch](https://github.com/c15t/c15t/pull/1539). Each PR has its benchmarks and the tests that fail without it. It ships in the next c15t release, and if you already pass `resolveConsent()` to `ConsentRoot`, you get it without changing anything.

## Keep reading

[All articles](/blog)