The Stack
How to add AdSense to SvelteKit without fighting your own CSP
SvelteKit has two AdSense traps, and both come from features you'd reasonably turn on. Its built-in Content Security Policy defaults to a mode Google explicitly doesn't support for ad code. And afterNavigate — the obvious hook for re-filling ad slots — also fires on first load, so the naive version double-pushes every ad on your site.
SvelteKit runs AdSense fine once it’s set up, but it has two traps that generic guides walk straight past — and both are triggered by turning on features you’d sensibly want. The first is SvelteKit’s built-in Content Security Policy. The second is afterNavigate, which looks like exactly the right hook for re-filling ad slots and will double-push every ad if you use it the obvious way.
Verified against SvelteKit 2.69 and Svelte 5 (July 2026).
Step 1 — ads.txt in static/
Everything in SvelteKit’s static/ directory is copied to the root of your build output, so create static/ads.txt:
google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0
Confirm it resolves at https://yoursite.com/ads.txt after deploy, and that apex and www both resolve or one 301s to the other — AdSense checks the root of your registered domain.
Resist the urge to make this a +server.js route. It’s a static file, and ads.txt gets crawled constantly; a route turns every one of those crawls into a function invocation you pay for.
Step 2 — The loader in app.html
Put the loader in src/app.html, immediately before %sveltekit.head%:
<!-- src/app.html -->
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-0000000000000000"
crossorigin="anonymous"
></script>
%sveltekit.head%
</head>
app.html is the right home for this rather than <svelte:head> in a layout. It applies to every route including prerendered ones, and the script isn’t route-scoped — so client-side navigation never tears it down and re-creates it. The tag is already async, so it doesn’t block render.
Step 3 — A reserved-space ad unit
In Svelte 5, use $effect for the mount-time push and reserve the slot’s height:
<!-- src/lib/AdUnit.svelte -->
<script lang="ts">
let { slot, client }: { slot: string; client: string } = $props();
let ins: HTMLElement;
$effect(() => {
try {
// @ts-expect-error injected by the AdSense loader
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch (e) {
console.error('adsbygoogle error', e);
}
});
</script>
<ins
bind:this={ins}
class="adsbygoogle"
style="display:block; min-height:280px"
data-ad-client={client}
data-ad-slot={slot}
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
min-height:280px is the line that protects your revenue. An ad arriving into zero reserved height pushes content down, which wrecks CLS — the Core Web Vital that gates viewability and therefore what you earn. Match the reserve to the unit you expect; the placement guide has the table.
$effect only ever runs in the browser, which conveniently sidesteps the SSR problem: window.adsbygoogle doesn’t exist on the server, and touching it there throws.
Step 4 — The afterNavigate trap
SvelteKit intercepts navigation client-side, so moving between routes doesn’t reload the document and the loader never rescans for new <ins> elements. Slots on the second page you visit stay blank. The fix is to push again after each navigation — and this is where almost everyone gets bitten.
afterNavigate runs when the component mounts, not only when you navigate. So the naive version fires on first load, where the loader has already filled your server-rendered <ins> during its own initial scan, and you push a second time into an already-filled slot. That’s the All 'ins' elements in the DOM with class=adsbygoogle already have ads in them error, and it means the first ad on every fresh page load is broken.
SvelteKit gives you exactly what you need to tell the cases apart: navigation.type is 'enter' for initial hydration, versus 'link', 'goto', 'popstate', or 'form' for real navigations. Return early on 'enter':
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { afterNavigate } from '$app/navigation';
let { children } = $props();
afterNavigate((navigation) => {
// The loader already filled the SSR'd slots on first paint.
if (navigation.type === 'enter') return;
try {
// @ts-expect-error injected by the AdSense loader
(window.adsbygoogle = window.adsbygoogle || []).push({});
} catch (e) {
console.error('adsbygoogle error', e);
}
});
</script>
{@render children()}
One constraint worth knowing: afterNavigate must be called during component initialisation and stays active while that component is mounted. Put it in a root +layout.svelte, not inside a conditional block or an event handler.
If a route can hold several units, keying the ad components on page.url.pathname so each navigation mounts fresh, empty <ins> elements is more robust than pushing once per navigation — the same approach the Next.js guide uses.
Step 5 — The CSP trap (the one that will waste your afternoon)
SvelteKit ships Content Security Policy support in svelte.config.js, and it’s well built — it generates hashes and nonces for its own inline scripts and styles automatically. Turning it on is a straightforwardly good idea, right up until you run ads.
kit.csp.mode defaults to 'auto': nonces for dynamically rendered pages, hashes for prerendered ones. And Google’s
AdSense CSP guidance is explicit that AdSense supports only strict, nonce-based CSP — not domain allowlists — because its ad-serving domains change over time. The policy Google documents looks like this:
object-src 'none';
script-src 'nonce-{random}' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:;
base-uri 'none';
That 'strict-dynamic' is the load-bearing part: it lets the nonce’d loader vouch for the scripts it goes on to inject, which is how AdSense can keep working while its domains change underneath you.
Put those two facts together and the conclusion is sharper than “add Google to your allowlist”:
- On prerendered routes,
mode: 'auto'gives you hashes — and hashes cannot work here. You can’t hash scripts that don’t exist yet, and AdSense injects them at runtime. Adding domains toscript-srcdoesn’t rescue it either, since Google says allowlists aren’t supported and may break without notice. mode: 'nonce'needs a fresh nonce per response, which means the page must be dynamically rendered. A fully prerendered SvelteKit site can’t produce one.
So if your site is prerendered (adapter-static, or export const prerender = true) and you want AdSense, don’t use SvelteKit’s built-in CSP for this. Set the CSP header at your host or edge instead — Cloudflare, Netlify, or Vercel headers config — where you can emit Google’s documented policy without SvelteKit trying to compute hashes for scripts it can’t see.
If you’re server-rendering anyway, mode: 'nonce' is viable, but you’ll still need 'unsafe-inline', 'unsafe-eval', and 'strict-dynamic' in script-src per Google’s policy — which is a materially weaker CSP than the one you turned this on for. That’s the honest trade: running AdSense costs you most of the benefit of a strict CSP, on any framework. SvelteKit just surfaces the bill earlier than most.
Step 6 — Consent Mode v2 for EEA/UK/CH traffic
Serving ads to the EEA, UK, or Switzerland requires a Google-certified CMP (IAB TCF), and the consent signal must be established before the ad loader runs. In app.html that means putting the CMP tag above the AdSense tag — document order is your load order here, which is one more reason the loader belongs in app.html where you can see the sequence. Skipping this throttles serving to unconsented EEA traffic, so it’s a revenue issue as much as a compliance one.
One note on Auto ads
Auto ads works with the app.html loader — enable it per-site in the AdSense dashboard, no extra code. It also sidesteps the afterNavigate problem, since Google’s script handles its own placement. The tradeoff is CLS: Auto ads inserts elements dynamically and can shift layout, where manual units with a reserved min-height give you control. On a content site where speed is the revenue lever, that control is usually worth the extra wiring. Prefer a static-first framework for a pure content site? Here’s the same setup on Astro.
FAQ
- Why does AdSense break when I enable CSP in SvelteKit?
- Because of a mode mismatch. SvelteKit's kit.csp defaults to mode 'auto', which uses nonces for dynamically rendered pages and hashes for prerendered ones. Google's AdSense documentation states that AdSense only supports strict, nonce-based CSP — not domain allowlists or hashes — because its ad-serving domains change over time. So on any prerendered SvelteKit route, the hash-based policy SvelteKit generates cannot accommodate the ad code. Either set mode to 'nonce' (which requires dynamic rendering) or don't use SvelteKit's built-in CSP and set the header at your edge/host instead.
- Why do my SvelteKit ads throw 'already have ads in them'?
- Because afterNavigate also runs when the component mounts, not only on real navigations. On first load the AdSense loader already fills the server-rendered <ins> during its own initial scan, and then your afterNavigate callback pushes again into the same filled slot. Guard it by checking the navigation type: SvelteKit sets navigation.type to 'enter' for initial hydration, so return early on 'enter' and only push for 'link', 'goto', 'popstate', and 'form'.
- Where does ads.txt go in a SvelteKit project?
- In static/ads.txt. Everything in SvelteKit's static/ directory is copied to the root of your build output, so it resolves at yourdomain.com/ads.txt, which is where AdSense checks. Don't create a +server.js route for it — it's a static file, and a route adds a function invocation to something crawled constantly.
- Where do I put the AdSense loader script in SvelteKit?
- In src/app.html, just before %sveltekit.head%. That puts it in the document head on every route, including prerendered ones, and it loads once for the life of the SPA session rather than being torn down and re-created by client-side navigation. Using <svelte:head> in a layout works too, but app.html is simpler and avoids the script being treated as route-scoped.
Keep reading
- Add AdSense to Nuxt The other SSR-first framework — where the trap is a privacy proxy that's on by default.
- Add AdSense to Next.js Same client-side-routing problem, solved with next/script and a pathname key.
- Where to put ads (without wrecking your page) The placements and reserved-space rules that protect Core Web Vitals.