mirror of
https://github.com/sanity-io/agent-toolkit.git
synced 2026-09-14 18:39:20 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user