An opinionated guide to Sanity Studio
Sanity Studio is an incredibly flexible tool with near limitless customisation. Here's how I use it.
Go to An opinionated guide to Sanity StudioGive your authors the ultimate content creation experience with Presentation's interactive live preview for absolute confidence to publish.
This guide is for Next.js applications using the App router.
Go to this guide for Visual Editing using Next.js‘ Pages router.
This guide deliberately focuses on the experience of manually creating a new Next.js 14 application and creating a Sanity project with an embedded Studio.
All the instructions below could also be adapted to an existing Next.js application.
The following terms describe the functions that combine to create an interactive live preview, known as Visual Editing.
Visual Editing can be enabled on any hosting platform or front end framework.
Create a new project using the command below. Default options such as TypeScript, Tailwind, and ESLint have been selected for you but could be removed if you have different preferences. Just know the code snippets in this guide may no longer be compatible.
# from the command line
npx create-next-app@latest sanity-nextjs-app --typescript --tailwind --eslint --app --src-dir --import-alias="@/*"
# enter the new project's directory
cd sanity-nextjs-app
# run the development server
npm run dev
Need more help? See the Next.js docs for getting started.
Visit http://localhost:3000 in your web browser, and you should see this landing screen to show it’s been installed correctly.
The default Next.js project home page comes with some code boilerplate. So that you can more easily see what’s Sanity and what’s Next.js – you will remove almost all of it.
First, update the home page route file to simplify it greatly:
// ./src/app/page.tsx
export default function Page() {
return (
<main className="flex items-center justify-center min-h-screen">
Populate me with Sanity Content
</main>
)
}
Second, update the globals.css
file to just Tailwind utilities:
/* ./src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Now, our Next.js app at http://localhost:3000 should look much simpler:
Update nextjs.config.mjs
to include some features you'll need later. One for using images from Sanity's CDN, the other to help protect your token to only be rendered server-side.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.sanity.io",
},
],
},
experimental: {
taint: true,
},
// ...other config settings
};
export default nextConfig;
It's possible to create a new – or connect an existing – Sanity project and configure a Studio inside a Next.js application!
Run the following command from inside the same ./nextjs-app
directory you created for your Next.js application and follow the prompts:
npx sanity@latest init --env --create-project "Next.js Live Preview" --dataset production
> Would you like to add configuration files for a Sanity project in this Next.js folder?
Yes
> Do you want to use TypeScript?
Yes
> Would you like an embedded Sanity Studio?
Yes
> Would you like to use the Next.js app directory for routes?
Yes
> What route do you want to use for the Studio?
/studio
> Select project template to use
Blog (schema)
> Would you like to add the project ID and dataset to your .env file?
Yes
Now your Next.js application should contain some Sanity-specific files, including a .env
file with your Sanity project ID and dataset name
Check to see that this file exists with values from your new project.
# ./.env.local NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id" NEXT_PUBLIC_SANITY_DATASET="production"
If this file is named .env
, rename it to .env.local
and ensure it does not get checked into git.
While these two values are not considered "secrets," you will add one later in this guide. It's best practice never to check .env
files into your version control platform.
Visit http://localhost:3000/studio to see your new Sanity project's Studio.
Note: When deploying the site to your hosting, you must:
Once logged in, your Studio should look like this with a basic schema to create blog posts.
Create and publish a few posts so that you have content to query.
Server-side fetching of content will require a token in order to enable stega encoding.
Visit your Studio at http://localhost:3000/studio, and from the top right in your user icon, click Manage project.
Navigate to the API tab, and under Tokens, add a new token. Give it viewer
permissions and save.
Open your .env.local
file and add the token on a new line as SANITY_API_READ_TOKEN
# ./.env.local NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id" NEXT_PUBLIC_SANITY_DATASET="production" # 👇 add this line SANITY_API_READ_TOKEN="your-new-token"
It is your responsibility to secure this token, and beware that unencrypted access could allow a user to read any document from any dataset in your project. The way it is implemented in this guide should result in the token only ever being read server-side and never being transmitted to the browser. The Personal Website template contains additional logic to help prevent the value being leaked client-side.
experimental_taintUniqueValue
is only available in React 19, which may cause issues and not be compatible with other dependencies in your project. Examples currently include Framer Motion and Material UI. You may choose to access the token from your project without it, but will need to take care to not pass its value from the server to the client.
Create a file to store, protect, and export this token:
// ./src/sanity/lib/token.ts
import 'server-only'
import { experimental_taintUniqueValue } from 'react'
export const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
throw new Error('Missing SANITY_API_READ_TOKEN')
}
experimental_taintUniqueValue(
'Do not pass the Sanity API read token to the client.',
process,
token,
)
Create a new file to store the GROQ queries you'll use in the Next.js application:
// ./src/sanity/lib/queries.ts
import { defineQuery } from "next-sanity";
export const POSTS_QUERY =
defineQuery(`*[_type == "post" && defined(slug.current)][0...12]{
_id, title, slug
}`);
export const POST_QUERY =
defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title, body, mainImage
}`);
You can use Sanity TypeGen to generate TypeScript types for your schema types and GROQ queries from inside your Next.js application.
Run the following command in your terminal to create a schema.json
file at the root of your project.
# Run this each time your schema types change
npx sanity@latest schema extract
Run the following command in your terminal to generate TypeScript types and create a sanity.types.ts
file at the root of your project.
# Run this each time your schema types or GROQ queries change
npx sanity@latest typegen generate
This will create Types for query results based on any GROQ queries it finds with either the groq
template literal or defineQuery
helper function.
next-sanity
is the do-it-all toolkit for building with Sanity in Next.js. For brevity, this guide skips over explanations of some more complex parts like integrating with Next.js caching options. See the next-sanity readme for more details.
Data fetching with Sanity is typically done with Sanity Client, and one has been configured for you already. However, to dynamically configure it for Next.js draftMode
and caching, it is useful to wrap it in a helper function that sets some sensible defaults.
Update the file that was created for you to prepare a Sanity Client to export sanityFetch()
.
// ./src/sanity/lib/client.ts
import "server-only";
import { createClient, QueryOptions, type QueryParams } from "next-sanity";
import { draftMode } from "next/headers";
import { apiVersion, dataset, projectId } from "../env";
import { token } from "./token";
export const client = createClient({
projectId,
dataset,
apiVersion, // https://www.sanity.io/docs/api-versioning
useCdn: true, // Set to false if statically generating pages, using ISR or tag-based revalidation
stega: {
enabled: process.env.NEXT_PUBLIC_VERCEL_ENV === "preview",
studioUrl: "/studio",
},
});
export async function sanityFetch<const QueryString extends string>({
query,
params = {},
revalidate = 60, // default revalidation time in seconds
tags = [],
}: {
query: QueryString;
params?: QueryParams;
revalidate?: number | false;
tags?: string[];
}) {
const isDraftMode = draftMode().isEnabled;
if (isDraftMode && !token) {
throw new Error("Missing environment variable SANITY_API_READ_TOKEN");
}
const queryOptions: QueryOptions = {};
let maybeRevalidate = revalidate;
if (isDraftMode) {
queryOptions.token = token;
queryOptions.perspective = "previewDrafts";
queryOptions.stega = true;
maybeRevalidate = 0; // Do not cache in Draft Mode
} else if (tags.length) {
maybeRevalidate = false; // Cache indefinitely if tags supplied
}
return client.fetch(query, params, {
...queryOptions,
next: {
revalidate: maybeRevalidate,
tags,
},
});
}
Create a new file with a component that will render all of your posts on the home page:
// ./src/components/Posts.tsx
import { POSTS_QUERYResult } from "../../sanity.types";
export function Posts({ posts }: { posts: POSTS_QUERYResult }) {
return (
<ul className="container mx-auto grid grid-cols-1 divide-y divide-blue-100">
{posts.map((post) => (
<li key={post._id}>
<a
className="block p-4 hover:bg-blue-50"
href={`/posts/${post?.slug?.current}`}
>
{post?.title}
</a>
</li>
))}
</ul>
);
}
We're ready to fetch content after some housekeeping.
Create a new folder called (blog)
inside your app
folder. This "route group" enables having distinct layouts for the studio and the rest of the website.
Move the page.tsx
file into the new (blog)
folder.
Update your home page route now to import the sanityFetch
function and add your POSTS_QUERY
to it load content on this route:
// ./src/app/(blog)/page.tsx
import { Posts } from "@/components/Posts";
import { sanityFetch } from "@/sanity/lib/client";
import { POSTS_QUERY } from "@/sanity/lib/queries";
export default async function Page() {
const posts = await sanityFetch({
query: POSTS_QUERY,
});
return <Posts posts={posts} />;
}
Open http://localhost:3000 now. The home page should now show all of your published blog posts.
You now have:
/studio
sanityFetch()
functionNow, you'll toggle Next.js' built-in "draft mode" to query draft content and reveal live-as-you-type updates inside the Presentation tool.
To enable (and disable!) Visual Editing, you will need:
Create a new app/layout.tsx
file the (blog)
folder and import the <VisualEditing />
component and have it conditionally render when draftMode
is enabled.
// ./src/app/(blog)/layout.tsx
import { VisualEditing } from "next-sanity";
import { draftMode } from "next/headers";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
{draftMode().isEnabled && (
<a
className="fixed right-0 bottom-0 bg-blue-500 text-white p-4 m-4"
href="/api/draft-mode/disable"
>
Disable preview mode
</a>
)}
{children}
{draftMode().isEnabled && <VisualEditing />}
</body>
</html>
);
}
This component handles hydrating the page with draft documents as edits are made. The code example above also adds a button to disable draft mode.
Create a new API route that the Presentation tool will use to activate draft mode.
// ./src/app/api/draft-mode/enable/route.ts
import { validatePreviewUrl } from "@sanity/preview-url-secret";
import { draftMode } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { client } from "@/sanity/lib/client";
import { token } from "@/sanity/lib/token";
const clientWithToken = client.withConfig({ token });
export async function GET(request: NextRequest) {
if (!process.env.SANITY_API_READ_TOKEN) {
return new Response("Missing environment variable SANITY_API_READ_TOKEN", {
status: 500,
});
}
const { isValid, redirectTo = "/" } = await validatePreviewUrl(
clientWithToken,
request.url
);
if (!isValid) {
return new Response("Invalid secret", { status: 401 });
}
draftMode().enable();
return NextResponse.redirect(new URL(redirectTo, request.url));
}
To secure preview mode, the Presentation tool passes a secret from the dataset along in the request, which this route will validate.
Create another API route to disable draft mode:
// ./src/app/api/draft-mode/disable/route.ts
import { draftMode } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
export function GET(request: NextRequest) {
draftMode().disable();
return NextResponse.redirect(new URL("/", request.url));
}
Now you have all the pieces assembled to fetch drafts, show real-time updates, and include stega encoding for interactive live previews – you'll need to set up the Presentation tool to show it in action.
For the closest relationship between your Next.js application and your Sanity Studio, install and configure the Presentation plugin. It will handle requesting a URL to enable draft mode, as well as the ability to navigate and edit the website from an interactive preview rather than a separate tab or window.
Update your sanity.config.ts
file to import the Presentation tool.
// ./sanity.config.ts
// ...all other imports
import { presentationTool } from 'sanity/presentation'
export default defineConfig({
// ... all other config settings
plugins: [
// ...all other plugins
presentationTool({
previewUrl: {
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
],
})
Notice how the plugin's configuration includes the route you just created. Presentation will visit this route first, confirm an automatically generated secret from the dataset, and activate draft mode in the Next.js application if successful.
You should now see the Presentation tool in the top toolbar of the Studio or by visiting http://localhost:3000/studio/presentation
The home page now displays a list of published blog posts.
If you click on one of the post titles, you'll be taken to the post document focusing on the title field. You can edit and see the updates happening in real-time on the front end.
Now at http://localhost:3000/studio/presentation the Presentation tool should show:
Success!
When you click on any of these posts (with the Edit mode off), they return a 404 error. You'll need to create a route and a component for individual posts.
Create a new route with a slug parameter passed into the query:
// ./app/(blog)/posts/[slug]/page.tsx
import { QueryParams } from "next-sanity";
import { notFound } from "next/navigation";
import { POSTS_QUERY, POST_QUERY } from "@/sanity/lib/queries";
import { client, sanityFetch } from "@/sanity/lib/client";
import { Post } from "@/components/Post";
export async function generateStaticParams() {
const posts = await client.fetch(
POSTS_QUERY,
{},
{ perspective: "published" }
);
return posts.map((post) => ({
slug: post?.slug?.current,
}));
}
export default async function Page({ params }: { params: QueryParams }) {
const post = await sanityFetch({
query: POST_QUERY,
params,
});
if (!post) {
return notFound();
}
return <Post post={post} />;
}
Create a component to display a single post:
// ./src/components/Post.tsx
import Image from "next/image";
import { PortableText } from "@portabletext/react";
import { urlFor } from "@/sanity/lib/image";
import { POST_QUERYResult } from "../../sanity.types";
import Link from "next/link";
export function Post({ post }: { post: POST_QUERYResult }) {
const { title, mainImage, body } = post || {};
return (
<main className="container mx-auto prose prose-lg p-4">
{title ? <h1>{title}</h1> : null}
{mainImage?.asset?._ref ? (
<Image
className="float-left m-0 w-1/3 mr-4 rounded-lg"
src={urlFor(mainImage?.asset?._ref).width(300).height(300).url()}
width={300}
height={300}
alt={title || ""}
/>
) : null}
{body ? <PortableText value={body} /> : null}
<hr />
<Link href="/">← Return home</Link>
</main>
);
}
On single post pages, the Portable Text field from the Studio is being rendered into HTML by the <PortableText />
component.
Install the Tailwind CSS Typography package to quickly apply beautiful default styling:
npm install -D @tailwindcss/typography
Update your tailwind.config.js
file's plugins to include it:
// ./tailwind.config.ts
module.exports = {
// ...other settings
plugins: [require('@tailwindcss/typography')],
}
This package styles the prose
class names in the <Post />
component.
You should now be able to click into individual posts and see text fields, portable text, and images rendered beautifully. Inside Presentation, you should be able to make content edits and see them update as you type!
The content of a document can be used in multiple places. In this simple example, even a post’s title is shown both on the individual post route and in the post listing on the home page. The Visual Editing mode enables preview across the whole site.
To show where its content is used and can be previewed within a document form, you must pass a configuration that tells the presentation tool where it can open any document.
Create a new file for the resolve
option in the Presentation plugin options:
// ./src/sanity/presentation/resolve.ts
import {
defineLocations,
PresentationPluginOptions,
} from "sanity/presentation";
export const resolve: PresentationPluginOptions["resolve"] = {
locations: {
// Add more locations for other post types
post: defineLocations({
select: {
title: "title",
slug: "slug.current",
},
resolve: (doc) => ({
locations: [
{
title: doc?.title || "Untitled",
href: `/posts/${doc?.slug}`,
},
{ title: "Home", href: `/` },
],
}),
}),
},
};
Update your sanity.config.ts
file to import the locate function into the Presentation plugin.
// ./sanity.config.ts
// Add this import
import { resolve } from '@/sanity/presentation/resolve'
export default defineConfig({
// ...all other settings
plugins: [
presentationTool({
resolve,
previewUrl: {
draftMode: {
enable: '/api/draft',
},
},
}),
// ..all other plugins
],
})
You should now see the locations at the top of all post type documents:
And that's it! You can now follow these patterns when you extend your blog with category and author pages.
Sanity Composable Content Cloud is the headless CMS that gives you (and your team) a content backend to drive websites and applications with modern tooling. It offers a real-time editing environment for content creators that’s easy to configure but designed to be customized with JavaScript and React when needed. With the hosted document store, you query content freely and easily integrate with any framework or data source to distribute and enrich content.
Sanity scales from weekend projects to enterprise needs and is used by companies like Puma, AT&T, Burger King, Tata, and Figma.
Sanity Studio is an incredibly flexible tool with near limitless customisation. Here's how I use it.
Go to An opinionated guide to Sanity StudioIt can be useful for testing plugins, front ends, or other integrations to have a Sanity Studio populated with fake content.
Go to How to generate massive amounts of demo content for SanitySummarise form progression by decorating the entire editing form for a document with a component loaded at the root level.
Go to Create a document form progress componentObject types use a preview property to display contextual information about an item when they are inside of an array; customizing the preview component can make them even more useful for content creators.
Go to Create richer array item previews