
How to Integrate Sanity With SvelteKit
To integrate Sanity with SvelteKit, install @sanity/client, create a client with your project ID, dataset and an apiVersion date, and call client.fetch() with a GROQ query inside a load function in a +page.server.ts file. SvelteKit passes the returned data to your +page.svelte component as the data prop, where you render it with Svelte 5 runes. For rich text, use @portabletext/svelte; for images, use @sanity/image-url. If you want an embedded Studio or visual editing, add the official @sanity/sveltekit package, which wraps the client and adds preview mode helpers.
SvelteKit and Sanity are a natural pair: SvelteKit's server load functions keep tokens and queries on the server, and Sanity's Content Lake returns exactly the JSON shape your GROQ projection asks for. This guide walks through a working setup from a blank SvelteKit app to dynamic post pages, then covers rendering Portable Text and images, prerendering and caching, embedding the Studio, visual editing and what changes in SvelteKit 3.
What You Need Before You Start
You need a Sanity project with at least one document type, such as a post with title, slug, publishedAt, mainImage and body fields. If you don't have one yet, follow how to set up your first Sanity project and create your first schema, then add a couple of posts in the Studio.
On the SvelteKit side, you need:
- Svelte 5:
@portabletext/sveltev4 and@sanity/sveltekitboth require Svelte 5, and the examples below use runes ($props(),$derived()). - Node.js 22.12 or later: the current
sanitypackage requires it, and recent SvelteKit releases have similar minimums. - A SvelteKit version Sanity's packages support: at the time of writing,
@sanity/sveltekitand@sanity/svelte-loaderdeclare SvelteKit 2 as a peer dependency. SvelteKit 3 was released in October 2026, so check the peer ranges withnpm view @sanity/sveltekit peerDependenciesbefore upgrading. The plain@sanity/clientapproach works on either version.
Create a new app with the Svelte CLI if you're starting fresh:
npx sv create my-sanity-site
cd my-sanity-site
npm install
Step 1: Install the Packages
For fetching and rendering content, three packages cover almost everything:
npm install @sanity/client @sanity/image-url @portabletext/svelte groq
Each one has a clear job:
| Package | What it does |
|---|---|
@sanity/client | Queries the Content Lake with GROQ and handles mutations |
@sanity/image-url | Builds cdn.sanity.io URLs with width, crop and format parameters |
@portabletext/svelte | Renders Portable Text arrays as Svelte components |
groq | Provides defineQuery for typed queries and editor syntax highlighting |
If you also want an embedded Studio or visual editing, install @sanity/sveltekit. It re-exports createClient, defineQuery and groq, so you can import everything from one place instead of installing groq separately.
Step 2: Configure Environment Variables
Store your project details in a .env file at the project root. SvelteKit exposes variables prefixed with PUBLIC_ to the browser and keeps the rest server-only:
# .env
PUBLIC_SANITY_PROJECT_ID="abc123xy"
PUBLIC_SANITY_DATASET="production"
SANITY_API_READ_TOKEN=""
The project ID and dataset aren't secret, since they appear in every image URL. The token is, so it must never get a PUBLIC_ prefix. You only need a token if your dataset is private or you want to read drafts; a public dataset can be queried without one. Securing your Sanity project explains token roles in more detail.
Step 3: Create the Sanity Client
Create a client module in src/lib/sanity/client.ts:
// src/lib/sanity/client.ts
import { createClient } from '@sanity/client';
import { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } from '$env/static/public';
export const client = createClient({
projectId: PUBLIC_SANITY_PROJECT_ID,
dataset: PUBLIC_SANITY_DATASET,
apiVersion: '2026-10-01',
useCdn: true,
});
A few notes on these options:
- apiVersion: always pin a date. Any date from
2025-02-19onwards makespublishedthe default perspective, so you get published documents without drafts mixed in. - useCdn: true: serves cached responses from Sanity's API CDN, which is faster and cheaper. Set it to
falsefor requests that must be fresh, such as previews. Sanity CDN vs Live API covers the trade-off.
If you need a token, create a second, server-only client so the token can't leak into browser code. Files ending in .server.ts can only be imported from server code, and SvelteKit will fail the build if a client component tries:
// src/lib/sanity/client.server.ts
import { SANITY_API_READ_TOKEN } from '$env/static/private';
import { client } from './client';
export const serverClient = client.withConfig({
token: SANITY_API_READ_TOKEN,
useCdn: false,
});
Step 4: Write Your GROQ Queries
Keep queries in one file so pages stay readable and you can reuse projections:
// src/lib/sanity/queries.ts
import { defineQuery } from 'groq';
export const POSTS_QUERY = defineQuery(`
*[_type == "post" && defined(slug.current)] | order(publishedAt desc)[0...20] {
_id,
title,
"slug": slug.current,
publishedAt,
mainImage
}
`);
export const POST_QUERY = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
publishedAt,
mainImage,
body,
"author": author->{ name }
}
`);
export const POST_SLUGS_QUERY = defineQuery(`
*[_type == "post" && defined(slug.current)].slug.current
`);
defineQuery returns the string unchanged but lets Sanity TypeGen infer result types, which Sanity TypeGen explains. Notice that POST_QUERY uses $slug instead of string interpolation. Using GROQ parameters safely explains why that matters.
Step 5: Fetch a List of Posts
Fetch data in a server load function so it runs on the server during SSR and prerendering:
// src/routes/blog/+page.server.ts
import { client } from '$lib/sanity/client';
import { POSTS_QUERY } from '$lib/sanity/queries';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const posts = await client.fetch(POSTS_QUERY);
return { posts };
};
Then render the list in the matching component:
<!-- src/routes/blog/+page.svelte -->
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<h1>Blog</h1>
<ul>
{#each data.posts as post (post._id)}
<li>
<a href="/blog/{post.slug}">{post.title}</a>
<time datetime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString('en-GB')}
</time>
</li>
{/each}
</ul>
Using +page.server.ts rather than +page.ts matters. A universal +page.ts load also runs in the browser during client-side navigation, which means any token it uses would be bundled for the browser, and the browser would need a CORS origin to call Sanity. Server loads avoid both problems.
Step 6: Build Dynamic Post Pages
Create a [slug] route. The params object gives you the slug from the URL, which you pass to the query as a parameter:
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import { client } from '$lib/sanity/client';
import { POST_QUERY } from '$lib/sanity/queries';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const post = await client.fetch(POST_QUERY, { slug: params.slug });
if (!post) {
error(404, 'Post not found');
}
return { post };
};
Calling error(404) stops the load function and renders your nearest +error.svelte page with the correct status code, so search engines don't index empty pages.
Step 7: Render Portable Text and Images
Sanity stores rich text as Portable Text, an array of blocks rather than an HTML string. If you're new to the format, start with what Portable Text is.
Create an Image URL Helper
// src/lib/sanity/image.ts
import { createImageUrlBuilder, type SanityImageSource } from '@sanity/image-url';
import { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } from '$env/static/public';
const builder = createImageUrlBuilder({
projectId: PUBLIC_SANITY_PROJECT_ID,
dataset: PUBLIC_SANITY_DATASET,
});
export function urlFor(source: SanityImageSource) {
return builder.image(source).auto('format');
}
The builder respects the hotspot and crop that editors set in the Studio. Using Sanity's image URL builder covers the full method chain.
Add a Custom Block Component
Standard blocks such as paragraphs, headings, lists and links render out of the box. Custom types, such as images inside the body, need a component. @portabletext/svelte passes the block to your component as a portableText prop:
<!-- src/lib/components/PortableImage.svelte -->
<script lang="ts">
import type { CustomBlockComponentProps } from '@portabletext/svelte';
import type { SanityImageSource } from '@sanity/image-url';
import { urlFor } from '$lib/sanity/image';
type ImageValue = SanityImageSource & { alt?: string };
interface Props {
portableText: CustomBlockComponentProps<ImageValue>;
}
let { portableText }: Props = $props();
let value = $derived(portableText.value);
</script>
<figure>
<img
src={urlFor(value).width(1200).url()}
alt={value.alt ?? ''}
loading="lazy"
/>
</figure>
Render the Post
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
import { PortableText } from '@portabletext/svelte';
import PortableImage from '$lib/components/PortableImage.svelte';
import { urlFor } from '$lib/sanity/image';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let post = $derived(data.post);
</script>
<svelte:head>
<title>{post.title}</title>
</svelte:head>
<article>
<h1>{post.title}</h1>
{#if post.author}<p>By {post.author.name}</p>{/if}
{#if post.mainImage}
<img src={urlFor(post.mainImage).width(1600).height(900).url()} alt="" />
{/if}
<PortableText
value={post.body ?? []}
components={{ types: { image: PortableImage } }}
/>
</article>
Using $derived keeps post reactive, so when SvelteKit navigates from one post to another and data changes, the page updates. The components object also accepts marks, block, list and listItem keys for overriding links, headings and lists, just like the React renderer in how to render Portable Text in React.
Prerendering and Caching
Most Sanity-powered pages change rarely, so you have three sensible rendering options:
| Option | How | Content updates appear |
|---|---|---|
| Prerender | export const prerender = true | After the next build |
| SSR with CDN caching | setHeaders with cache-control | When the cache expires |
| SSR without caching | Default behaviour | Immediately |
Prerender Every Post
To prerender dynamic routes, export prerender and an entries function that lists the slugs. SvelteKit also discovers pages by crawling links, but entries guarantees that posts not linked from anywhere still get built:
// src/routes/blog/[slug]/+page.server.ts
import { client } from '$lib/sanity/client';
import { POST_SLUGS_QUERY } from '$lib/sanity/queries';
import type { EntryGenerator } from './$types';
export const prerender = true;
export const entries: EntryGenerator = async () => {
const slugs = await client.fetch(POST_SLUGS_QUERY);
return slugs.map((slug) => ({ slug }));
};
// ...the load function from Step 6
With prerendering, you'll want a rebuild whenever editors publish. Point a Sanity webhook at your host's deploy hook, as described in triggering site rebuilds with Sanity webhooks.
Cache Server-Rendered Pages
If you'd rather render on demand, let your host's CDN cache the HTML for a short time:
export const load: PageServerLoad = async ({ params, setHeaders }) => {
const post = await client.fetch(POST_QUERY, { slug: params.slug });
if (!post) error(404, 'Post not found');
setHeaders({ 'cache-control': 'public, max-age=60, stale-while-revalidate=600' });
return { post };
};
This keeps pages fresh within a minute without rebuilding, and combined with useCdn: true it keeps your Sanity API usage low.
Embedding Sanity Studio in SvelteKit
You can run the Studio as a route inside your app instead of deploying it separately. @sanity/sveltekit provides a SanityStudio component for this. First create a Studio config with a basePath that matches the route:
// src/lib/sanity/sanity.config.ts
import { defineConfig } from 'sanity';
import { structureTool } from 'sanity/structure';
import { visionTool } from '@sanity/vision';
import { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } from '$env/static/public';
import { schemaTypes } from './schemaTypes';
export default defineConfig({
basePath: '/studio',
projectId: PUBLIC_SANITY_PROJECT_ID,
dataset: PUBLIC_SANITY_DATASET,
plugins: [structureTool(), visionTool()],
schema: { types: schemaTypes },
});
Then add a catch-all route that renders it:
<!-- src/routes/studio/[...catchall]/+page.svelte -->
<script lang="ts">
import config from '$lib/sanity/sanity.config';
import { SanityStudio } from '@sanity/sveltekit';
</script>
<SanityStudio {config} />
The Studio is a React app, so it has its own styles and layout. Put your public pages in a route group such as src/routes/(site)/ with their own +layout.svelte, so your site's header and global CSS don't wrap the Studio. You'll also need to add your app's origin as a CORS origin with credentials, since the Studio talks to Sanity from the browser:
npx sanity cors add http://localhost:5173 --credentials
If you'd rather keep the Studio separate, how to deploy Sanity Studio covers hosting it on a sanity.studio URL.
Adding Visual Editing
Visual editing lets editors open your SvelteKit site inside the Studio's Presentation tool and click any piece of text to edit it. @sanity/sveltekit handles the SvelteKit side with three pieces:
- handlePreviewMode: a server hook that validates requests from the Studio, sets a preview cookie and adds a
sanityobject toevent.locals. - PreviewMode and VisualEditing: components you wrap around your root layout's children, enabled only when preview mode is on.
- Stega: client configuration that invisibly encodes the source document and field into strings, so overlays know where each piece of text came from.
The hook looks like this:
// src/hooks.server.ts
import { handlePreviewMode } from '@sanity/sveltekit';
import { redirect } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { serverClient } from '$lib/sanity/client.server';
export const handle = sequence(
handlePreviewMode({
client: serverClient,
preview: { redirect },
})
);
In your load functions you then use locals.sanity.client and pass { stega: previewEnabled } to fetch, so encoded strings only appear for editors. In the Studio, add presentationTool with your site's URL as previewUrl.initial and /preview/enable as the preview mode endpoint. The official "Visual Editing with SvelteKit" guide on sanity.io has the full layout code and optional loaders for instant updates. The concepts are the same as in setting up Sanity visual editing in Next.js, if you want a deeper explanation of how stega and the Presentation tool fit together.
Notes for SvelteKit 3
SvelteKit 3 removes some legacy features. If you upgrade, the Sanity code above stays the same, but a few framework conventions change:
| SvelteKit 2 | SvelteKit 3 |
|---|---|
$lib alias generated automatically | #lib alias declared in package.json imports |
$env/static/public, $env/static/private | Deprecated in favour of $app/env/public and $app/env/private with defineEnvVars |
Config in svelte.config.js | Config passed to the sveltekit() Vite plugin |
invalidateAll() | refreshAll() (the old name is deprecated) |
The npx sv migrate sveltekit-3 command automates many of these changes. The bigger question is whether @sanity/sveltekit and @sanity/svelte-loader list SvelteKit 3 in their peer dependencies yet. If they don't, you can still use @sanity/client, @sanity/image-url and @portabletext/svelte on SvelteKit 3, since none of them depend on SvelteKit, and add the Sanity SvelteKit toolkit once it supports your version.
Common Problems and Fixes
- Empty results: check the dataset name and that documents are published. With
apiVersionset to2025-02-19or later, drafts aren't returned without a token and adraftsperspective. - CORS errors in the browser console: a request is running in the browser. Move the fetch to
+page.server.ts, or add your origin withnpx sanity cors add. - Stale content after publishing:
useCdn: truecaches responses briefly, and prerendered pages only change on rebuild. Check which layer is serving old data before changing settings. - Token exposed warning: you've imported a private variable into code that reaches the browser. Keep token-based clients in
.server.tsfiles. - Images ignore the crop: you're using
asset->urldirectly. Pass the whole image object tourlFor()so the builder can apply hotspot and crop.
FAQ: Sanity and SvelteKit
No. For fetching and rendering content, @sanity/client, @sanity/image-url and @portabletext/svelte are enough. The @sanity/sveltekit package adds convenience exports, an embedded Studio component and preview mode helpers for visual editing, so install it when you need those features.
Use +page.server.ts in most cases. Server load functions keep tokens private, avoid CORS configuration and run during prerendering. A universal +page.ts load is only worth it for public data you deliberately want to fetch from the browser during client-side navigation.
Yes. Export prerender = true from your routes and an entries function that returns every slug. SvelteKit fetches the content at build time and outputs static HTML. Set up a webhook to trigger a rebuild when editors publish, or content changes won't appear until the next deploy.
Install @portabletext/svelte and pass the body array to its PortableText component. Standard blocks, marks and lists render automatically. For custom types such as images or code blocks, pass your own Svelte components through the components prop, keyed by type name.
The core libraries work with any JavaScript framework, so fetching content with @sanity/client works on SvelteKit 3. At the time of writing, the @sanity/sveltekit and @sanity/svelte-loader packages declare SvelteKit 2 as their peer dependency, so check their current peer ranges before relying on visual editing or the embedded Studio in a SvelteKit 3 app.
Only for requests made from the browser. Server load functions call Sanity from your server, which doesn't need a CORS origin. You do need one, with credentials allowed, if you embed the Studio in your app or use visual editing, because those run in the browser.
Conclusion
Integrating Sanity with SvelteKit comes down to a small client module, a few GROQ queries and server load functions that return their results to Svelte components. From there, @portabletext/svelte and @sanity/image-url handle rich text and responsive images, and you choose between prerendering with webhook rebuilds or server rendering with short CDN caching depending on how fresh content needs to be.
Once the basics work, @sanity/sveltekit lets you embed the Studio at /studio and add click-to-edit previews for your editors. Keep an eye on its supported SvelteKit versions if you're moving to SvelteKit 3, and keep tokens in server-only modules so the integration stays secure as your site grows.


