Skip to content
← Back to blog
Guides August 1, 2026· 9 min read

Adding analytics to a SvelteKit app without a cookie banner

Adding cookieless analytics to SvelteKit: an SSR-safe component in the layout, events via useTakt, and why you see nothing in development.

Contents

A SvelteKit app has a recognisable shape: a root layout wrapping everything, a server render followed by hydration, a client router that takes over on the first click. Then it’s time to measure traffic, and the first page of results offers the same rig every time: an ad-tech tag, a consent manager to keep it in line, a banner to ask permission. On a server-rendered app that banner is no decorative strip: it’s a stateful component that has to be hydrated, whose answer must be known at render time (so read from a cookie sent with the request) and which gates the loading of a third-party script. You aren’t adding a line to your layout; you’re adding a branch to your render.

This piece has the rare advantage of being verifiable from the page you’re reading. taktlytics.com runs on SvelteKit, and it measures itself with Takt. Open your devtools, Network tab, filter on event, reload: you’ll watch the measurement request leave. Nothing asked your permission, no modal appeared, and you were counted. That isn’t a demo staged for the occasion, it’s the site in front of you.

This guide walks through the SvelteKit integration — the component in the root layout, events, and how the two integration styles divide the work — and dwells on the one place that genuinely costs you time: two entirely independent mechanisms produce the exact same symptom, “I can’t see anything”, and only one of them leaves a trace.

Why the banner isn’t inevitable

What triggers the consent requirement isn’t the act of counting visits. It’s writing to or reading from the visitor’s device: a cookie, an entry in local storage, a reconstructed browser fingerprint. Measurement that writes nothing to the browser and never tries to recognise anyone from one visit to the next falls outside that scope. The second source of friction, transferring personal data to servers outside the Union, disappears the same way once collection and storage stay in Europe.

What’s left is exactly what you need to steer a product: aggregates. Visits, pages viewed, traffic sources, breakdowns by country, region, device, browser and operating system. No individual profile, no persistent identifier, nobody to recognise from one session to the next. That’s the design bet behind Takt: no cookie, no identification, a managed service hosted in Europe, and a small browser runtime instead of a stack of tags. The full contrast with the Google Analytics approach is on our comparison page.

On the SvelteKit side the payoff is pleasantly concrete. No cookie to read in hooks.server.ts, no +layout.server.ts whose only purpose is to carry a consent answer down to the layout, no “before/after acceptance” branch to render twice and test twice. And a page whose render depends on a cookie stops being prerenderable: a banner doesn’t only cost you JavaScript, it pulls whole pages out of the prerender. The benefit isn’t only technical either: nobody declines or dismisses a modal that doesn’t exist, so your numbers describe all of your traffic rather than the fraction that clicked “Accept”.

Dropping the component into the layout

pnpm add @vskstudio/takt-svelte @vskstudio/takt-core

Two packages, because @vskstudio/takt-core is a peer dependency of the wrapper, exactly like svelte itself — which is why the install line names it explicitly rather than leaning on your package manager’s peer resolution. The wrapper is written with runes and requires Svelte 5; it adds no collection logic of its own, it just makes the core feel native inside a Svelte app.

<!-- +layout.svelte -->
<script>
  import { Takt } from '@vskstudio/takt-svelte'

  let { children } = $props()
</script>

<Takt domain="example.com" outbound files />
{@render children()}

That block shows the shape of the file, not a file to paste over yours. Your root layout already exists, with its imports, its navigation, its styles: the only lines to add are the import and the <Takt … /> tag. How you render your children stays your business and needn’t change: {@render children()} is the Svelte 5 form, which the wrapper requires anyway, and a layout still on <slot /> keeps working, with nothing worse than a deprecation warning at compile time. The component renders nothing and doesn’t care what surrounds it.

Once, and in the root layout: src/routes/+layout.svelte, not a route group’s layout. The reason isn’t cosmetic. The component tears down cleanly what it wired up: when it is destroyed it detaches its listeners and clears the shared instance. A <Takt> sitting in src/routes/(app)/+layout.svelte therefore stops measuring the second a visitor leaves the group, and your stats end up describing half the site — with no error anywhere to tell you.

The component is SSR-safe: it only boots the runtime on mount, in the browser. Nothing touches window, document or history during the server render, so there’s no crash while rendering, no hydration mismatch, and no guard for you to write.

domain is the option to set yourself in production. It defaults to location.hostname, the host serving the page, and ingestion only knows the domains registered in your account: declare the exact domain you registered in Takt, and the same one across every production deploy. Preview deploys are a separate case, covered further down alongside the verification, where that default turns out to be an asset rather than a nuisance. What you won’t find in that block is an ingestion address, and that’s deliberate: the endpoint option exists, but its default points at the hosted Takt service, never at a server of your own, and it works untouched. Don’t invent a value for it: a wrong one buys you a perfectly silent 404, because the transport goes through navigator.sendBeacon(), which returns true the moment the request is queued and has no way to hand you back a response status. A neighbouring option, scriptOrigin, derives the ingestion address from an origin of your own, for setups where measurement goes through a proxy on your domain.

outbound and files switch on two autocaptures: clicks going off-site, and downloads. Like every capture they’re off by default — track404 for error pages and tagged for elements marked up in the HTML round out the set. Navigation, on the other hand, is tracked with nothing to switch on: the core patches pushState and replaceState, and listens for popstate and hashchange. A client-router click, a back button, an anchor change: each one fires its pageview. The corollary is worth knowing because it’s specific to SvelteKit: anything that writes a history entry counts as a pageview, including the shallow routing you’d use to open a modal without changing screen. Lean on it heavily and your pageviews count those openings too. It’s one of the classic pitfalls of measuring an app with client-side navigation: counting URL changes that don’t change screen.

The trap: two silences, one message

Here’s the scene. You’ve dropped in the component, added a button that fires an event, run pnpm dev, and the dashboard is empty. You re-read the config, you check the domain, you drop a console.log into the click handler — it runs. Nothing breaks, nothing arrives.

The trouble is that two independent mechanisms can produce that emptiness, with unrelated causes, unrelated scopes and unrelated fixes. Debugging the wrong one of the two easily costs an hour.

Silence #1 — you’re on localhost. The excludeLocalhost option defaults to true. In development nothing is sent, and “localhost” is read generously: localhost, ::1, 0.0.0.0, .local names, and the private ranges 127.x, 10.x, 192.168.x, 172.16 through 172.31. A vite dev --host opened from your phone on the local network is covered as well. It isn’t a fault: it’s what keeps three weeks of development out of your production stats. The part that matters below: this filter cuts everything, pageviews and events alike, and it says nothing.

Silence #2 — the instance was resolved too early. useTakt() never throws. When no <Takt> has mounted yet, it hands back a stand-in instance on which track and pageview do nothing. And the component only boots the runtime on mount, whereas a component’s script runs before any mount. So the code below, which is the code anybody writes first, captures the stand-in and keeps it:

<script>
  import { useTakt } from '@vskstudio/takt-svelte'

  // The trap: the instance is resolved on the very first render, before mount.
  const takt = useTakt()
</script>

<button onclick={() => takt.track('Signup', { props: { plan: 'pro' } })}>
  Sign up
</button>

What makes this trap expensive is that it doesn’t always show. On the landing page, the one present at first render and the one your organic traffic arrives on, the component’s script runs before the layout mounts: the instance is the stand-in, and the click sends nothing. Navigate to that same page from elsewhere on the site and the component mounts with the runtime already in place: identical code, working fine. So you test by clicking around your app, everything looks right, and the visitor landing straight from a search engine is never counted.

Unlike #1, this silence does leave a trace: a console warning, [takt] useTakt() called before <Takt /> mounted — returning a no-op instance.. Its scope is worth a second of attention, because the flag that stops it repeating lives at module level, not at page level. In the browser it fires once per load, however many components are affected. During the server render it won’t fire again until the module is re-evaluated, which in practice means for the lifetime of your pnpm dev process: after you attempt a fix, its silence proves nothing until you have restarted the server. Once you know it, it settles the question in seconds. You do have to look at the console, though, which nobody does when the symptom is “the dashboard is empty”, because that sends you looking at the server.

Telling them apart. The two causes don’t cut the same things, and that’s what separates them without any guesswork:

  • Nothing at all — no request on load, none on navigation, a clean console: that’s #1. You’re on localhost or a private IP.
  • Pageviews arrive, the event is missing — with a [takt] line sitting in the console: that’s #2. The offending component resolved its instance too early.

The check is short and happens in the browser. Open the Network tab, filter on event, reload: one request should leave. Click three or four internal links: one request per click. Then click the button meant to fire your conversion and open the last request’s payload: the event name is in there under the n key, next to the declared domain.

Read the status column too, the part everyone skips. A request leaving tells you nothing about where it landed: a wrong endpoint sends one just fine, as a 404, and the transport will never report that back to you. A 202 is the floor to insist on. It isn’t sufficient either, because ingestion answers 202 and discards the event without a word when the declared domain isn’t registered in your account. What this procedure establishes is that the client side works: the resolved instance is the real one, the event carries the right name, the request is well formed and accepted. Whether the data is then credited to the right site is read elsewhere, in the dashboard.

Getting out of #1. Two routes, and each one has a price. The first, excludeLocalhost={false} for the length of a test, gets requests leaving your machine; forgotten in a commit, it pours your development traffic into the declared site’s stats. The second, a preview deploy, isn’t free either: a preview carrying the production domain hard-coded produces exactly the same spill, on every reload and every click of the check above, and with no suspicious line to find in the diff.

What makes a preview safe is leaving domain on its default there. The host being served then isn’t a domain registered in your account: ingestion answers 202 and discards the event. Requests still leave, the check above still observes them and still settles the whole client side, and your production stats don’t move. That’s isolation, not breakage. The trade-off is that it tells you nothing about the end of the trip, which only a production deploy under the right domain can confirm. And if you’d rather have an explicit switch than an implicit filter, the component exposes enabled: an enabled={!dev}, with dev imported from $app/environment, makes the local silence deliberate and visible in the code.

Getting out of #2: resolve the instance at emit time rather than at component init — the next section does exactly that — and check while you’re there that <Takt> really is in the root layout, so it covers the whole tree and not a slice of it.

Firing events

Pageviews ask nothing of you. For conversions — a signup, a purchase, a submitted form — useTakt() returns the shared instance, and you call it inside the handler:

<script>
  import { useTakt } from '@vskstudio/takt-svelte'
</script>

<button onclick={() => useTakt().track('Signup', { props: { plan: 'pro' } })}>
  Sign up
</button>

<button
  onclick={() =>
    useTakt().track('Purchase', {
      props: { plan: 'pro' },
      revenue: { amount: '29.00', currency: 'EUR' }
    })}
>
  Buy
</button>

One move separates this from the block in the previous section: the call happens on click, not on first render. By then the layout mounted long ago and the resolved instance is the real one, whether the visitor landed on the page or navigated to it. It’s a habit worth forming once: in a Svelte component, whatever sits in the <script> runs at a moment when the measurement runtime does not yet exist.

The first argument is the event name, and that name is data, not interface copy: it’s the exact value you’ll find in your stats. Keep it stable and identical everywhere. On a bilingual site, a Signup translated to Inscription on the French side doesn’t rename a conversion: it creates a second one and cuts yours in half, with nothing anywhere to flag the split. The same rule covers property keys — plan stays plan.

Revenue is declared event by event, in the options passed to track(), as above: nothing is configured once and for all on the instance. The amount is a string that has to match \d+(\.\d{1,2})?, the currency a three-letter code. This isn’t a stylistic preference: a malformed amount (a number instead of a string, three decimals, a currency symbol) is discarded, and the event still goes out, minus its amount. You end up with conversions counted correctly and revenue at zero, which would be the worst of both worlds if the rejection left nothing behind. It leaves a console warning, [takt] revenue dropped: amount must match \d+(.\d{1,2})?, once for the whole page however many amounts are affected. Format amounts as you send them, especially if they come out of floating-point arithmetic. These sends leave from the browser, so they stay within reach of an ad blocker or a tab closed during a payment redirect: if your app talks to a PHP back end, the piece on the Laravel integration shows how to emit the same conversion from the server.

Component or actions: pick one

The package exposes two integration styles on the Svelte side, and you have to choose. The <Takt> component is the idiomatic path: it accepts the full set of core settings — enabled, sampleRate, trackQuery, queryParams, exclude and scrubUrl included — and the one configuration option it doesn’t expose is debug.

The other path is the /actions subpath, which leaves you to call init() yourself:

<script>
  import { onMount } from 'svelte'
  import { init } from '@vskstudio/takt-svelte/actions'

  // init() touches the History API: never during the server render.
  onMount(() => {
    init({ domain: 'example.com', debug: true, sampleRate: 0.5 })
  })
</script>

That onMount isn’t a comfort measure. The component was shielding you from the server render; here the job is yours. An init() written straight into the body of the <script> also runs on the server, where it touches the History API and throws a ReferenceError — your page stops rendering entirely. What you get in exchange is thin: debug, which logs every payload actually sent to the console. Handy to confirm a departure, useless to explain a silence, since a filtered event never reaches that logging.

Which leaves the rule that matters: never both at once. The component builds its own instance; init() installs a different one, the instance the core’s standalone functions talk to. Mounting <Takt> and calling init() in the same app isn’t configuring measurement in two places, it’s running two of them — each with its own navigation tracking, so every pageview counted twice. The converse holds too: if all you mount is <Takt>, the functions imported from /actions are talking to nobody and do nothing. The doubling that results is flat — two on every navigation, never three: it’s the signature of measurement installed twice, whatever the shape, and the Astro piece works through another variant of it, the navigation listener added one time too many.

Wrapping up

One package and its peer dependency, one domain to declare, one <Takt /> tag in the root layout: a SvelteKit app is measured with no cookie and no banner, client-side navigation included, with no listener to wire and nothing to shield from the server render. The genuinely sharp edge isn’t installation, it’s diagnosis: two independent mechanisms produce the same emptiness. excludeLocalhost, true by default, cuts absolutely everything for as long as you’re on localhost or a private IP, and says nothing about it; useTakt() called during a component’s init returns a stand-in that sends nothing, only cuts that component’s events, and leaves a single console warning. No pageviews and a quiet console is the first; pageviews present and an event missing is the second. Call useTakt() in the handler rather than in the <script>, and the second one stops existing. The full option reference is in the Svelte documentation.

Take the next step

Measure your audience without a consent banner.

See Takt in action, then install cookieless analytics on your site.

Share