Skill 05 · Sanity Best Practices
Subchapter 5.6
references/get-started.mdMarkdown21 KBView on GitHub
Getting started with Sanity follows three phases:
Keep responses succinct:
Examples:
TRIGGER PHRASE: When the user says “Get started with Sanity” or similar, follow these steps.
Before starting: Let the user know they can pause and resume anytime by saying “Continue Sanity setup”.
RESUME TRIGGER: If the user says “Continue Sanity setup”, check what’s already configured:
sanity.config.ts exist (typically in a studio/ folder)? → Studio is set upschemaTypes export)? → Schema existspackage.json? → May need integrationDo not treat files in schemaTypes/ as proof that a schema exists. The clean
Studio template includes schemaTypes/index.ts with an empty
schemaTypes array.
Resume from where they left off.
Check whether Sanity MCP tools are already available before creating files.
schema.md patterns, run deploy_schema, then
deploy_studio. Skip Phase 1 below and continue with Phase 2.From the intended workspace root, run:
npx sanity@latest debugIf the User result identifies a logged-in user, prefer the account-owned
sanity init path in Step 1. Otherwise, continue directly with sanity new
below.
Without an account, sanity new creates a working full-stack app with Sanity as
the content backend in seconds. The project works immediately; claim it within
72 hours to keep it. First run this from the intended workspace root:
npx sanity@latest new --instructionsThis prints the current agent guide and creates nothing. Read and follow it before creating the project because this flow is rolling out and the CLI guide is the source of truth.
From an empty workspace, the batteries-included command is:
npx sanity@latest new "<project name>" --yesChoose the setup that fits:
sanity/, and a Next.js app in web/, all connected and ready to run.sanity/ Studio without replacing the frontend.--no-scaffold when only the project and credentials are
needed, such as for a custom setup or a framework other than Next.js. This
creates no Studio or frontend files.Tell the user the claim link and expiry immediately. Treat the claim link and robot token as secrets: never commit them or paste them into issues, PRs, or shared channels, and keep the token server-only. Do not run the authenticated initializer in Step 1 afterward.
If the robot token, claim link, or other project details are not readily available, recover the existing project instead of creating a duplicate:
npx sanity@latest projects unclaimed
npx sanity@latest projects unclaimed --project-id <projectId>The project-specific command returns full details, including the robot token. Keep its output private.
Look for sanity.config.ts or sanity.cli.ts across the workspace — in the recommended side-by-side layout the Studio lives in its own folder (studio/, sanity/ when created by sanity new, or studio-* in some onboarding flows) next to the app folder:
If NO Studio found:
node --version. Current Sanity Studio and CLI releases
require Node.js 22.12 or newer.sanity.new flow above and then continue at Step 2 with the generated
sanity/ Studio. The remaining bullets are for the account-owned
sanity init path.sanity init to create or select the project and dataset. If the project,
organization, or dataset choice is unclear, ask the user rather than guessing.sanity init unattended from the repo root —
not inside a Next.js app folder, where the CLI would switch to its
embedded flow (not recommended):
npx sanity@latest init --yes --project <projectId> --dataset <dataset> --template clean --typescript --output-path studiosanity login, then
retry. If a project, organization, or dataset choice is still missing, ask
the user to provide it. Do not fall back to an interactive initializer flow.studio/, alongside your app folder (see project-structure.md)If Studio exists:
projectId and datasetInspect the types registered by sanity.config.ts, usually through
schemaTypes/index.ts, schemas/index.ts, or
src/sanity/schemaTypes/index.ts:
If NO custom types are registered:
schema.md for patternsIf custom types are registered:
Before moving to Phase 2, keep track of the primary document type and the
fields needed to list and render it. Carry that choice through content checks,
sample content, queries, routes, and components. Never fall back to post
unless the registered primary type is actually post.
If they want a quick example: Create a basic blog schema:
// schemaTypes/post.ts
import { defineArrayMember, defineField, defineType } from 'sanity'
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
defineField({
name: 'body',
type: 'array',
of: [defineArrayMember({ type: 'block' })],
}),
],
})Register the type in the schema entry point:
// schemaTypes/index.ts
import { post } from './post'
export const schemaTypes = [post]Creating the file is not enough. Only types included in the array passed to
schema.types are part of the Studio schema and available to schema
deployment.
Required before Phase 2:
Run schema commands with the detected Studio folder as the working directory. For the default side-by-side layout:
cd studio
npx sanity schemas deployThis uploads your schema to the Content Lake so MCP tools can work with it.
Use MCP query_documents to check:
*[_type == "<primaryDocumentType>"][0...5]Replace <primaryDocumentType> with the registered type selected in Phase 1,
such as post, product, or project.
If content exists:
If NO content:
If migrating from another CMS or files:
migration.md and the sanity-migration skill for guidancecreate_documents and patch_documents after converting content to structured Sanity documentsAsk the agent to draft structured sample content that matches the selected primary document type, then create it with the Sanity MCP Server.
For the quick blog example above:
Tool: create_documents
Documents: [{
type: "post",
content: {
title: "Getting started with Sanity",
slug: { _type: "slug", current: "getting-started-with-sanity" },
body: []
}
}]The content tool creates a draft. Show the draft to the user and ask whether to
publish it so the public frontend can read it. If yes, call
publish_documents with the returned document ID before starting frontend
integration.
If MCP content tools cannot see new types or fields: Remind them to run npx sanity schemas deploy first.
Quick start via Sanity CLI:
npx sanity@latest mcp configureThis command detects Codex, Cursor, Claude Code, VS Code, and other supported editors. Prefer it over editing client configuration by hand.
Codex (manual fallback): Register the server globally and authenticate in one command:
codex mcp add sanity --url https://mcp.sanity.ioCursor: One-click install →
Or add to .cursor/mcp.json:
{
"mcpServers": {
"Sanity": {
"type": "http",
"url": "https://mcp.sanity.io"
}
}
}Claude Code:
claude mcp add Sanity -t http https://mcp.sanity.io --scope userVS Code: Command Palette → MCP: Open User Configuration → add:
{
"servers": {
"Sanity": {
"type": "http",
"url": "https://mcp.sanity.io"
}
}
}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:
import.meta.env.VITE_*$env/static/publicimport.meta.env.PUBLIC_*useRuntimeConfig().publicFor 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.
The working directory is often a parent folder with the Studio and the app side by side. Identify the app folder first: a sibling of the Studio folder with its own package.json (commonly web/). If several candidates exist, ask the user which app to integrate — never assume.
Check the app’s package.json dependencies:
| Dependency | Framework | Rule File |
|---|---|---|
next | Next.js | nextjs.md |
@remix-run/react or react-router | React Router / Remix | remix.md |
svelte or @sveltejs/kit | SvelteKit | svelte.md |
nuxt | Nuxt | nuxt.md |
astro | Astro | astro.md |
If NO framework found:
If Next.js is detected, follow these essential steps:
The inline implementation below continues the quick Blog example. If the
primary document type is not post, adapt the type filter, projection, sample
document, route, component names, and renderer to the fields selected in Phase
post as a fallback for E-commerce or Portfolio
setups.Scaffold a new app (if you don’t have one yet):
Run from the repo root so the app sits alongside your studio/ folder:
npx create-next-app@latest web --tailwind --ts --app --src-dir --eslint --import-alias "@/*" --turbopack
cd webInstall dependencies:
npm install next-sanity @sanity/image-urlnext-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, stegaCleannext-sanity/live — defineLive for live content with Next.js cache integrationnext-sanity/draft-mode — Draft Mode endpoint helpersnext-sanity/visual-editing — <VisualEditing /> component for click-to-edit overlaysnext-sanity/image — Sanity-aware <Image /> wrapping next/imagenext-sanity/studio — embed the Sanity Studio at a route (legacy setups only — keep the Studio standalone, see nextjs.md)next-sanity/webhook — webhook signature verificationDon’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):
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: "YYYY-MM-DD", // Replace with today's UTC date and keep it hard-coded
useCdn: true, // Fast, cached published-content reads
});Fetch content in a Server Component:
// src/app/page.tsx
import { client } from "@/sanity/client";
import { defineQuery, type SanityDocument } from "next-sanity";
import Link from "next/link";
const POSTS_QUERY = defineQuery(
`*[_type == "post" && defined(slug.current)] | order(_createdAt desc){ _id, title, slug }`
);
const options = { next: { revalidate: 30 } };
export default async function PostsPage() {
const posts = await client.fetch<SanityDocument[]>(POSTS_QUERY, {}, options);
return (
<ul>
{posts.map((post) => (
<li key={post._id}>
<Link href={`/${(post.slug as { current?: string })?.current}`}>{post.title as string}</Link>
</li>
))}
</ul>
);
}{ next: { revalidate: 30 } } opts the fetch into Next.js’ ISR cache with a
30-second revalidation window. This is a minimal published-content path for a
first smoke test. Tune to taste; omit options to use defaults.
Render an individual post (src/app/[slug]/page.tsx):
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=productionConfigure TypeGen before calling the Next.js setup complete:
Merge the TypeGen settings into the existing studio/sanity.cli.ts. For the
side-by-side studio/ and web/ layout:
typegen: {
enabled: true,
path: '../web/src/**/*.{ts,tsx,js,jsx}',
schema: 'schema.json',
generates: '../web/sanity.types.ts',
overloadClientMethods: true,
},Add a repeatable script to studio/package.json:
"typegen": "sanity schemas extract --force && sanity typegen generate"Then run it from the Studio folder:
cd studio
npm run typegenConfirm TypeGen found the frontend queries, then remove the SanityDocument
import, broad generic arguments, and casts. Run TypeGen after schema or query
changes. For other layouts, use typegen.md to adjust the paths.
For the recommended production path—live content with defineLive, Visual
Editing, and the standalone Studio architecture—follow nextjs.md.
For non-Next.js frameworks, read the corresponding rule file and follow its integration guide:
remix.mdsvelte.mdnuxt.mdastro.mdEach rule file contains framework-specific patterns for data fetching, Portable Text rendering, and Visual Editing.
Before declaring integration done, exercise both render paths:
npm run dev (in the app folder)<Link> — do not paste the URL.ReferenceError: process is not defined, no hard reload to /.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.
Once setup is complete, let the user know:
“You’re all set! Here are some things I can help with:
visual-editing.md)typegen.md)studio-structure.md)seo.md)localization.md)Just ask about any of these!”
| Framework | Client-Side Prefix | Example |
|---|---|---|
| Next.js | NEXT_PUBLIC_ | NEXT_PUBLIC_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.
npx sanity@latest new --instructions # Print the current no-write sanity.new guide
npx sanity@latest debug # Check the current CLI user without logging in
npx sanity@latest init # Initialize an account-owned project or Studio
npx sanity@latest mcp configure # Configure MCP for your editor
npx sanity dev # Start Studio locally
npx sanity schemas deploy # Deploy schema for MCP/editor access
npx sanity deploy # Deploy Studio to Sanity hosting
npx sanity manage # Open project settings
npm run typegen # Generate types (run in Studio after adding the script above)