Visual Editing with Next.js App Router and Sanity Studio
Give your authors the ultimate content creation experience with Presentation's interactive live preview for absolute confidence to publish.
Gotcha
This guide is for Next.js applications using the App router.
Go to this guide for Visual Editing using Next.js‘ Pages router.
Watch Simeon work through this guide to setup the entire project from start to finish.
This guide deliberately focuses on the experience of manually creating a new Next.js 15 application and creating a Sanity project with an embedded Studio.
All the instructions below could also be adapted to an existing Next.js application.
- Need reference code sooner? The final code created in this Guide is available as a repository on GitHub.
- Looking for a more complete example? The Next.js Personal Website template has an example schema and Visual Editing already set up and can be instantly deployed to Vercel.
- TypeScript is not required. The code examples in this guide are all in TypeScript; However, TypeScript is not necessary for any of this to work. You must remove the types from these examples if you work with plain JavaScript.
- Embedded Studio is not required. For convenience, you'll embed a Studio inside the Next.js application, but you could do all this with the Studio as a separate application.
- You already have a Sanity account
- You have some familiarity with both Sanity Studio and Next.js
- You are reasonably confident with JavaScript in general and React in particular.
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.
- Perspectives modify queries to return either draft or published content. These are especially useful for server-side fetching to display draft content on the initial load when previewing drafts.
- Content Source Maps aren't something you'll need to interact with directly, but they are used by Stega encoding when enabled. They are an extra response from the Content Lake that notes the full path of every field of returned content.
- Stega encoding is when the Sanity Client takes Content Source Maps and combines every field of returned content with an invisible string of characters which contains the full path from the content to the field within its source document.
- Overlays are created by a dedicated package that looks through the DOM for these stega encoded strings and creates clickable links to edit documents.
- Presentation is a plugin included with Sanity Studio to simplify displaying a front end inside an iframe with an adjacent document editor. It communicates directly with the front end instead of making round-trips to the Content Lake for faster live preview.
- Draft mode: A Next.js-specific way of enabling, checking, and disabling a global variable available during requests so that your application queries draft content.
- In other frameworks, you might replace this with an environment variable, cookie, or session.
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@15 sanity-nextjs-app --typescript --tailwind --eslint --app --src-dir --import-alias="@/*" --turbopack
# 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.
You should see something similar to this in your terminal:
> sanity-nextjs-app@0.1.0 dev
> next dev --turbopack
▲ Next.js 15.0.3 (Turbopack)
- Local: http://localhost:3000
Visit http://localhost:3000 in your web browser, 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">
Replace 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.ts
to include configuration for using images from Sanity's CDN in the Next.js Image component.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.sanity.io",
},
],
},
// ...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 /sanity-nextjs-app
directory you created for your Next.js application:
npx sanity@latest init --create-project "Next.js Live Preview" --dataset production
Then follow the prompts. If you do not already have a Sanity account – or are not yet logged into the Sanity CLI – you will be prompted to do so first.
> 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.local file?
Yes
Unfortunately at this moment there are compatibility issues between Next.js 15's use of React 19 and many packages in the React ecosystem. Read more in our compatibility guide.
If you get install errors, as mentioned in that guide, you may need to downgrade the React dependencies to version 18 (Next will still use version 19)
npm install --legacy-peer-deps react@18 react-dom@18
You can ensure all the required Sanity packages were installed by re-running:
npm install --legacy-peer-deps --save next-sanity@9 @sanity/vision@3 sanity@3 @sanity/image-url@1 styled-components@6 @sanity/icons
Now your Next.js application should contain some Sanity-specific files, including a .env.local
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"
While these two values are not considered "secrets," you will add one later in this guide. It's best practice never to check any .env
files into your version control platform.
Visit http://localhost:3000/studio to see your new Sanity project's Studio.
- You may need to restart your development environment.
- You may also need to follow the prompts to add a CORS origin
Protip
Note: When deploying the site to your hosting, you must:
- Configure these environment variables
- Add a CORS origin to your Sanity project in sanity.io/manage
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.
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.
Protip
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. First you'll set up your application to fetch content and receive live updates to published documents.
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>
);
}
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 (blog)
folder.
Create a new layout.tsx
file in the (blog)
folder. This includes the SanityLive
component that will subscribe to updates to published documents.
// src/app/(blog)/layout.tsx
import { SanityLive } from "@/sanity/lib/live";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="min-h-screen bg-white">
{children}
<SanityLive />
</div>
);
}
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/live";
import { POSTS_QUERY } from "@/sanity/lib/queries";
export default async function Page() {
const { data: 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:
- Created a new Next.js application
- Created a new Sanity project
- An embedded Sanity Studio in your application at
/studio
- A home page that displays published blog posts queried by
sanityFetch
and kept updated bySanityLive
.
Now, you'll toggle the 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:
- A way to activate (and deactivate) "draft mode" for your production front end, but only for authenticated users
- The Presentation tool for Sanity Studio, where you get the side-by-side view with the front end and the relevant content forms to edit the content in real-time
Querying draft content will require a token. You can create one in Manage, either open it with:
npx sanity manage
Or, from your Studio at http://localhost:3000/studio, click your user icon, and 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"
Gotcha
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 being given to authorized users, and never be included in the code bundle.
Create a file to store and export this token:
// src/sanity/lib/token.ts
export const token = process.env.SANITY_API_READ_TOKEN;
if (!token) {
throw new Error("Missing SANITY_API_READ_TOKEN");
}
Create a new component with a button which, when clicked, will disable draft mode. Useful for authors that wish to get back to seeing published content.
// src/components/DisableDraftMode.tsx
"use client";
import { useDraftModeEnvironment } from "next-sanity/hooks";
export function DisableDraftMode() {
const environment = useDraftModeEnvironment();
// Only show the disable draft mode button when outside of Presentation Tool
if (environment !== "live" && environment !== "unknown") {
return null;
}
return (
<a
href="/api/draft-mode/disable"
className="fixed bottom-4 right-4 bg-gray-50 px-4 py-2"
>
Disable Draft Mode
</a>
);
}
Update the layout.tsx
in the (blog)
folder. Add imports for the VisualEditing
and DisableDraftMode
components and have them conditionally rendered when draftMode
is enabled.
// src/app/(blog)/layout.tsx
import { SanityLive } from "@/sanity/lib/live";
import { DisableDraftMode } from "@/components/DisableDraftMode";
import { VisualEditing } from "next-sanity";
import { draftMode } from "next/headers";
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="bg-white min-h-screen">
{children}
<SanityLive />
{(await draftMode()).isEnabled && (
<>
<DisableDraftMode />
<VisualEditing />
</>
)}
</div>
);
}
The VisualEditing
component handles hydrating the page with draft documents as edits are made. The code example above also adds a button to disable draft mode.
Update the preconfigured Sanity Client to include "stega
" configuration for the clickable overlays.
// src/sanity/lib/client.ts
import { createClient } from "next-sanity";
import { apiVersion, dataset, projectId } from "../env";
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
stega: { studioUrl: "http://localhost:3000/studio" },
});
Update the preconfigured defineLive
function to add the authentication token you created earlier.
// src/sanity/lib/live.ts
import { defineLive } from "next-sanity";
import { client } from "@/sanity/lib/client";
import { token } from "@/sanity/lib/token";
export const { sanityFetch, SanityLive } = defineLive({
client: client.withConfig({apiVersion: "vX"}),
browserToken: token,
serverToken: token,
});
Create a new API route that the Presentation tool will use to activate draft mode.
// src/app/api/draft-mode/enable/route.ts
import { defineEnableDraftMode } from "next-sanity/draft-mode";
import { client } from "@/sanity/lib/client";
import { token } from "@/sanity/lib/token"
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token }),
});
To secure preview mode, the Presentation tool passes a secret from the dataset along in the request. This is all conveniently bundled into the defineEnableDraftMode
function from next-sanity
.
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 async function GET(request: NextRequest) {
(await draftMode()).disable();
return NextResponse.redirect(new URL("/", request.url));
}
Now you have all the pieces assembled to receive edits to 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:
- Both draft and published documents.
- Clickable links on the title of each post to edit that document.
- Real-time changes when editing the title of any post.
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 } from "@/sanity/lib/client";
import { sanityFetch } from "@/sanity/lib/live";
import { Post } from "@/components/Post";
export async function generateStaticParams() {
const posts = await client.fetch(POSTS_QUERY);
return posts.map((post) => ({
slug: post?.slug?.current,
}));
}
export default async function Page({
params,
}: {
params: Promise<QueryParams>;
}) {
const { data: post } = await sanityFetch({
query: POST_QUERY,
params: await 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 Link from "next/link";
import { PortableText } from "@portabletext/react";
import { urlFor } from "@/sanity/lib/image";
import { POST_QUERYResult } from "../../sanity.types";
export function Post({ post }: { post: NonNullable<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 --legacy-peer-deps -D @tailwindcss/typography
Update your tailwind.config.js
file's plugins to include it:
// tailwind.config.ts
import type { Config } from "tailwindcss";
import typography from "@tailwindcss/typography";
const config: Config = {
// ...other settings
plugins: [typography],
}
export default config;
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: {
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
// ..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.
Currently the Overlays support click-to-edit, but additional affordances for rearranging arrays in your front end can be added to your front end while Visual Editing is configured.
Update the post
schema type fields to include an array of "related posts" to render at the bottom of your post
type documents.
// src/sanity/schemaTypes/postType.ts
export const postType = defineType({
// ...all other settings
fields: [
// ...all other fields
defineField({
name: "relatedPosts",
type: "array",
of: [{ type: "reference", to: { type: "post" } }],
}),
],
});
Update your single post query to return the array and resolve any references. You'll also need the _id
and _type
value to create dynamic data attributes on draggable elements.
// src/sanity/lib/queries.ts
// ...other queries
export const POST_QUERY =
defineQuery(`*[_type == "post" && slug.current == $slug][0]{
_id,
_type,
title,
body,
mainImage,
relatedPosts[]{
_key, // required for drag and drop
...@->{_id, title, slug} // get fields from the referenced post
}
}`);
Update your types by extracting the schema again and regenerating types from queries by running the following in the terminal.
npx sanity@latest schema extract && npx sanity@latest typegen generate
Create a new component to render these references.
// src/components/RelatedPosts.tsx
"use client";
import Link from "next/link";
import { createDataAttribute } from "next-sanity";
import { POST_QUERYResult } from "../../sanity.types";
import { client } from "@/sanity/lib/client";
import { useOptimistic } from "next-sanity/hooks";
const { projectId, dataset, stega } = client.config();
export const createDataAttributeConfig = {
projectId,
dataset,
baseUrl: typeof stega.studioUrl === "string" ? stega.studioUrl : "",
};
export function RelatedPosts({
relatedPosts,
documentId,
documentType,
}: {
relatedPosts: NonNullable<POST_QUERYResult>["relatedPosts"];
documentId: string;
documentType: string;
}) {
const posts = useOptimistic<
NonNullable<POST_QUERYResult>["relatedPosts"] | undefined,
NonNullable<POST_QUERYResult>
>(relatedPosts, (state, action) => {
if (action.id === documentId && action?.document?.relatedPosts) {
// Optimistic document only has _ref values, not resolved references
return action.document.relatedPosts.map(
(post) => state?.find((p) => p._key === post._key) ?? post
);
}
return state;
});
if (!posts) {
return null;
}
return (
<aside className="border-t">
<h2>Related Posts</h2>
<div className="not-prose text-balance">
<ul
className="flex flex-col sm:flex-row gap-0.5"
data-sanity={createDataAttribute({
...createDataAttributeConfig,
id: documentId,
type: documentType,
path: "relatedPosts",
}).toString()}
>
{posts.map((post) => (
<li
key={post._key}
className="p-4 bg-blue-50 sm:w-1/3 flex-shrink-0"
data-sanity={createDataAttribute({
...createDataAttributeConfig,
id: documentId,
type: documentType,
path: `relatedPosts[_key=="${post._key}"]`,
}).toString()}
>
<Link href={`/post/${post?.slug?.current}`}>{post.title}</Link>
</li>
))}
</ul>
</div>
</aside>
);
}
Update your Post
component to render the related posts.
// src/components/Post.tsx
import Image from "next/image";
import Link from "next/link";
import { PortableText } from "@portabletext/react";
import { urlFor } from "@/sanity/lib/image";
import { RelatedPosts } from "./RelatedPosts";
import { POST_QUERYResult } from "../../sanity.types";
export function Post({ post }: { post: NonNullable<POST_QUERYResult> }) {
const { _id, _type, title, mainImage, body, relatedPosts } = 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}
{relatedPosts ? (
<RelatedPosts
relatedPosts={relatedPosts}
documentId={_id}
documentType={_type}
/>
) : null}
<hr />
<Link href="/">← Return home</Link>
</main>
);
}
Add a few references to any post
type document and now in Presentation, you should be able to drag and drop them into a different order – whether vertical or horizontal!