July 03, 2023 (Updated August 23, 2023)

Live Preview 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 live updates of draft content to preview content in context with absolute confidence to publish.

Protip

The final code for this tutorial is available as a repository.

Gotcha

This guide is for projects using the App router. Go to this guide for projects using Next.js‘ Pages router.

Scope of this guide and possible alternatives

This guide is deliberately focused on the experience of manually creating a new Next.js 13 application and creating a Sanity project with an embedded Studio.

All the instructions below could also be adapted to an existing Next.js application.

  • Want working code faster? Our Next.js starter has an example blog schema and live preview 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 need to remove the types from these examples if working with JavaScript.
  • Embedded Studio is not required. For convenience in this guide you'll embed a Studio inside the Next.js application, but you could do all of 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.

Create a new Next.js 13 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 13 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:

// ./nextjs-app/app/page.tsx

export default function Home() {
  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:

/* ./nextjs-app/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"

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:

# in ./nextjs-app
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.

# ./nextjs-app/.env.local

NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id"
NEXT_PUBLIC_SANITY_DATASET="production"

If this file is named .env, rename to .env.local and ensure it does not get checked into git.

Now visit http://localhost:3000/studio to see your new Sanity project's Studio.

  • You may need to restart your development environment.
  • You also may need to follow the prompts to add a CORS origin and then 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.

Creating a new blog post in Sanity Studio

Fetching data from Sanity

Before you set up Live Preview to see draft content from Sanity, you should confirm you can fetch published content.

Create a new component file to display an array of post documents:

// ./nextjs-app/app/_components/Posts.tsx

import Link from "next/link";
import type { SanityDocument } from "@sanity/client";

export default function Posts({ posts = [] }: { posts: SanityDocument[] }) {
  const title = posts.length === 1 ? `1 Post` : `${posts.length} Posts`;

  return (
    <main className="container mx-auto grid grid-cols-1 divide-y divide-blue-100">
      <h1 className="text-2xl p-4 font-bold">{title}</h1>
      {posts.map((post) => (
        <Link
          key={post._id}
          href={post.slug.current}
          className="p-4 hover:bg-blue-50"
        >
          <h2>{post.title}</h2>
        </Link>
      ))}
    </main>
  );
}

Creating the new Sanity project inside our Next.js application created a Sanity Client inside /nextjs-app/sanity/lib/client.ts

However, to fully benefit from advanced features like draft mode, revalidation and caching we'll need to create a new file that integrates all these features.

Install these additional packages:

npm install suspend-react server-only

Create a new file for this sanityFetch function. For more information, see the next-sanity readme.

import "server-only";

import type { QueryParams } from "@sanity/client";
import { draftMode } from "next/headers";
import { client } from "@/sanity/lib/client";

const DEFAULT_PARAMS = {} as QueryParams;
const DEFAULT_TAGS = [] as string[];

export const token = process.env.SANITY_API_READ_TOKEN;

export async function sanityFetch<QueryResponse>({
  query,
  params = DEFAULT_PARAMS,
  tags = DEFAULT_TAGS,
}: {
  query: string;
  params?: QueryParams;
  tags?: string[];
}): Promise<QueryResponse> {
  const isDraftMode = draftMode().isEnabled;
  if (isDraftMode && !token) {
    throw new Error(
      "The `SANITY_API_READ_TOKEN` environment variable is required."
    );
  }
  const isDevelopment = process.env.NODE_ENV === "development";

  return client
    .withConfig({ useCdn: true })
    .fetch<QueryResponse>(query, params, {
      cache: isDevelopment || isDraftMode ? undefined : "force-cache",
      ...(isDraftMode && {
        token: token,
        perspective: "previewDrafts",
      }),
      next: {
        ...(isDraftMode && { revalidate: 30 }),
        tags,
      },
    });
}

Create a new file to hold all our GROQ queries. If you want to retrieve more fields from each post document, update the queries in this file.

// ./nextjs-app/sanity/lib/queries.ts

import { groq } from "next-sanity";

// Get all posts
export const postsQuery = groq`*[_type == "post" && defined(slug.current)]{
    _id, title, slug
  }`;

// Get a single post by its slug
export const postQuery = groq`*[_type == "post" && slug.current == $slug][0]{ 
    title, mainImage, body
  }`;

// Get all post slugs
export const postPathsQuery = groq`*[_type == "post" && defined(slug.current)][]{
    "params": { "slug": slug.current }
  }`;

Update the index route to query for every published post document that has a valid slug.

// ./nextjs-app/app/page.tsx

import { SanityDocument } from "next-sanity";
import Posts from "@/app/_components/Posts";
import { postsQuery } from "@/sanity/lib/queries";
import { sanityFetch } from "@/sanity/lib/sanityFetch";

export default async function Home() {
  const posts = await sanityFetch<SanityDocument[]>({ query: postsQuery });

  return <Posts posts={posts} />;
}

Your home page at http://localhost:3000 should now look like this:

Content queried from a Sanity dataset and displayed in a Next.js application

If so, great … until you click on one of those links.

You can fix that 404 by adding another route.

Display individual posts

Install @portabletext/react to render the portable text field onto the page

npm i @portabletext/react@latest 

Create a new component to display the post:

// ./nextjs-app/app/_components/Post.tsx

"use client";

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

const builder = imageUrlBuilder(client);

export default function Post({ post }: { post: SanityDocument }) {
  return (
    <main className="container mx-auto prose prose-lg p-4">
      <h1>{post.title}</h1>
      {post?.mainImage ? (
        <Image
          className="float-left m-0 w-1/3 mr-4 rounded-lg"
          src={builder.image(post.mainImage).width(300).height(300).url()}
          width={300}
          height={300}
          alt={post?.mainImage?.alt}
        />
      ) : null}
      {post?.body ? <PortableText value={post.body} /> : null}
    </main>
  );
}

Create a new route to query for the post by its slug:

// ./nextjs-app/app/[slug]/page.tsx

import { SanityDocument } from "@sanity/client";
import Post from "@/app/_components/Post";
import { postPathsQuery, postQuery } from "@/sanity/lib/queries";
import { sanityFetch } from "@/sanity/lib/sanityFetch";
import { client } from "@/sanity/lib/client";

// Prepare Next.js to know which routes already exist
export async function generateStaticParams() {
  // Important, use the plain Sanity Client here
  const posts = await client.fetch(postPathsQuery);

  return posts;
}

export default async function Page({ params }: { params: any }) {
  const post = await sanityFetch<SanityDocument>({ query: postQuery, params });

  return <Post post={post} />;
}

Displaying images with next/image

Update next.config.ts so that next/image will load images from the Sanity CDN:

// ./nextjs-app/next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.sanity.io',
      },
    ],
  },
  // ...other config settings
};

module.exports = nextConfig;

Without doing this, Next.js will throw an error.

Displaying rich text with @tailwindcss/typography

The Portable Text field in 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:

// ./nextjs-app/tailwind.config.js

module.exports = {
  // ...other settings
  plugins: [require('@tailwindcss/typography')],
}

This package styles the prose class names in the <Post /> component.

Viewing a single post

You should now be able to click links on the home page and see pages just like these:

Our Next.js application can now display a Sanity image and block content!

You should now have the following:

  • A working Sanity Studio with some published content
  • A Next.js application with a home page that lists published posts of content – and those individual pages show rich text and an image.

And that's great, but it gets even better!

Let’s see changes made in Sanity Studio on the Next.js website.

Setup preview functionality

next-sanity has already been installed and is a collection of utilities, including @sanity/preview-kit, which powers live-as-you-type preview. To

Add API routes to Next.js

Create two new API Routes to enter and exit draft mode

// ./nextjs-app/app/api/preview/route.ts

import { draftMode } from "next/headers";
import { redirect } from "next/navigation";

export async function GET() {
  draftMode().enable();
  redirect(`/`);
}
// ./nextjs-app/app/api/exit-preview/route.ts

import { draftMode } from "next/headers";
import { redirect } from "next/navigation";

export async function GET() {
  draftMode().disable();
  redirect(`/`);
}

Add a read token

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

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

Open your .env file and add the token on a new line as SANITY_API_READ_TOKEN

# ./nextjs-app/.env
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 to it 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 being encrypted in the browser of any user that views the site in preview mode.

Prepare your Preview utilities

Create a <PreviewProvider /> component so that any child component can benefit from preview mode in the browser.

// ./nextjs-app/app/_components/PreviewProvider.tsx

"use client";

import dynamic from "next/dynamic";
import { suspend } from "suspend-react";

const LiveQueryProvider = dynamic(() => import("next-sanity/preview"));

// suspend-react cache is global, so we use a unique key to avoid collisions
const UniqueKey = Symbol("../../sanity/lib/client");

export default function PreviewProvider({
  children,
  token,
}: {
  children: React.ReactNode;
  token: string;
}) {
  const { client } = suspend(
    () => import("../../sanity/lib/client"),
    [UniqueKey]
  );
  if (!token) {
    throw new TypeError("Missing token");
  }
  return (
    <LiveQueryProvider
      client={client}
      token={token}
      // Uncomment below to see debug reports
      // logger={console}
    >
      {children}
    </LiveQueryProvider>
  );
}

Home page previews

Create a component that performs the live-as-you-type data fetching in the browser. In this instance, for the home page's list of posts.

It takes the initial server-side fetch of preview content as initial data and then replaces it with updated preview content as it receives changes.

// ./nextjs-app/app/_components/PreviewPosts.tsx

"use client";

import type { SanityDocument } from "@sanity/client";
import { useLiveQuery } from "@sanity/preview-kit";
import Posts from "@/app/_components/Posts";
import { postsQuery } from "@/sanity/lib/queries";

export default function PreviewPosts({
  posts = [],
}: {
  posts: SanityDocument[];
}) {
  const [data] = useLiveQuery(posts, postsQuery);

  return <Posts posts={data} />;
}

Lastly, update your home page route to check for the existence of a preview token – and if found – fetch preview content on the server, and wrap the front end's components in the PreviewProvider.

// ./nextjs-app/app/page.tsx

import { SanityDocument } from "next-sanity";
import Posts from "@/app/_components/Posts";
import { postsQuery } from "@/sanity/lib/queries";
import { sanityFetch, token } from "@/sanity/lib/sanityFetch";
import { draftMode } from "next/headers";
import PreviewPosts from "@/app/_components/PreviewPosts";
import PreviewProvider from "@/app/_components/PreviewProvider";

export default async function Home() {
  const posts = await sanityFetch<SanityDocument[]>({ query: postsQuery });
  const isDraftMode = draftMode().isEnabled;

  if (isDraftMode && token) {
    return (
      <PreviewProvider token={token}>
        <PreviewPosts posts={posts} />
      </PreviewProvider>
    );
  }

  return <Posts posts={posts} />;
}

All done! Let's unpack how all this works:

  1. Any visitor that navigates to localhost:3000/api/preview will be redirected to the home page, with the site put into “Preview Mode” with a viewer token saved to user's browser.
  2. That token – if present – is used in getStaticProps to fetch static draft content at the time of the page visit.
  3. The previewToken is passed to the PreviewProvider , which allows useLiveQuery() to fetch live draft content as changes are streamed to the browser.

Try it out! Visit your home page at http://localhost:3000, and not only will you see draft documents listed along with published documents – you should see any changes made in your Studio appear in your Next.js application simultaneously!

Neat!

Now add the same live preview capability to the individual post pages.

Post page previews

Create a component to wrap the Post component with a live query.

// ./nextjs-app/app/components/PreviewPost.tsx

"use client";

import { useParams } from 'next/navigation'
import type { SanityDocument } from "@sanity/client";
import { useLiveQuery } from "@sanity/preview-kit";
import { postQuery } from '@/sanity/lib/queries';
import Post from "@/app/_components/Post";

export default function PreviewPost({ post }: { post: SanityDocument }) {
  const params = useParams();
  const [data] = useLiveQuery(post, postQuery, params);

  return <Post post={data} />;
}

Update the [slug] route to perform a fetch for preview content when in preview mode and pass the token down to the browser:

// ./nextjs-app/app/[slug]/page.tsx

import { SanityDocument } from "@sanity/client";
import { draftMode } from "next/headers";
import Post from "@/app/_components/Post";
import { postPathsQuery, postQuery } from "@/sanity/lib/queries";
import { sanityFetch, token } from "@/sanity/lib/sanityFetch";
import { client } from "@/sanity/lib/client";
import PreviewProvider from "@/app/_components/PreviewProvider";
import PreviewPost from "@/app/_components/PreviewPost";

// Prepare Next.js to know which routes already exist
export async function generateStaticParams() {
  // Important, use the plain Sanity Client here
  const posts = await client.fetch(postPathsQuery);

  return posts;
}

export default async function Page({ params }: { params: any }) {
  const post = await sanityFetch<SanityDocument>({ query: postQuery, params });
  const isDraftMode = draftMode().isEnabled;

  if (isDraftMode && token) {
    return (
      <PreviewProvider token={token}>
        <PreviewPost post={post} />
      </PreviewProvider>
    );
  }

  return <Post post={post} />;
}

You should now be able to see draft content in the home and individual post pages – as well as changes as they happen, streamed to the browser!

View entire pages before publishing with the Next.js application in preview mode

This is great! But it could be even better.

Previewing Next.js from inside the Studio

Your setup works excellently so far, but putting these two browser windows side-by-side is more work than it needs to be. Using view panes in the Studio, you can embed the Next.js website into an iframe beside the document form editor.

Install the Iframe Pane plugin.

npm i sanity-plugin-iframe-pane

Create a new file to handle the "Default Document Node", which defines the default settings when the editor for a particular schema type is loaded.

// ./nextjs-app/sanity/desk/defaultDocumentNode.ts

import {DefaultDocumentNodeResolver} from 'sanity/desk'
import Iframe from 'sanity-plugin-iframe-pane'

export const defaultDocumentNode: DefaultDocumentNodeResolver = (S, {schemaType}) => {
  switch (schemaType) {
    case `post`:
      return S.document().views([
        S.view.form(),
        S.view
          .component(Iframe)
          .options({
            url: `http://localhost:3000/api/preview`,
          })
          .title('Preview'),
      ])
    default:
      return S.document().views([S.view.form()])
  }
}

Now import this into the deskTool() plugin in your Studio config file:

// ./nextjs-app/sanity.config.ts

// ...other imports
import { defaultDocumentNode } from './sanity/lib/defaultDocumentNode'

export default defineConfig({
  // ...other config settings
  plugins: [
    deskTool({ defaultDocumentNode }),
    // ...other plugins
  ],
})

Open up any Post-type document now, and you should be able to show the Next.js website side-by-side with the editor and see draft content in real-time.

Iframe pane can display the Next.js Application directly inside the Studio

Next steps

Write more resilient components by checking existing values before trying to display them. Assume no data exists because the author is previewing a new document. Swap for placeholders or remove the components altogether. For example:

// In preview mode, this title might not yet exist!
<h1>{post.title}</h1>

// Swap with a placeholder
{post?.title ? <h1>{post.title}</h1> : <h1>Untitled</h1>}

// Or just remove the component
{post?.title ? <h1>{post.title}</h1> : null}

Update the options for your Iframe Pane to a function that will render a different URL based on the current document, valid for if you update your Next.js preview route to redirect a URL other than the home page.

.options({
  URL: (doc) => doc?.slug?.current 
    ? `http://localhost:3000/api/preview?slug=${doc.slug.current}`
    : `http://localhost:3000/api/preview`,
})

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