Skill 05 · Sanity Best Practices
Subchapter 5.22
references/svelte.mdMarkdown10 KBView on GitHub
This guide uses the official @sanity/sveltekit package (Svelte 5 + SvelteKit 2). The older @sanity/svelte-loader does not work with Svelte 5 — its useQuery store returns empty on the client. Use @sanity/sveltekit instead.
npx sv@latest create my-app --template minimal --types ts --no-add-ons --install npm
cd my-app--template minimal is the bare app. --types ts enables TypeScript. --no-add-ons skips the add-on picker. --install <pm> chooses the package manager (npm, pnpm, yarn, or bun).
npm install @sanity/sveltekit @sanity/image-url @portabletext/svelte@sanity/sveltekit is the one-stop integration: it bundles @sanity/client, @sanity/visual-editing, @sanity/core-loader, groq, and friends, and re-exports createClient, defineQuery, groq, and stegaClean. Do not also install @sanity/client, @sanity/visual-editing, or groq directly — import them from @sanity/sveltekit. @sanity/image-url and @portabletext/svelte are not bundled, so add them separately.
PUBLIC_SANITY_PROJECT_ID=your-project-id
PUBLIC_SANITY_DATASET=production
PUBLIC_SANITY_API_VERSION=2026-05-15
PUBLIC_SANITY_STUDIO_URL=http://localhost:3333
SANITY_API_READ_TOKEN=SvelteKit’s $env/static/public requires the PUBLIC_ prefix for any var read on the client. SANITY_API_READ_TOKEN must be declared (even empty) if any file imports it from $env/static/private, otherwise Vite throws at build time.
import {
PUBLIC_SANITY_DATASET,
PUBLIC_SANITY_PROJECT_ID,
PUBLIC_SANITY_API_VERSION,
PUBLIC_SANITY_STUDIO_URL,
} from '$env/static/public'
function assertEnvVar<T>(value: T | undefined, name: string): T {
if (value === undefined || value === '') {
throw new Error(`Missing environment variable: ${name}`)
}
return value
}
export const dataset = assertEnvVar(PUBLIC_SANITY_DATASET, 'PUBLIC_SANITY_DATASET')
export const projectId = assertEnvVar(PUBLIC_SANITY_PROJECT_ID, 'PUBLIC_SANITY_PROJECT_ID')
export const apiVersion = PUBLIC_SANITY_API_VERSION || '2026-05-15'
export const studioUrl = PUBLIC_SANITY_STUDIO_URL || 'http://localhost:3333'import {createClient} from '@sanity/sveltekit'
import {apiVersion, projectId, dataset, studioUrl} from '$lib/sanity/api'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
stega: {studioUrl},
})Import createClient from @sanity/sveltekit, not @sanity/client. useCdn: true is for production reads; the server (preview) client below overrides to false.
import {SANITY_API_READ_TOKEN} from '$env/static/private'
import {client} from '$lib/sanity/client'
export const serverClient = client.withConfig({
token: SANITY_API_READ_TOKEN,
useCdn: false,
stega: true,
})import {groq} from '@sanity/sveltekit'
export const postsQuery = groq`*[_type == "post" && defined(slug.current)] | order(_createdAt desc){
_id, _createdAt, title, slug, excerpt, mainImage, body
}`
export const postQuery = groq`*[_type == "post" && slug.current == $slug][0]{
_id, _createdAt, title, slug, excerpt, mainImage, body
}`
export interface Post {
_id: string
_createdAt: string
title?: string
slug: {current: string}
excerpt?: string
mainImage?: unknown
body?: unknown[]
}Use defineQuery instead of groq if you want TypeGen-friendly query definitions; both are re-exported from @sanity/sveltekit.
import {createImageUrlBuilder} from '@sanity/image-url'
import {client} from './client'
const builder = createImageUrlBuilder(client)
export function urlFor(source: unknown) {
return builder.image(source as never)
}Use the named createImageUrlBuilder export; the default export logs a deprecation warning at runtime.
import {handlePreviewMode, handleQueryLoader, setServerClient} from '@sanity/sveltekit'
import {redirect} from '@sveltejs/kit'
import {sequence} from '@sveltejs/kit/hooks'
import {serverClient} from '$lib/sanity/client.server'
setServerClient(serverClient)
export const handle = sequence(
handlePreviewMode({
client: serverClient,
preview: {redirect},
}),
handleQueryLoader(),
)handlePreviewMode installs /preview/enable and /preview/disable endpoints, reads the preview cookie, and populates locals.sanity with {client, fetch, loadQuery, previewEnabled, previewPerspective, browserToken}. handleQueryLoader attaches loadQuery to locals.sanity for use in +page.server.ts / +layout.server.ts.
import type {SanityLocals} from '@sanity/sveltekit'
declare global {
namespace App {
interface Locals extends SanityLocals {}
}
}
export {}import type {LayoutServerLoad} from './$types'
export const load: LayoutServerLoad = (event) => {
const {previewEnabled} = event.locals.sanity
return {previewEnabled}
}<script lang="ts">
import {PreviewMode, QueryLoader, VisualEditing} from '@sanity/sveltekit'
import type {LayoutProps} from './$types'
import {client} from '$lib/sanity/client'
const {children, data}: LayoutProps = $props()
// svelte-ignore state_referenced_locally
const {previewEnabled} = data
</script>
<PreviewMode enabled={previewEnabled}>
<VisualEditing enabled={previewEnabled}>
<QueryLoader enabled={previewEnabled} {client}>
{@render children()}
</QueryLoader>
</VisualEditing>
</PreviewMode>Svelte 5 idioms here are mandatory:
const {children, data} = $props() — not export let data.{@render children()} — not <slot />.svelte-ignore state_referenced_locally comment silences a warning about destructuring reactive props at module scope.<VisualEditing> dynamically imports its component only when enabled === true, so a preview-off app never loads the React-Compiler-runtime chunk.
src/routes/+page.server.ts:
import {postsQuery as query, type Post} from '$lib/sanity/queries'
import type {PageServerLoad} from './$types'
export const load: PageServerLoad = async ({locals}) => {
const {loadQuery} = locals.sanity
const initial = await loadQuery<Post[]>(query)
return {query, options: {initial}}
}The return shape {query, params?, options: {initial}} is what useQuery(data) on the client expects — don’t change the field names.
src/routes/+page.svelte:
<script lang="ts">
import {useQuery} from '@sanity/sveltekit'
import type {Post} from '$lib/sanity/queries'
import type {PageProps} from './$types'
const {data}: PageProps = $props()
const query = $derived(useQuery<Post[]>(data))
const posts = $derived($query.data)
</script>
<h1>Posts</h1>
{#if posts?.length}
<ul>
{#each posts as post (post._id)}
<li><a href={`/post/${post.slug.current}`}>{post.title}</a></li>
{/each}
</ul>
{:else}
<p>No posts yet.</p>
{/if}Critical Svelte 5 pattern:
useQuery returns a Svelte Readable store. Wrap in $derived(useQuery(data)) so the store reference stays current across reactive updates.$query (Svelte’s auto-subscription) and read .data.src/routes/post/[slug]/+page.server.ts:
import {postQuery as query, type Post} from '$lib/sanity/queries'
import type {PageServerLoad} from './$types'
export const load: PageServerLoad = async ({locals, params}) => {
const {loadQuery} = locals.sanity
const {slug} = params
const initial = await loadQuery<Post>(query, {slug})
return {query, params: {slug}, options: {initial}}
}src/routes/post/[slug]/+page.svelte:
<script lang="ts">
import {useQuery} from '@sanity/sveltekit'
import {PortableText} from '@portabletext/svelte'
import {urlFor} from '$lib/sanity/image'
import type {Post} from '$lib/sanity/queries'
import type {PageProps} from './$types'
const {data}: PageProps = $props()
const query = $derived(useQuery<Post>(data))
const post = $derived($query.data)
</script>
{#if post}
<article>
<h1>{post.title}</h1>
{#if post.mainImage}
<img src={urlFor(post.mainImage).width(800).url()} alt={post.title ?? ''} />
{/if}
{#if post.body}
<PortableText value={post.body} />
{/if}
</article>
{:else}
<p>Post not found.</p>
{/if}When using fetched strings for logic (routing, classNames), strip the stega markers first.
import {stegaClean} from '@sanity/sveltekit'
// …
if (stegaClean(slug) === 'home') { /* … */ }@sanity/visual-editing lazy-loads a chunk that imports react/compiler-runtime. Yarn classic doesn’t auto-install peer deps, so users who flip preview mode on with yarn classic also need yarn add react react-dom. (Other package managers handle this automatically.) <VisualEditing> only loads this chunk when enabled === true, so a default preview-off app is unaffected.<slot />. Svelte 5 layouts use {@render children()}.export let. Pages and components use const {data} = $props().@sanity/image-url default export. Use the named createImageUrlBuilder; the default export still works but logs a runtime deprecation warning.