August 26, 2021 (Updated June 30, 2023)

Live Preview with Next.js Pages Router and Sanity Studio

By Simeon Griggs & Knut Melvær

Live-as-you-type previewing of draft content is the ultimate upgrade for your content creators so that they can see changes in real-time and gain increased confidence to hit that publish button.

Composable content management is a powerful tool. Content separated from its presentation is the right way to think about content modeling. However, confidence can suffer when the edited content lacks the context of how those edits will affect real-world outputs.

Protip

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

Gotcha

This guide is for projects using the Pages router. Go to this guide for projects using Next.js‘ App 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 inside.

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.
  • Interested in using Next.js’ app directory? This guide will only cover the pages directory approach. There is a similar tutorial using the app router.
  • 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-pages --typescript --tailwind --eslint --src-dir --import-alias="@/*"

> Use App Router?
No

# enter the new project's directory
cd nextjs-pages

# 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-pages/src/pages/index.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-pages/src/styles/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-pages directory you created for your Next.js application and follow the prompts:

# in ./nextjs-pages
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? 
No

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

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

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-pages/src/components/Posts.tsx

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

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

  return (
    <>
      <Head>
        <title>{title}</title>
      </Head>
      <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>
    </>
  );
}

Update the index route to include a getStaticProps function. In it, you’ll import the Sanity Client to query for every published post document that has a valid slug.

// ./nextjs-pages/src/pages/index.tsx

import { groq } from "next-sanity";
import type { SanityDocument } from "@sanity/client";
import Posts from "@/components/Posts";
import { client } from "../../sanity/lib/client";

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

export const getStaticProps = async () => {
  const data = await client.fetch(postsQuery);

  return { props: { data } };
};

export default function Home({ data }: { data: SanityDocument[] }) {
  return <Posts posts={data} />
}

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-pages/src/components/Post.tsx

import Image from "next/image";
import Head from "next/head";
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 (
    <>
      <Head>
        <title>{post.title}</title>
      </Head>
      <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>
    </>
  );
}

Then, create a new route to query for the post by its slug:

// ./nextjs-pages/src/pages/[slug].tsx

import { SanityDocument } from "@sanity/client";
import { GetStaticPaths, GetStaticProps } from "next";
import { groq } from "next-sanity";
import { client } from "../../sanity/lib/client";
import Post from "@/components/Post";

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

// Prepare Next.js to know which routes already exist
export const getStaticPaths: GetStaticPaths = async () => {
  const paths = await client.fetch(
    groq`*[_type == "post" && defined(slug.current)][]{
      "params": { "slug": slug.current }
    }`
  );

  return { paths, fallback: true };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const queryParams = { slug: params?.slug ?? `` };

  const post = await client.fetch(postQuery, queryParams);

  return {
    props: {
      data: { post },
    },
  };
};

export default function Page({ data }: { data: { post: SanityDocument } }) {
  return <Post post={data.post} />
}

Displaying images with next/image

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

// ./nextjs-pages/next.config.ts

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {domains: ['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-pages/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-pages/src/pages/api/preview.ts

import type { NextApiRequest, NextApiResponse } from 'next'

export default function preview(req: NextApiRequest, res: NextApiResponse) {
    res.setDraftMode({ enable: true })
    res.writeHead(307, { Location: '/' })
    res.end()
}
// ./nextjs-pages/src/pages/api/exit-preview.ts

import type { NextApiRequest, NextApiResponse } from 'next'

export default function exit(req: NextApiRequest, res: NextApiResponse) {
    res.setDraftMode({ enable: true })
    res.writeHead(307, { Location: '/' })
    res.end()
}

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_READ_TOKEN

# ./nextjs-pages/.env
NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id"
NEXT_PUBLIC_SANITY_DATASET="production"

# 👇 add this line
SANITY_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 getClient function so that fetches for content are performed with a differently configured Sanity client, depending on whether preview mode is active or not.

This helps us fetch the initial preview content server-side.

// ./nextjs-pages/sanity/lib/getClient.ts

import { createClient } from "@sanity/client";
import type { SanityClient } from "@sanity/client";

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

export function getClient(previewToken?: string): SanityClient {
  const client = createClient({
    projectId,
    dataset,
    apiVersion,
    useCdn,
  });

  return previewToken
    ? client.withConfig({
        token: previewToken,
        useCdn: false,
        ignoreBrowserTokenWarning: true,
        perspective: 'previewDrafts'
      })
    : client;
}

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

// ./nextjs-pages/src/components/PreviewProvider.tsx

import { LiveQueryProvider } from "@sanity/preview-kit";
import { useMemo } from "react";
import { getClient } from "../../sanity/lib/getClient";

export default function PreviewProvider({
  children,
  previewToken,
}: {
  children: React.ReactNode;
  previewToken: string;
}) {
  const client = useMemo(() => getClient(previewToken), [previewToken]);
  return <LiveQueryProvider client={client}>{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-pages/src/components/PreviewPosts.tsx

import type { SanityDocument } from "@sanity/client";
import { useLiveQuery } from '@sanity/preview-kit'
import { postsQuery } from "@/pages";
import Posts from "./Posts";

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-pages/src/pages/index.tsx

import { groq } from "next-sanity";
import type { SanityDocument } from "@sanity/client";
import Posts from "@/components/Posts";
import { getClient } from "../../sanity/lib/getClient";
import dynamic from "next/dynamic";
import PreviewPosts from "@/components/PreviewPosts";
import { GetStaticProps } from "next";

const PreviewProvider = dynamic(
  () => import("@/components/PreviewProvider")
);

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

export const getStaticProps: GetStaticProps = async (context) => {
  const preview = context.draftMode || false;
  const previewToken = preview ? process.env.SANITY_READ_TOKEN : ``;
  if (preview && !previewToken) {
    throw new Error(`Preview mode is active, but SANITY_READ_TOKEN is not set in environment variables`);
  }
  const client = getClient(previewToken);

  const data = await client.fetch(postsQuery);

  return { props: { data, preview, previewToken } };
};

export default function Home({
  data,
  preview,
  previewToken,
}: {
  data: SanityDocument[];
  preview: boolean;
  previewToken?: string;
}) {
  if (preview && previewToken) {
    return (
      <PreviewProvider previewToken={previewToken}>
        <PreviewPosts posts={data} />
        <div className="prose prose-blue p-8">
          <a href="/api/exit-preview">
            Exit preview
          </a>
        </div>
      </PreviewProvider>
    );
  }

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

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 the 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-pages/src/components/PreviewPost.tsx

import { useRouter } from "next/router";
import type { SanityDocument } from "@sanity/client";
import { useLiveQuery } from "@sanity/preview-kit";
import { postQuery } from "@/pages/[slug]";
import Post from "./Post";

export default function PreviewPost({ post }: { post: SanityDocument }) {
  const params = useRouter().query;
  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-pages/src/pages/[slug].tsx

import { SanityDocument } from "@sanity/client";
import { GetStaticPaths, GetStaticProps } from "next";
import { groq } from "next-sanity";
import { client } from "../../sanity/lib/client";
import Post from "@/components/Post";
import dynamic from "next/dynamic";
import PreviewPost from "@/components/PreviewPost";
import { getClient } from "../../sanity/lib/getClient";

const PreviewProvider = dynamic(() => import("@/components/PreviewProvider"));
export const postQuery = groq`*[_type == "post" && slug.current == $slug][0]{ 
  title, mainImage, body
}`;

// Prepare Next.js to know which routes already exist
export const getStaticPaths: GetStaticPaths = async () => {
  const paths = await client.fetch(
    groq`*[_type == "post" && defined(slug.current)][]{
      "params": { "slug": slug.current }
    }`
  );

  return { paths, fallback: true };
};

export const getStaticProps: GetStaticProps = async (context) => {
  const preview = context.draftMode || false;
  const previewToken = preview ? process.env.SANITY_READ_TOKEN : ``;
  const client = getClient(previewToken);

  const data = await client.fetch(postQuery, context.params);

  return { props: { data, preview, previewToken } };
};

export default function Page({
  data,
  preview,
  previewToken,
}: {
  data: SanityDocument;
  preview: boolean;
  previewToken?: string;
}) {
  if (preview && previewToken) {
    return (
      <PreviewProvider previewToken={previewToken}>
        <PreviewPost post={data} />
        <div className="prose prose-lg px-4 prose-blue clear-both py-16 mx-auto">
          <a href="/api/exit-preview">Exit preview</a>
        </div>
      </PreviewProvider>
    );
  }

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

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