Using Unhead with SvelteKit
SvelteKit applications need separate Unhead instances for server rendering and client-side navigation. This guide connects them through SvelteKit's handle hook, locals, and Svelte context.
Setup
1. Install the package
pnpm add @unhead/svelte@next2. Update app.d.ts
Extend SvelteKit's Locals interface so the head instance can flow through the request:
import type { Unhead } from '@unhead/svelte/server'
declare global {
namespace App {
interface Locals {
unhead: Unhead
}
}
}
export {}
3. Create the head in hooks.server.ts
Use the handle hook to create a head instance per request and render the managed tags into the HTML response:
import { createHead, transformHtmlTemplate } from '@unhead/svelte/server'
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
const unhead = createHead()
event.locals.unhead = unhead
const response = await resolve(event)
if (!response.headers.get('content-type')?.includes('text/html'))
return response
const html = await response.text()
const transformed = transformHtmlTemplate(unhead, html)
const headers = new Headers(response.headers)
headers.delete('content-length')
return new Response(transformed, {
headers,
status: response.status,
statusText: response.statusText,
})
}
transformHtmlTemplate() needs the complete HTML document, so this example buffers HTML responses instead of using SvelteKit's chunk transform. It extracts existing head tags and attributes, merges the managed tags, and writes the result back into the document. Non-HTML responses pass through unchanged.
4. Set SSR head tags in server load functions
SvelteKit serializes load return values, so you cannot pass the unhead instance directly to components. Push SSR head tags from server load functions through locals.unhead:
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals }) => {
// Set site-wide SSR head tags
locals.unhead.push({
htmlAttrs: { lang: 'en' },
titleTemplate: '%s | My Site',
})
}
Per-page SSR head tags work the same way:
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals, params }) => {
const post = await getPost(params.slug)
// Push page-specific head tags for SSR
locals.unhead.push({
title: post.title,
meta: [
{ name: 'description', content: post.excerpt },
{ property: 'og:image', content: post.coverImage },
],
})
return { post }
}
These tags are rendered into the HTML by transformHtmlTemplate() in hooks.server.ts before the response is sent.
5. Provide a client-side head in +layout.svelte
Create a client-side head instance in the root layout and provide it through Svelte context. Components can then call useHead() during client-side navigation:
<script lang="ts">
import { setContext } from 'svelte'
import { browser } from '$app/environment'
import { UnheadContextKey } from '@unhead/svelte'
import { createHead } from '@unhead/svelte/client'
if (browser) {
const unhead = createHead()
setContext(UnheadContextKey, unhead)
}
</script>
<slot />
locals.unhead.push() in load functions. The layout only needs to provide the client-side instance so that useHead() calls work after hydration for client-side navigation.6. Use useHead() in your components
Guard component composables with browser because the layout provides this head instance only on the client:
<script lang="ts">
import { useHead } from '@unhead/svelte'
import { browser } from '$app/environment'
if (browser) {
useHead({
title: 'My SvelteKit App',
meta: [
{ name: 'description', content: 'Built with SvelteKit and Unhead' }
]
})
}
</script>
<h1>Home</h1>
For reactive tags driven by page data during client-side navigation:
<script lang="ts">
import { useSeoMeta } from '@unhead/svelte'
import { browser } from '$app/environment'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
const entry = browser ? useSeoMeta() : undefined
$effect(() => {
entry?.patch({
title: data.post.title,
description: data.post.excerpt,
ogImage: data.post.coverImage,
})
})
</script>
+page.server.ts via locals.unhead.push(). The useHead() composable in components handles client-side navigation and reactive updates after hydration.7. Add default tags (optional)
Set site-wide defaults in hooks.server.ts using the init option:
import { createHead, transformHtmlTemplate } from '@unhead/svelte/server'
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
const unhead = createHead({
init: [
{
htmlAttrs: { lang: 'en' },
title: 'My SvelteKit App',
titleTemplate: '%s | My Site',
meta: [
{ name: 'description', content: 'Default site description' }
],
},
],
})
event.locals.unhead = unhead
const response = await resolve(event)
if (!response.headers.get('content-type')?.includes('text/html'))
return response
const html = await response.text()
const transformed = transformHtmlTemplate(unhead, html)
const headers = new Headers(response.headers)
headers.delete('content-length')
return new Response(transformed, {
headers,
status: response.status,
statusText: response.statusText,
})
}
How the pieces fit together
| File | Role |
|---|---|
hooks.server.ts | Creates a per-request head instance, buffers HTML responses, then renders tags via transformHtmlTemplate() |
+layout.server.ts / +page.server.ts | Push SSR head tags via locals.unhead.push() |
+layout.svelte | Provides a client-side head instance via Svelte context |
+page.svelte / any component | Calls useHead() / useSeoMeta() for client-side reactivity |
Next Steps
- Use
useSeoMeta()for a flat, type-safe SEO API - Add
useSchemaOrg()for structured data - Use
useScript()for third-party script loading, triggers, and lifecycle hooks - Read the Reactivity guide for reactive head tags