All skills
sanity-io avatar

/sanity-best-practices

@fc8116b official
by Sanitysanity-io/agent-toolkit187 stars
30

Sanity development best practices for schema design, GROQ queries, TypeGen, Visual Editing, images, Portable Text, Studio structure, localization, migrations, Sanity Functions, webhooks, Blueprints, and framework integrations such as Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen, and the App SDK. Use this skill whenever working with Sanity schemas, defineType or defineField, GROQ or defineQuery, content modeling, Presentation or preview setups, Sanity-powered frontend integrations, event-driven content automation, documentEventHandler, defineDocumentFunction, defineMediaLibraryAssetFunction, @sanity/functions, @sanity/blueprints, sanity.blueprint.ts, event-driven content automation, or when reviewing and fixing a Sanity codebase.

Use this Skill: https://skilld.dev/gh/sanity-io/agent-toolkit/sanity-best-practices

This session only. Nothing lands on disk.

referencessvelte.md

≈2.6k tokens on demand. Your agent reads this file only when SKILL.md points to it.

SvelteKit & Sanity Integration Rules

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.

1. Setup & Configuration

Scaffold a new SvelteKit app

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).

Installation

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.

Environment variables (.env.local)

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.

2. Files

src/lib/sanity/api.ts — env var resolution

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'

src/lib/sanity/client.ts — public client

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.

src/lib/sanity/client.server.ts — server (preview) client

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,
})

src/lib/sanity/queries.ts — queries + types

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.

src/lib/sanity/image.ts — image URL builder

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.

3. Hooks & Locals

src/hooks.server.ts — wire preview + query loader

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.

src/app.d.ts — typed locals

import type {SanityLocals} from '@sanity/sveltekit'

declare global {
  namespace App {
    interface Locals extends SanityLocals {}
  }
}

export {}

4. Layout: Preview + Visual Editing Providers

src/routes/+layout.server.ts — propagate previewEnabled

import type {LayoutServerLoad} from './$types'

export const load: LayoutServerLoad = (event) => {
  const {previewEnabled} = event.locals.sanity
  return {previewEnabled}
}

src/routes/+layout.svelte — wrap children in providers (Svelte 5)

<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 />.
  • The 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.

5. Data Fetching (Loaders + useQuery)

Posts list

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.
  • Subscribe via $query (Svelte's auto-subscription) and read .data.
  • Works on both SSR and client.

Post detail ([slug])

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}

6. Stega Cleaning

When using fetched strings for logic (routing, classNames), strip the stega markers first.

import {stegaClean} from '@sanity/sveltekit'
// …
if (stegaClean(slug) === 'home') { /* … */ }

7. Caveats

  • Yarn classic + Visual Editing. @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.
  • No <slot />. Svelte 5 layouts use {@render children()}.
  • No 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.

Source: SKILL.md on GitHub

2 warnings14d5 checks · Risk SAFE
  • Gen Agent Trust Hub14d

    This skill provides comprehensive Sanity.io development best practices, covering schema design, GROQ queries, and integration with major frontend frameworks. It promotes secure development habits, such as proper management of API tokens and environment variables.

  • Socket14d

    1 alert: gptAnomaly

  • Snyk14d

    Risk: LOW · No issues

  • Runlayer6mo

    8/24 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at fc8116b. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 months ago
  • Next.js
  • Nuxt
  • sanity
  • groq
  • schema
  • typegen
  • portable-text
  • visual-editing
  • astro
  • remix
  • sveltekit
  • migrations
  • localization
  • cms

README badge

README badge for sanity-io/agent-toolkit/sanity-best-practices

Provides guidelines and reference materials for Sanity schema design, GROQ queries, TypeGen, Visual Editing, Portable Text, Studio structure, localization, migrations, Sanity Functions, and framework integrations including Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, and Hydrogen. Use this skill when setting up Sanity projects, designing content models, writing queries, implementing live preview, or integrating Sanity with a frontend framework.

Generated from the current SKILL.md.

Does this skill cover framework integrations like Next.js, Nuxt, and Astro?
Yes. The skill includes integration guides for Next.js, Nuxt, Astro, Remix, SvelteKit, Angular, Hydrogen, and standalone Studio patterns.
What does this skill cover for GROQ queries?
The skill provides GROQ query patterns, type safety approaches, and performance optimization guidelines, with reference materials for detailed examples.
Does this skill include guidance on Sanity Functions and event automation?
Yes. The skill covers Sanity Functions for automating content workflows, including documentEventHandler and defineDocumentFunction patterns.
Can I use this skill for schema design and content modeling?
Yes. The skill provides schema design best practices, field definitions, validation patterns, and content modeling guidance for different use cases.
Does this cover Visual Editing and live preview setup?
Yes. The skill includes guidance on the Presentation Tool, Stega, overlays, and live preview configuration for frontend frameworks.

Generated from the current SKILL.md. These answers refresh after source changes.