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.

referencesremix.md

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

React Router (Remix) & Sanity Integration Rules

Version Note

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.

If you are on the older Remix v2 stack, the integration shape is identical; only the import paths differ:

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

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

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

import { createQueryStore } from '@sanity/react-loader'

export const {
  loadQuery,
  setServerClient,
  useQuery,
  useLiveMode,
} = createQueryStore({ client: false, ssr: true })

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.

import { createClient } from '@sanity/client'
import { loadQuery, setServerClient } from './loader'
import { projectId, dataset, apiVersion, studioUrl } from './env'

const client = createClient({
  projectId,
  dataset,
  apiVersion,
  useCdn: true,
  token: process.env.SANITY_API_READ_TOKEN,
  stega: {
    // 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,
  },
})

setServerClient(client)

export { loadQuery }

D. Browser-safe Client + Image URL Builder (app/sanity/client.ts, app/sanity/image.ts)

Anything used by a route component runs in the browser too. Build a separate publishable-only client for things like the image URL builder:

// app/sanity/client.ts
import { createClient } from '@sanity/client'
import { projectId, dataset, apiVersion } from './env'

export const client = createClient({
  projectId,
  dataset,
  apiVersion,
  useCdn: true,
})
// 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:

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.

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.

// 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() {
  const initial = await loadQuery(POSTS_QUERY, {});
  return { initial, query: POSTS_QUERY, params: {} };
}

export default function Home({ loaderData }: Route.ComponentProps) {
  const { initial } = loaderData;
  // …pass to component
}

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:

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:

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.

import { useQuery } from "~/sanity/loader";

export default function Page({ loaderData }: Route.ComponentProps) {
  const { initial, query, params } = loaderData;

  const { data, encodeDataAttribute } = useQuery(query, params, { initial });

  return (
    <h1 data-sanity={encodeDataAttribute("title")}>
      {data?.title}
    </h1>
  );
}

B. Enable Live Mode (VisualEditing.tsx)

Create a component to handle the connection.

import { enableVisualEditing } from '@sanity/visual-editing'
import { useLiveMode } from '~/sanity/loader'
import { client } from '~/sanity/client' // Your browser-safe client
import { useEffect } from 'react'

export default function VisualEditing() {
  useEffect(() => enableVisualEditing(), [])
  useLiveMode({ client })
  return null
}

Render this component in root.tsx only when valid (e.g., check env vars or user session).

5. Stega Cleaning

When using data for logic (routing, classNames), use stegaClean.

import { stegaClean } from "@sanity/client/stega"
// ...
if (stegaClean(slug) === 'home') { ... }

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.