Unlock seamless workflows and faster delivery with our latest releases - Join the deep dive

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.

You'll setup a basic blog, with Visual Editing and live preview inside Presentation

Video walkthrough

Watch Simeon work through this guide to setup the entire project from start to finish.

Scope of this guide and possible alternatives

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

Assumptions

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

Glossary

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 Next.js project

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:

> [email protected] 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 home page of a new Next.js 15 project

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 Next.js confg

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;

Create a new Sanity project

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

Fixing package install errors

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

Sanity config files

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.

Open your Studio

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.

A list of new blog posts in Sanity Studio

Write GROQ queries

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
}`);

Generate TypeScript types

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.

Query Sanity content

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.

Your updated home page route displaying published blog posts

Summary so far

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 by SanityLive.

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.

Enabling Visual Editing

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

Create a viewer token

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");
}

Disabling draft mode

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>
  );
}

Enabling draft mode

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.

Configure Presentation

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!

Create single post pages

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="/">&larr; Return home</Link>
    </main>
  );
}

Displaying rich text with @tailwindcss/typography

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!

Configuring document locations in the Studio

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.

Drag-and-drop for arrays

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.

Add "related posts" to your content

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="/">&larr; 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!

Was this article helpful?