👀 Our most exciting product launch yet 🚀 Join us May 8th for Sanity Connect
Last updated April 19, 2024

Visual Editing with Next.js App Router and Sanity Studio

Official(made by Sanity team)

By Simeon Griggs & Knut Melvær

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

Scope of this guide and possible alternatives

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.

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

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@latest nextjs-app --typescript --tailwind --eslint --app --no-src-dir --import-alias="@/*"

# enter the new project's directory
cd 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 home page of a new Next.js 14 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:

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

/* ./app/globals.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

Now, our Next.js app at http://localhost:3000 should look much simpler:

A blank webpage other than the words "Populate me with Sanity Content"

Update Next.js confg

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;

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

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 and log in.

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

Create a viewer token

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.

User menu inside Sanity Studio with "Manage project" highlighted

Navigate to the API tab, and under Tokens, add a new token. Give it viewer permissions and save.

Creating a new token with "Viewer" permissions

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

Create a file to store, protect, and export this token:

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

Loading data from Sanity

Querying content from the Content Lake into your application and deeply integrating it with your front end is simplified with the tooling that comes with the Next.js toolkit for Sanity. See the documentation for more details about the benefits and application of Loaders.

Install the next-sanity package into your Next.js application:

npm install next-sanity

Update the file that was created for you to prepare a Sanity client.

// ./sanity/lib/client.ts

import { createClient } from "next-sanity";

import { apiVersion, dataset, projectId, useCdn } from "../env";

export const client = createClient({
  apiVersion,
  dataset,
  projectId,
  useCdn,
  // These settings will be overridden in 
  // ./sanity/lib/store.ts when draftMode is enabled
perspective: "published",
stega: {
enabled: false,
studioUrl: "/studio",
},
});

Create a new file for setting up the content fetching and add the logic for fetching draft content in the preview mode and only the published content in production:

// ./sanity/lib/fetch.ts

import type { ClientPerspective, QueryParams } from "next-sanity";
import { draftMode } from "next/headers";

import { client } from "./client";
import { token } from "./token";

/**
 * Used to fetch data in Server Components, it has built in support for handling Draft Mode and perspectives.
 * When using the "published" perspective then time-based revalidation is used, set to match the time-to-live on Sanity's API CDN (60 seconds)
 * and will also fetch from the CDN.
 * When using the "previewDrafts" perspective then the data is fetched from the live API and isn't cached, it will also fetch draft content that isn't published yet.
 */
export async function sanityFetch<QueryResponse>({
  query,
  params = {},
  perspective = draftMode().isEnabled ? "previewDrafts" : "published",
  /**
   * Stega embedded Content Source Maps are used by Visual Editing by both the Sanity Presentation Tool and Vercel Visual Editing.
   * The Sanity Presentation Tool will enable Draft Mode when loading up the live preview, and we use it as a signal for when to embed source maps.
   * When outside of the Sanity Studio we also support the Vercel Toolbar Visual Editing feature, which is only enabled in production when it's a Vercel Preview Deployment.
   */
  stega = perspective === "previewDrafts" ||
    process.env.VERCEL_ENV === "preview",
}: {
  query: string;
  params?: QueryParams;
  perspective?: Omit<ClientPerspective, "raw">;
  stega?: boolean;
}) {
  if (perspective === "previewDrafts") {
    return client.fetch<QueryResponse>(query, params, {
      stega,
      perspective: "previewDrafts",
      // The token is required to fetch draft content
      token,
      // The `previewDrafts` perspective isn't available on the API CDN
      useCdn: false,
      // And we can't cache the responses as it would slow down the live preview experience
      next: { revalidate: 0 },
    });
  }
  return client.fetch<QueryResponse>(query, params, {
    stega,
    perspective: "published",
    // The `published` perspective is available on the API CDN
    useCdn: true,
    // Only enable Stega in production if it's a Vercel Preview Deployment, as the Vercel Toolbar supports Visual Editing
    // When using the `published` perspective we use time-based revalidation to match the time-to-live on Sanity's API CDN (60 seconds)
    next: { revalidate: 60 },
  });
}

Here, you're preparing the sanityFetch function with the necessary configuration depending on whether your site is in or out of "preview mode."

Create a new file to store the GROQ queries you'll use in the Next.js application:

// ./sanity/lib/queries.ts

import { groq } from "next-sanity";

export const POSTS_QUERY = groq`*[_type == "post" && defined(slug)]`;

export const POST_QUERY = groq`*[_type == "post" && slug.current == $slug][0]`;

Create a new file with a component that will render all of your posts on the home page:

// ./components/Posts.tsx

import { SanityDocument } from "next-sanity";
import Link from "next/link";

export default function Posts({ posts }: { posts: SanityDocument[] }) {
  return (
    <main className="container mx-auto grid grid-cols-1 divide-y divide-blue-100">
      {posts?.length > 0 ? (
        posts.map((post) => (
          <Link
            key={post._id}
            href={post.slug.current}
          >
            <h2 className="p-4 hover:bg-blue-50">{post.title}</h2>
          </Link>
        ))
      ) : (
        <div className="p-4 text-red-500">No posts found</div>
      )}
    </main>
  );
}

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:

// ./app/(blog)/page.tsx

import { SanityDocument } from "next-sanity"

import Posts from "@/components/Posts"
import { sanityFetch } from "@/sanity/lib/fetch"
import { POSTS_QUERY } from "@/sanity/lib/queries"

export default async function Page() {
  const posts = await sanityFetch<SanityDocument[]>({
    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

Gotcha

To keep this guide short, it intentionally skips over some fine-grained configuration steps. You may wish to set revalidation tags or custom caching controls for your fetches. See the Next.js Personal Website template for a more advanced Loaders configuration.

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 fetched by
  • A configured Sanity Client and Fetch function

Now, you'll combine 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" (or "preview 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

Enabling draft mode

Move the app/layout.tsx file into the (blog) folder.

Import the VisualEditing component into layout.tsx and have it conditionally render when draftMode is enabled:

// ./app/(blog)/layout.tsx
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "../globals.css"
import { VisualEditing } from "next-sanity"
import { draftMode } from "next/headers"
const inter = Inter({ subsets: ["latin"] }) export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", } export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( <html lang="en"> <body className={inter.className}>
{draftMode().isEnabled && (
<div>
<a className="p-4 bg-blue-300 block" href="/api/disable-draft">
Disable preview mode
</a>
</div>
)}
<main>{children}</main>
{draftMode().isEnabled && <VisualEditing />}
</body> </html> ) }

Install the @sanity/preview-url-secret package so you can authenticate that the request that enables draft mode comes from an authenticated user on your Sanity project:

npm i @sanity/preview-url-secret

Create a new API route that the Presentation tool will use to activate draft mode:

// ./app/api/draft/route.ts

import { validatePreviewUrl } from "@sanity/preview-url-secret";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";

import { client } from "@/sanity/lib/client";
import { token } from "@/sanity/lib/token";

const clientWithToken = client.withConfig({ token });

export async function GET(request: Request) {
  const { isValid, redirectTo = "/" } = await validatePreviewUrl(
    clientWithToken,
    request.url
  );

  if (!isValid) {
    return new Response("Invalid secret", { status: 401 });
  }

  draftMode().enable();

  redirect(redirectTo);
}

The Presentation tool inside your Studio will visit this route with a secret. Your Next.js app will validate that secret and activate draft mode.

Create another API route to disable draft mode:

// ./app/api/disable-draft/route.ts

import { draftMode } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'

export function GET(request: NextRequest) {
  draftMode().disable()
  const url = new URL(request.nextUrl)
  return NextResponse.redirect(new URL('/', url.origin))
}

In this example, we added a visual banner with a link to disable preview mode. It's also possible to wrap the LivePreview component and do this automatically for all contexts outside the Presentation tool:

// ./components/AutomaticVisualEditing.tsx

'use client'

import { VisualEditing } from 'next-sanity'
import { useEffect } from 'react'

export function AutomaticVisualEditing() {
  useEffect(() => {
    // If not an iframe or a Vercel Preview deployment, turn off Draft Mode
    if (process.env.NEXT_PUBLIC_VERCEL_ENV !== 'preview' && window === parent) {
      location.href = '/api/disable-draft'
    }
  }, [])

  return <VisualEditing />
}

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.

Configure Presentation

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: {
draftMode: {
enable: '/api/draft',
},
},
}),
], })

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

Presentation tool showing a list of blog posts

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)/[slug]/page.tsx

import { QueryParams, SanityDocument } from "next-sanity"
import { notFound } from "next/navigation"

import { POSTS_QUERY, POST_QUERY } from "@/sanity/lib/queries"
import Post from "@/components/Post"

import { sanityFetch } from "@/sanity/lib/fetch"


export async function generateStaticParams() {
  const posts = await sanityFetch<SanityDocument[]>({
    query: POSTS_QUERY,
    perspective: "published",
    stega: false,
  })

  return posts.map((post) => ({
    slug: post.slug.current,
  }))
}

export default async function Page({ params }: { params: QueryParams }) {
  const post = await sanityFetch<SanityDocument>({ query: POST_QUERY, params })
  if (!post) {
    return notFound()
  }
  return <Post post={post} />
}

Create a component to display a single post:

// ./components/Post.tsx

import Image from "next/image"
import { PortableText } from "@portabletext/react"
import imageUrlBuilder from "@sanity/image-url"
import { SanityDocument } from "next-sanity"

import { dataset, projectId } from "@/sanity/env"

const urlFor = (source: any) =>
  imageUrlBuilder({ projectId, dataset }).image(source)

export default function Post({ post }: { post: SanityDocument }) {
  const { title, mainImage, body } = post

  return (
    <main className="container mx-auto prose prose-lg p-4">
      {title ? <h1>{title}</h1> : null}
      {mainImage ? (
        <Image
          className="float-left m-0 w-1/3 mr-4 rounded-lg"
          src={urlFor(mainImage).width(300).height(300).quality(80).url()}
          width={300}
          height={300}
          alt={mainImage.alt || ""}
        />
      ) : null}
      {body ? <PortableText value={body} /> : null}
    </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 -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!

Editing a single post page in Presentation with Visual Editing

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.

To enable real-time updates inside of these affordances, install the rxjs dependency:

npm install rxjs

Create a new file for the locate function

// ./sanity/presentation/locate.ts

import { DocumentLocationResolver } from "sanity/presentation";
import { map } from "rxjs";

// Pass 'context' as the second argument
export const locate: DocumentLocationResolver = (params, context) => {
  // Set up locations for post documents
  if (params.type === "post") {
    // Subscribe to the latest slug and title
    const doc$ = context.documentStore.listenQuery(
      `*[_id == $id][0]{slug,title}`,
      params,
      { perspective: "previewDrafts" } // returns a draft article if it exists
    );
    // Return a streaming list of locations
    return doc$.pipe(
      map((doc) => {
        // If the document doesn't exist or have a slug, return null
        if (!doc || !doc.slug?.current) {
          return null;
        }
        return {
          locations: [
            {
              title: doc.title || "Untitled",
              href: `/${doc.slug.current}`,
            },
            {
              title: "Posts",
              href: "/",
            },
          ],
        };
      })
    );
  }
  return null;
}

Update your sanity.config.ts file to import the locate function into the Presentation plugin.

// ./sanity.config.ts

// Add this import
import { locate } from '@/sanity/presentation/locate'
export default defineConfig({ // ...all other settings plugins: [ presentationTool({
locate,
previewUrl: { draftMode: { enable: '/api/draft', }, }, }), // ..all other plugins ], })

You should now see the locations at the top of all post type documents:

Presentation showing locations of the page in use

And that's it! You can now follow these patterns when you extend your blog with category and author pages.

Sanity – build remarkable experiences at scale

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.

Other guides by authors

Create richer array item previews

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

Simeon Griggs
Go to Create richer array item previews