Merge pull request #47 from sanity-io/fix/get-started-frontend-integrations

fix(skill): get-started frontend integrations — verified, modernized, and fixed
This commit is contained in:
Jon Eide Johnsen
2026-06-02 14:24:04 -07:00
committed by GitHub
5 changed files with 697 additions and 168 deletions
@@ -7,25 +7,55 @@ description: Integration guide for Astro, including @sanity/astro, visual editin
## 1. Setup & Configuration
### Scaffold a new Astro app
```bash
npm create astro@latest my-app -- --template with-tailwindcss --install --git --yes
cd my-app
```
`--yes` accepts defaults non-interactively. `--install` runs `npm install` for you, `--git` initializes a repo.
### Installation
Add the `@sanity/astro` integration and the renderer/helper packages used by the examples below.
```bash
npx astro add @sanity/astro
npm install astro-portabletext @sanity/image-url groq
```
`@sanity/astro` provides the `sanity:client` virtual module. `astro-portabletext` renders Portable Text. `@sanity/image-url` builds image URLs. `groq` exports `defineQuery` for typed queries.
### Configuration (`astro.config.mjs`)
Use the official `@sanity/astro` integration.
Use the official `@sanity/astro` integration. `astro.config.mjs` runs at config time before Astro's env loading, so `import.meta.env.PUBLIC_*` is not available there — use Vite's `loadEnv` to read the same `PUBLIC_` variables your pages will use.
```javascript
import { defineConfig } from "astro/config";
import { loadEnv } from "vite";
import sanity from "@sanity/astro";
const { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET } = loadEnv(
process.env.NODE_ENV ?? "development",
process.cwd(),
""
);
export default defineConfig({
integrations: [
sanity({
projectId: "YOUR_PROJECT_ID",
dataset: "production",
projectId: PUBLIC_SANITY_PROJECT_ID,
dataset: PUBLIC_SANITY_DATASET,
useCdn: false, // False for static builds
studioBasePath: "/admin", // If embedding Studio
studioBasePath: "/admin", // Optional — only if embedding the Studio
}),
],
});
```
Inside `.astro` files and components you can keep using `import.meta.env.PUBLIC_SANITY_*` directly; the `loadEnv` shim above is config-only.
### Client Type Safety
Enable types in `tsconfig.json`.
@@ -69,6 +99,36 @@ export async function getPosts() {
}
```
### Dynamic Routes (`[slug].astro`)
Astro hoists `getStaticPaths()` into a separate module context. Module-scope `const` declarations in the frontmatter are NOT accessible inside it — referencing them throws `ReferenceError: <NAME> is not defined` at request time. Define queries used by `getStaticPaths` inside the function, or import them from a utility module.
```astro
---
import { sanityClient } from "sanity:client";
import { defineQuery } from "groq";
import { PortableText } from "astro-portabletext";
// Module-scope queries are fine for module-scope code…
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{ title, body }`);
// …but anything used inside getStaticPaths must live inside it.
export async function getStaticPaths() {
const SLUGS_QUERY = defineQuery(
`*[_type == "post" && defined(slug.current)]{ "params": { "slug": slug.current } }`
);
return await sanityClient.fetch(SLUGS_QUERY);
}
const { slug } = Astro.params;
const post = await sanityClient.fetch(POST_QUERY, { slug });
---
<article>
<h1>{post?.title}</h1>
{post?.body && <PortableText value={post.body} />}
</article>
```
## 3. Portable Text
Use `astro-portabletext` for rendering rich text.
@@ -181,6 +181,21 @@ claude mcp add Sanity -t http https://mcp.sanity.io --scope user
## Phase 3: Frontend Integration
### Client Bundle Warning (Vite-based frameworks)
React Router, SvelteKit, Astro, and Nuxt all run on Vite. **Any module imported by a client component will be bundled to the browser.** `process.env` doesn't exist there.
For publishable values (`projectId`, `dataset`, `apiVersion`, public studio URL), use the framework's client-safe env mechanism:
- React Router / Remix: `import.meta.env.VITE_*`
- SvelteKit: `$env/static/public`
- Astro: `import.meta.env.PUBLIC_*`
- Nuxt: `useRuntimeConfig().public`
For secrets (read tokens, webhook secrets), read `process.env.*` (or the server equivalent) **only from server-only modules** — `.server.ts`, route handlers, API endpoints. Don't centralize them in a shared `env.ts` that anything else imports.
This trap is invisible at SSR — the page renders fine on first load. It surfaces on client-side route transitions, when a lazy-loaded route chunk pulls a shared client/image module into the browser.
### Step 1: Detect Framework
**Check `package.json` dependencies:**
@@ -201,40 +216,60 @@ claude mcp add Sanity -t http https://mcp.sanity.io --scope user
If Next.js is detected, follow these essential steps:
**Scaffold a new app (if you don't have one yet):**
```bash
npx create-next-app@latest my-app --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd my-app
```
**Install dependencies:**
```bash
npm install @sanity/client @sanity/image-url @portabletext/react
npm install next-sanity @sanity/image-url
```
`next-sanity` is the official Sanity toolkit for Next.js. It bundles `@sanity/client`, `groq` (with `defineQuery`), and `@portabletext/react`, plus dedicated subpath exports for Next.js-specific features:
- `next-sanity` — `createClient`, `defineQuery`, `PortableText`, `SanityDocument`, `stegaClean`
- `next-sanity/live` — `defineLive` for live content with Next.js cache integration
- `next-sanity/draft-mode` — Draft Mode endpoint helpers
- `next-sanity/visual-editing` — `<VisualEditing />` component for click-to-edit overlays
- `next-sanity/image` — Sanity-aware `<Image />` wrapping `next/image`
- `next-sanity/studio` — embed the Sanity Studio at a route
- `next-sanity/webhook` — webhook signature verification
Don't also install `@sanity/client`, `@portabletext/react`, or `groq` directly — import them from `next-sanity`. `@sanity/image-url` is not bundled (yet), so add it separately.
**Create the client (`src/sanity/client.ts`):**
```typescript
import { createClient } from "@sanity/client";
import { createClient } from "next-sanity";
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: "2026-02-01", // Use current date for new projects
apiVersion: "2026-05-15", // Use current date for new projects
useCdn: false, // Use API directly for server-side rendering; set true for client-side reads
});
```
**Fetch content in a Server Component:**
```typescript
// app/posts/page.tsx
// src/app/page.tsx
import { client } from "@/sanity/client";
import { defineQuery, type SanityDocument } from "next-sanity";
import { defineQuery } from "groq";
const POSTS_QUERY = defineQuery(
`*[_type == "post" && defined(slug.current)] | order(_createdAt desc){ _id, title, slug }`
);
const POSTS_QUERY = defineQuery(`*[_type == "post"]{ _id, title, slug }`);
const options = { next: { revalidate: 30 } };
export default async function PostsPage() {
const posts = await client.fetch(POSTS_QUERY);
const posts = await client.fetch<SanityDocument[]>(POSTS_QUERY, {}, options);
return (
<ul>
{posts.map((post) => (
<li key={post._id}>
<a href={`/posts/${post.slug.current}`}>{post.title}</a>
<a href={`/${(post.slug as { current?: string })?.current}`}>{post.title as string}</a>
</li>
))}
</ul>
@@ -242,13 +277,46 @@ export default async function PostsPage() {
}
```
`{ next: { revalidate: 30 } }` opts the fetch into Next.js' ISR cache with a 30-second revalidation window. Tune to taste; omit `options` to use defaults.
**Render an individual post (`src/app/[slug]/page.tsx`):**
```typescript
import { PortableText, defineQuery, type SanityDocument } from "next-sanity";
import { notFound } from "next/navigation";
import { client } from "@/sanity/client";
const POST_QUERY = defineQuery(
`*[_type == "post" && slug.current == $slug][0]{ _id, title, body }`
);
const options = { next: { revalidate: 30 } };
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await client.fetch<SanityDocument | null>(POST_QUERY, { slug }, options);
if (!post) return notFound();
return (
<article>
<h1>{post.title as string}</h1>
{Array.isArray(post.body) && <PortableText value={post.body} />}
</article>
);
}
```
**Add environment variables (`.env.local`):**
```
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
```
For advanced patterns (TypeGen, Visual Editing, `defineLive`), see `nextjs.md`.
For advanced patterns (TypeGen, Visual Editing with `next-sanity/visual-editing`, live content with `defineLive` from `next-sanity/live`, embedded Studio via `next-sanity/studio`), see `nextjs.md`.
### Step 3: Other Frameworks
@@ -261,6 +329,18 @@ For non-Next.js frameworks, read the corresponding rule file and follow its inte
Each rule file contains framework-specific patterns for data fetching, Portable Text rendering, and Visual Editing.
### Step 4: Smoke Test
Before declaring integration done, exercise both render paths:
1. `npm run dev`
2. Load the home page (lists posts).
3. **Click through to a detail page** via an in-app `<Link>` / `<a>` — do not paste the URL.
4. Open the browser console. It should be clean. No `ReferenceError: process is not defined`, no hard reload to `/`.
5. For good measure, reload the detail page directly (URL bar) — that exercises SSR.
Server-side rendering passing isn't enough. Client-side route transitions pull lazy chunks that exercise different code paths, and that's where env/bundling traps surface.
---
## What's Next
@@ -286,11 +366,13 @@ Just ask about any of these!"
| Framework | Client-Side Prefix | Example |
|-----------|-------------------|---------|
| Next.js | `NEXT_PUBLIC_` | `NEXT_PUBLIC_SANITY_PROJECT_ID` |
| React Router / Remix | None (use loader) | `SANITY_PROJECT_ID` |
| React Router / Remix | `VITE_` | `VITE_SANITY_PROJECT_ID` |
| SvelteKit | `PUBLIC_` | `PUBLIC_SANITY_PROJECT_ID` |
| Nuxt | `NUXT_PUBLIC_` | `NUXT_PUBLIC_SANITY_PROJECT_ID` |
| Astro | `PUBLIC_` | `PUBLIC_SANITY_PROJECT_ID` |
**Secrets** (read tokens, webhook secrets) stay **unprefixed** and are read via `process.env` (or the framework's server-only equivalent) from server-only modules — `*.server.ts`, route handlers, API routes. Never re-export a secret from a module that a route component can import.
---
## Common Commands
+101 -21
View File
@@ -7,18 +7,56 @@ description: Integration guide for Nuxt, including @nuxtjs/sanity, visual editin
## 1. Setup & Configuration
### Configuration (`nuxt.config.ts`)
Use the official `@nuxtjs/sanity` module.
### Scaffold a new Nuxt app
**Important:** Ensure the `minimal` client is NOT enabled if you want full features.
```bash
npm create nuxt@latest my-app -- -t ui -M "" --packageManager npm --no-gitInit
cd my-app
```
`-t ui` selects the Nuxt UI starter. `-M ""` skips the interactive module-selection prompt (empty string = no extra modules). `--packageManager npm` and `--no-gitInit` suppress the other two prompts so the scaffold runs end-to-end without input.
### Installation
```bash
npx nuxi@latest module add sanity
```
`nuxi module add sanity` resolves to the official `@nuxtjs/sanity` module and registers it in `nuxt.config.ts` automatically. The module bundles `@sanity/client`, `@sanity/visual-editing`, `@portabletext/vue`, and `groq` as direct dependencies — no separate installs needed.
`groq` and `defineQuery` are also **auto-imported** by the module, so you can use them in `.vue` files without an `import` statement.
For manual image-URL building (an alternative to the auto-registered `<SanityImage>` component), add `@sanity/image-url`:
```bash
npm install @sanity/image-url
```
### What the module auto-imports
**Composables** (use directly in `<script setup>`, no imports needed):
- `useSanity()` — get the client and its config
- `useSanityQuery()` / `useLazySanityQuery()` — reactive query helpers
- `useSanityConfig()` — read the resolved module config
- `useSanityPerspective()`, `useSanityPreviewPerspective()`, `useSanityPreviewEnvironment()` — perspective helpers for drafts/preview
- `useSanityVisualEditingState()`, `useIsSanityLivePreview()`, `useIsSanityPresentationTool()` — visual-editing state helpers
**GROQ helpers** (template tags): `groq`, `defineQuery`
**Components** (use directly in `<template>`):
- `<SanityContent>` — Portable Text renderer (uses `@portabletext/vue` internally; prop is `:value`)
- `<SanityImage>` — image renderer; takes an `assetId` (the image asset's `_ref`); upgrades to `<NuxtImg>` automatically when `@nuxt/image` is installed
- `<SanityFile>` — file renderer
### Configuration (`nuxt.config.ts`)
```typescript
export default defineNuxtConfig({
modules: ["@nuxtjs/sanity"],
modules: ['@nuxtjs/sanity'],
sanity: {
projectId: process.env.NUXT_SANITY_PROJECT_ID,
dataset: process.env.NUXT_SANITY_DATASET,
apiVersion: "2026-02-01",
apiVersion: '2026-05-15',
// Live Visual Editing Configuration
visualEditing: {
studioUrl: process.env.NUXT_SANITY_STUDIO_URL,
@@ -30,55 +68,97 @@ export default defineNuxtConfig({
});
```
**Important:** Don't enable the `minimal` client if you want the full feature set (composables, components, visual editing).
## 2. Data Fetching
### `useSanityQuery`
Use the composable provided by the module for reactive fetching. It automatically handles preview state when configured.
Use the composable for reactive fetching. It handles preview state automatically when `visualEditing` is configured. `groq` and `defineQuery` are auto-imported — use either.
```vue
<!-- app/pages/posts.vue -->
<script setup lang="ts">
const query = groq`*[_type == "post"]{title, slug}`;
const { data: posts } = await useSanityQuery(query);
const query = groq`*[_type == "post" && defined(slug.current)]{ _id, title, slug }`
const { data: posts } = await useSanityQuery<Array<{ _id: string; title?: string; slug?: { current?: string } }>>(query)
</script>
<template>
<ul>
<li v-for="post in posts" :key="post._id">{{ post.title }}</li>
<li v-for="post in posts || []" :key="post._id">
<NuxtLink :to="`/${post.slug?.current}`">{{ post.title }}</NuxtLink>
</li>
</ul>
</template>
```
### Dynamic Routes (`[slug].vue`)
Pull the slug off `useRoute()` and pass it as a query parameter. The `<SanityContent>` component renders Portable Text — note the prop is `value`, not `blocks` (renamed in v2).
```vue
<!-- app/pages/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const query = groq`*[_type == "post" && slug.current == $slug][0]{ _id, title, body }`
const { data: post } = await useSanityQuery<{ _id: string; title?: string; body?: unknown[] }>(
query,
{ slug: route.params.slug }
)
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<SanityContent v-if="post.body" :value="post.body" />
</article>
</template>
```
## 3. Visual Editing (Live Preview)
### Automatic Setup
When `visualEditing` is configured in `nuxt.config.ts`, the module handles:
1. Injecting the Visual Editing overlays.
2. Refreshing data when content changes in the Studio.
3. Enabling Stega encoding.
1. Injecting the Visual Editing overlays.
2. Refreshing data when content changes in the Studio.
3. Enabling Stega encoding.
### Handling Stega in Logic
Just like Next.js, if you use stega-encoded strings in logic (e.g. `v-if="post.layout === 'full'"`), you must clean them.
If you use stega-encoded strings in logic (e.g. `v-if="post.layout === 'full'"`), you must clean them. `stegaClean` is exported from `@sanity/client/stega` (a transitive of `@nuxtjs/sanity`, so no separate install).
```typescript
import { stegaClean } from "@sanity/client/stega";
import { stegaClean } from '@sanity/client/stega'
const layout = computed(() => stegaClean(props.layout));
const layout = computed(() => stegaClean(props.layout))
```
## 4. Components
### Portable Text
Use the `<PortableText>` component (if installed via `@portabletext/vue` or provided by the module).
### Portable Text — `<SanityContent>`
The module auto-registers `<SanityContent>`. Don't install `@portabletext/vue` separately; it's a direct dep of the module.
```vue
<PortableText :value="post.body" :components="customComponents" />
<SanityContent :value="post.body" />
```
### Images
Use `@sanity/image-url` helper or a dedicated image component.
For custom blocks/marks, pass `:components`:
```vue
<SanityContent :value="post.body" :components="{ block: { h2: MyH2 } }" />
```
### Images — two options
**Option A — `<SanityImage>` (recommended).** Auto-registered. Takes the asset's `_ref` (the `assetId`) and builds the URL via the module's resolved projectId/dataset. If `@nuxt/image` is installed, it transparently upgrades to `<NuxtImg>` for responsive sizing.
```vue
<SanityImage :asset-id="post.mainImage.asset._ref" width="800" />
```
**Option B — `@sanity/image-url` builder.** Install `@sanity/image-url` separately and build URLs manually. Useful when you need fine-grained control (hotspot/crop, format negotiation, srcset).
```typescript
import imageUrlBuilder from '@sanity/image-url'
const builder = imageUrlBuilder(useSanity().client)
// ... url generation logic
// builder.image(source).width(800).url()
```
+175 -38
View File
@@ -1,31 +1,65 @@
---
title: React Router (Remix) & Sanity Integration Rules
description: Integration guide for React Router (formerly Remix) with Sanity, including Loaders and Visual Editing.
description: Integration guide for React Router v7 (and Remix v2) with Sanity, including loaders and visual editing.
---
# React Router (Remix) & Sanity Integration Rules
## Version Note
This guide covers both:
- **Remix v2** (`@remix-run/*` packages)
- **React Router v7** (the successor to Remix, `react-router` package)
The primary examples below use **React Router v7** (the current shape — Remix v2 was renamed to React Router v7 starting with the v7 release). Import paths and the route-types file (`./+types/<route>`) come from the `react-router` package and the framework's typegen.
The Sanity integration pattern is the same for both. Import paths differ slightly:
If you are on the older **Remix v2** stack, the integration shape is identical; only the import paths differ:
| Remix v2 | React Router v7 |
|----------|-----------------|
| `@remix-run/node` | `react-router` |
| `@remix-run/react` | `react-router` |
| `remix.config.js` | `react-router.config.ts` |
The examples below use Remix v2 imports. Adjust if using React Router v7.
| React Router v7 | Remix v2 |
|-----------------|----------|
| `react-router` | `@remix-run/node` / `@remix-run/react` |
| `import type { Route } from "./+types/<route>"` | `import type { LoaderFunctionArgs } from "@remix-run/node"` + `useLoaderData<typeof loader>()` |
| `react-router.config.ts` | `remix.config.js` |
## 1. Setup & Client Pattern
### Scaffold a new React Router v7 app
```bash
npx create-react-router@latest my-app -y
cd my-app
npm install @sanity/client @sanity/react-loader @sanity/visual-editing @portabletext/react groq
```
`-y` accepts defaults. The Sanity packages cover server loaders (`@sanity/react-loader`, `@sanity/client`), live preview (`@sanity/visual-editing`), Portable Text rendering (`@portabletext/react`), and typed queries (`groq`).
To support both server-side fetching and client-side live previews, use the **Split Loader Pattern**.
### A. Shared Loader (`app/sanity/loader.ts`)
### A. Environment Variables
React Router runs on Vite. **Any module reachable from a route component gets bundled into the client**`process.env` doesn't exist there and will throw `ReferenceError: process is not defined` on client-side route transitions (SSR will still work, which makes this trap easy to miss).
Split publishable values from secrets:
- **Publishable** (`projectId`, `dataset`, `apiVersion`, `studioUrl`): prefix with `VITE_` and read via `import.meta.env`. Safe to import from anywhere.
- **Secrets** (read tokens, webhook secrets): keep unprefixed and read via `process.env` **only inside `*.server.ts` files**. Never re-export them from a shared module.
`.env`:
```
VITE_SANITY_PROJECT_ID=your-project-id
VITE_SANITY_DATASET=production
VITE_SANITY_API_VERSION=2026-02-01
VITE_SANITY_STUDIO_URL=http://localhost:3333
SANITY_API_READ_TOKEN=your-read-token
```
`app/sanity/env.ts` — browser-safe, publishable values only:
```typescript
export const projectId = import.meta.env.VITE_SANITY_PROJECT_ID!
export const dataset = import.meta.env.VITE_SANITY_DATASET!
export const apiVersion = import.meta.env.VITE_SANITY_API_VERSION ?? '2026-02-01'
export const studioUrl = import.meta.env.VITE_SANITY_STUDIO_URL
```
### B. Shared Loader (`app/sanity/loader.ts`)
Defines the store config (SSR enabled, client deferred).
```typescript
@@ -39,21 +73,27 @@ export const {
} = createQueryStore({ client: false, ssr: true })
```
### B. Server Loader (`app/sanity/loader.server.ts`)
Initializes the server client.
### C. Server Loader (`app/sanity/loader.server.ts`)
Initializes the server client. Read the token directly from `process.env` here — do **not** import it from `env.ts`, or it will leak into the client bundle the moment any client-reachable module touches `env.ts`.
```typescript
import { createClient } from '@sanity/client'
import { loadQuery, setServerClient } from './loader'
import { projectId, dataset, apiVersion, studioUrl } from './env'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
projectId,
dataset,
apiVersion,
useCdn: true,
apiVersion: '2026-02-01',
token: process.env.SANITY_API_READ_TOKEN,
stega: {
enabled: true,
studioUrl: 'https://my-studio-url.com',
// Stega encodes invisible markers into string fields for click-to-edit
// overlays in the Presentation tool. Those markers can leak into copy/paste,
// screen readers, and some downstream renderers, so only enable when actually
// previewing — gate on an env var that's only set in preview environments.
enabled: Boolean(studioUrl),
studioUrl,
},
})
@@ -62,28 +102,127 @@ setServerClient(client)
export { loadQuery }
```
## 2. Data Fetching (Loaders)
### D. Browser-safe Client + Image URL Builder (`app/sanity/client.ts`, `app/sanity/image.ts`)
Use `loadQuery` from your **server** file in route loaders.
Anything used by a route component runs in the browser too. Build a separate publishable-only client for things like the image URL builder:
```typescript
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
// app/sanity/client.ts
import { createClient } from '@sanity/client'
import { projectId, dataset, apiVersion } from './env'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
})
```
```typescript
// app/sanity/image.ts
import imageUrlBuilder from '@sanity/image-url'
import { client } from './client'
const builder = imageUrlBuilder(client)
export const urlFor = (source: Parameters<typeof builder.image>[0]) => builder.image(source)
```
Install `@sanity/image-url` if you'll render images:
```bash
npm install @sanity/image-url
```
### E. Queries (`app/sanity/queries.ts`)
Keep query definitions in one place so route loaders, components, and TypeGen all read the same source.
```typescript
import { defineQuery } from "groq";
export const POSTS_QUERY = defineQuery(
`*[_type == "post" && defined(slug.current)] | order(_createdAt desc){
_id, title, slug
}`
);
export const POST_QUERY = defineQuery(
`*[_type == "post" && slug.current == $slug][0]{
_id, title, body, image
}`
);
```
## 2. Data Fetching (Loaders)
Use `loadQuery` from your **server** file in route loaders. Import the generated `Route` type from `./+types/<route>` — React Router writes one type module per route file.
```typescript
// app/routes/home.tsx
import type { Route } from "./+types/home";
import { loadQuery } from "~/sanity/loader.server";
import { POSTS_QUERY } from "~/sanity/queries";
export async function loader({ params }: LoaderFunctionArgs) {
const initial = await loadQuery(POSTS_QUERY, params);
return { initial, query: POSTS_QUERY, params };
export async function loader() {
const initial = await loadQuery(POSTS_QUERY, {});
return { initial, query: POSTS_QUERY, params: {} };
}
export default function Index() {
const { initial, query, params } = useLoaderData<typeof loader>();
// ... pass to component
export default function Home({ loaderData }: Route.ComponentProps) {
const { initial } = loaderData;
// pass to component
}
```
## 3. Real-time Preview & Visual Editing
For Remix v2: replace `Route.ComponentProps` / `Route.LoaderArgs` with `useLoaderData<typeof loader>()` and `LoaderFunctionArgs` from `@remix-run/node`.
## 3. Dynamic Routes (`:slug`)
Register the dynamic route in `app/routes.ts`:
```typescript
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route(":slug", "routes/post.tsx"),
] satisfies RouteConfig;
```
Then in `app/routes/post.tsx`:
```typescript
import type { Route } from "./+types/post";
import { PortableText } from "@portabletext/react";
import { loadQuery } from "~/sanity/loader.server";
import { useQuery } from "~/sanity/loader";
import { urlFor } from "~/sanity/image";
import { POST_QUERY } from "~/sanity/queries";
export async function loader({ params }: Route.LoaderArgs) {
const initial = await loadQuery(POST_QUERY, { slug: params.slug });
return { initial, query: POST_QUERY, params: { slug: params.slug } };
}
export default function Post({ loaderData }: Route.ComponentProps) {
const { initial, query, params } = loaderData;
const { data: post } = useQuery(query, params, { initial });
return (
<article>
<h1>{post?.title}</h1>
{post?.image && (
<img src={urlFor(post.image).width(1200).url()} alt={post.title ?? ""} />
)}
{post?.body && <PortableText value={post.body} />}
</article>
);
}
```
This route is the canonical shape that exposes the env trap: `urlFor``client.ts``env.ts`. If `env.ts` reads `process.env`, the route works under SSR (curl returns HTML) but the client-side `<Link>` navigation will throw `ReferenceError: process is not defined` in the browser console and React Router will hard-reload back to `/`.
## 4. Real-time Preview & Visual Editing
### A. Use `useQuery` in Components
Import `useQuery` from your **shared** loader file.
@@ -91,15 +230,13 @@ Import `useQuery` from your **shared** loader file.
```typescript
import { useQuery } from "~/sanity/loader";
export default function Page() {
const { initial, query, params } = useLoaderData<typeof loader>();
export default function Page({ loaderData }: Route.ComponentProps) {
const { initial, query, params } = loaderData;
const { data, encodeDataAttribute } = useQuery(query, params, {
initial
});
const { data, encodeDataAttribute } = useQuery(query, params, { initial });
return (
<h1 data-sanity={encodeDataAttribute('title')}>
<h1 data-sanity={encodeDataAttribute("title")}>
{data?.title}
</h1>
);
@@ -124,7 +261,7 @@ export default function VisualEditing() {
Render this component in `root.tsx` only when valid (e.g., check env vars or user session).
## 4. Stega Cleaning
## 5. Stega Cleaning
When using data for logic (routing, classNames), use `stegaClean`.
```typescript
+265 -95
View File
@@ -1,155 +1,325 @@
---
title: "SvelteKit & Sanity Integration Rules"
description: Integration guide for SvelteKit with Sanity, including @sanity/svelte-loader, Visual Editing, and Preview Mode.
description: Integration guide for SvelteKit with Sanity using @sanity/sveltekit, including Visual Editing and Preview Mode.
---
# 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
### Installation
### Scaffold a new SvelteKit app
```bash
npm install @sanity/svelte-loader @sanity/client @sanity/visual-editing
npx sv@latest create my-app --template minimal --types ts --no-add-ons --install npm
cd my-app
```
### Client Configuration (`src/lib/sanity.ts`)
Define the client with `stega` enabled for the studio URL.
`--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`).
```typescript
import { createClient } from '@sanity/client'
import { PUBLIC_SANITY_PROJECT_ID, PUBLIC_SANITY_DATASET, PUBLIC_SANITY_API_VERSION, PUBLIC_SANITY_STUDIO_URL } from '$env/static/public'
### Installation
```bash
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`)
```bash
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
```ts
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
```ts
import {createClient} from '@sanity/sveltekit'
import {apiVersion, projectId, dataset, studioUrl} from '$lib/sanity/api'
export const client = createClient({
projectId: PUBLIC_SANITY_PROJECT_ID,
dataset: PUBLIC_SANITY_DATASET,
apiVersion: PUBLIC_SANITY_API_VERSION,
projectId,
dataset,
apiVersion,
useCdn: true,
stega: {
studioUrl: PUBLIC_SANITY_STUDIO_URL,
},
stega: {studioUrl},
})
```
### Server Client (`src/lib/server/sanity.ts`)
Use the read token for fetching preview content.
Import `createClient` from `@sanity/sveltekit`, not `@sanity/client`. `useCdn: true` is for production reads; the server (preview) client below overrides to `false`.
```typescript
import { SANITY_API_READ_TOKEN } from '$env/static/private'
import { client } from '$lib/sanity'
### `src/lib/sanity/client.server.ts` — server (preview) client
```ts
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,
stega: true, // Optional: enable stega on server too if needed
useCdn: false,
stega: true,
})
```
## 2. Hooks & Request Handler (Critical)
### `src/lib/sanity/queries.ts` — queries + types
You **must** configure `createRequestHandler` in `src/hooks.server.ts` to handle preview sessions and inject `loadQuery` into locals.
```ts
import {groq} from '@sanity/sveltekit'
```typescript
// src/hooks.server.ts
import { createRequestHandler, setServerClient } from '@sanity/svelte-loader'
import { serverClient } from '$lib/server/sanity'
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
```ts
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
```ts
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 = createRequestHandler()
export const handle = sequence(
handlePreviewMode({
client: serverClient,
preview: {redirect},
}),
handleQueryLoader(),
)
```
**Update `app.d.ts` types:**
```typescript
import type { LoaderLocals } from '@sanity/svelte-loader'
`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
```ts
import type {SanityLocals} from '@sanity/sveltekit'
declare global {
namespace App {
interface Locals extends LoaderLocals {}
interface Locals extends SanityLocals {}
}
}
export {}
```
## 3. Preview State Propagation
## 4. Layout: Preview + Visual Editing Providers
Pass the preview state from the server to the client via the root layout.
### `src/routes/+layout.server.ts` — propagate previewEnabled
**Server Layout (`src/routes/+layout.server.ts`):**
```typescript
import type { LayoutServerLoad } from './$types'
```ts
import type {LayoutServerLoad} from './$types'
export const load: LayoutServerLoad = ({ locals: { preview } }) => {
return { preview }
export const load: LayoutServerLoad = (event) => {
const {previewEnabled} = event.locals.sanity
return {previewEnabled}
}
```
**Client Layout (`src/routes/+layout.ts`):**
```typescript
import { setPreviewing } from '@sanity/svelte-loader'
import type { LayoutLoad } from './$types'
export const load: LayoutLoad = ({ data: { preview } }) => {
setPreviewing(preview)
}
```
## 4. Data Fetching (Loaders)
Use `locals.loadQuery` in your page server loaders.
```typescript
// src/routes/[slug]/+page.server.ts
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals: { loadQuery }, params }) => {
const initial = await loadQuery(QUERY, params)
return { initial }
}
```
## 5. Real-time Preview & Visual Editing
### Component Usage (`useQuery`)
Use `useQuery` in your Svelte component to handle real-time updates.
### `src/routes/+layout.svelte` — wrap children in providers (Svelte 5)
```svelte
<!-- src/routes/[slug]/+page.svelte -->
<script lang="ts">
import { useQuery } from '@sanity/svelte-loader'
import type { PageData } from './$types'
export let data: PageData
const { initial } = data
// Hydrate with initial data
const query = useQuery(initial)
// Reactive data access
$: ({ data: post, loading, encodeDataAttribute } = $query)
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>
{#if !loading && post}
<!-- Use encodeDataAttribute for overlays -->
<h1 data-sanity={encodeDataAttribute('title')}>
{post.title}
</h1>
<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`:
```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`:
```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}
```
### Enable Visual Editing (`+layout.svelte`)
Enable Visual Editing and Live Mode in your root layout.
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`:
```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`:
```svelte
<script lang="ts">
import { useLiveMode } from '@sanity/svelte-loader'
import { enableVisualEditing } from '@sanity/visual-editing'
import { PUBLIC_SANITY_STUDIO_URL } from '$env/static/public'
import { onMount } from 'svelte'
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'
onMount(() => enableVisualEditing())
onMount(() => useLiveMode({
studioUrl: PUBLIC_SANITY_STUDIO_URL
}))
const {data}: PageProps = $props()
const query = $derived(useQuery<Post>(data))
const post = $derived($query.data)
</script>
<slot />
{#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.
```ts
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.