👀 See Sanity in action: Watch product demo now →
August 12, 2021

How to setup Sanity CMS with Next.js & TailwindCSS

By Surjith S M

There are many Headless CMS out there, but Sanity CMS is a perfect choice when working with a Next.js & TailwindCSS Project.

Warning

This guide contains code examples for an older version of Sanity Studio (v2), which is deprecated.

Learn how to migrate to the new Studio v3 →

In this article, I will show you how to setup Sanity CMS with Next.js & Tailwind. Please note I'm using Sanity Studio v2 for this guide. I'll create a v3 guide soon.

Setting up Next.js & Tailwind

This is pretty straightforward, also there are many tutorials available. So I won't get deep, but I have also made a starter template that you can use to save time.

Next.js & TailwindCSS Starter Template

The first step is to install Next.js with their bootstrap template called "Create Next App". If you want an in-depth tutorial, visit: Next.js Docs

npx create-next-app 

# or 

yarn create next-app

Now we can install TailwindCSS. This is also easy. Follow the steps below or check out the official docs here: Install TailwindCSS with Next.js

npm install tailwindcss postcss autoprefixer 

# or 

yarn add tailwindcss postcss autoprefixer

Now Generate your Configuration file.

npx tailwindcss init -p

This will create a minimal tailwind.config.js file and postcss.config.js at the root of your project. Make sure you add purge settings to remove unused classes from the production build.

Now add TailwindCSS file eg: /styles/tailwind.css

@tailwind base; 

@tailwind components; 

@tailwind utilities;

Then you can include the CSS in pages/_app.js

That's it! Done! now run the following command to see if everything working. You should be able to see it live on http://localhost:3000

Setting up Sanity CMS

The first step is to install Sanity CLI globally. use the following command to do that.

npm install -g @sanity/cli

Now, go to the root folder of your created next.js app, and run the following command.

sanity init

The above command will walk you through some steps to create / login to an account, creating a project, set up the dataset, generate the files, etc.

Sanity CLI

The only thing to consider is when it asks to choose a folder name, make sure it's in the root folder of next.js and name it as something like studio or admin

Now, the folder will create at the root of Next.js project.

Setup Studio Path (Optional)

If you are using Vercel, you can configure studio path as /studio on production, you have to configure some steps. Create vercel.json and add the following code.

{
    "version": 2,
    "rewrites": [{
        "source": "/studio/(.*)",
        "destination": "/studio/index.html"
    }]
}

So while in development you can use http://localhost:3333 to open the studio and on your production website, you can use yourwebsite.com/studio

Also, make sure you update the basepath in studio/sanity.json so that the dependencies resolve correctly.

 {
   "project": {
    "name": "Your Sanity Project",
    "basePath": "/studio"
  }
 }

Setup CORS

If you did the above step, you must allow CORS origin from the Sanity Project Settings.

Go to: https://manage.sanity.io/projects/{project_id}/settings/api

Project ID can be found in /studio/sanity.json

Now, click on ADD ORIGIN button on the page and add your URL eg: yourwebsite.com & Enable the "Allow Credentials" checkbox.

Allow CORS

Configure Dev Server

The next step is to set up both Sanity & Next.js dev server. Open your package.json and change your scripts like this.

{
  "scripts": {
  "dev": "next dev",
  "prebuild": "echo 'Building Sanity to public/studio' && cd studio && yarn && npx @sanity/cli build ../public/studio -y && echo 'Done'",
  "build": "next build",
  "start": "next start",
  "sanity": "cd studio && sanity start",
  "lint": "next lint"
  }
}

Now, open two terminals in your code editor and try running yarn dev and yarn sanity to run both servers.

The prebuild step will ensure the Sanity Studio will build before building the Next.js while pushing to production.

Add .env for Sanity

You have to add a .env.local file to add the project ID. Use the following text and replace YOUR_PROJECT_ID with your actual project ID.

NEXT_PUBLIC_SANITY_PROJECT_ID=YOUR_PROJECT_ID

NEXT_PUBLIC_ is required by Next.js. Do not remove it. If you have to use this project ID in Sanity Studio, you have to create .env file instead.

Setup Next Sanity Plugin

Now, we need to install few plugins which is called next-sanity and nex-sanity-image These plugins are needed so that we can call the API easily and to render images proeprly.

npm install next-sanity 
npm install next-sanity-image

# or 

yarn add next-sanity
yarn add next-sanity-image

Now, create two files called config.js and sanity.js in /lib folder in the root of our project. These will be communicating with the plugin. (Code taken from the next-sanity repo). No changes need in the below file, Just copy-paste and save.

/lib/config.js

export const config = {
  /**
   * Find your project ID and dataset in `sanity.json` in your studio project.
   * These are considered “public”, but you can use environment variables
   * if you want differ between local dev and production.
   *
   * https://nextjs.org/docs/basic-features/environment-variables
   **/
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production",
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  apiVersion: "2021-08-11", // or today's date for latest
  /**
   * Set useCdn to `false` if your application require the freshest possible
   * data always (potentially slightly slower and a bit more expensive).
   * Authenticated request (like preview) will always bypass the CDN
   **/
  useCdn: process.env.NODE_ENV === "production",
};

/lib/sanity.js

import Image from "next/image";
import {
  createClient,
  createPreviewSubscriptionHook
} from "next-sanity";
import createImageUrlBuilder from "@sanity/image-url";
import { PortableText as PortableTextComponent } from "@portabletext/react";
import { config } from "./config";
import GetImage from "@utils/getImage";
if (!config.projectId) { throw Error( "The Project ID is not set. Check your environment variables." ); } export const urlFor = source => createImageUrlBuilder(config).image(source); export const imageBuilder = source => createImageUrlBuilder(config).image(source); export const usePreviewSubscription = createPreviewSubscriptionHook(config); // Barebones lazy-loaded image component const ImageComponent = ({ value }) => { // const {width, height} = getImageDimensions(value) return ( <Image {...GetImage(value)} blurDataURL={GetImage(value).blurDataURL} objectFit="cover" sizes="(max-width: 800px) 100vw, 800px" alt={value.alt || " "} placeholder="blur" loading="lazy" /> ); }; const components = { types: { image: ImageComponent, code: props => ( <pre data-language={props.node.language}> <code>{props.node.code}</code> </pre> ) }, marks: { center: props => ( <div className="text-center">{props.children}</div> ), highlight: props => ( <span className="font-bold text-brand-primary"> {props.children} </span> ), link: props => ( <a href={props?.value?.href} target="_blank" rel="noopener"> {props.children} </a> ) } }; // Set up Portable Text serialization export const PortableText = props => ( <PortableTextComponent components={components} {...props} /> ); export const client = createClient(config); export const previewClient = createClient({ ...config, useCdn: false }); export const getClient = usePreview => usePreview ? previewClient : client; export default client;

/utils/getImage.js

import client from "@lib/sanity";
import { useNextSanityImage } from "next-sanity-image";

export default function GetImage(image, CustomImageBuilder = null) {
  const imageProps = useNextSanityImage(client, image, {
    imageBuilder: CustomImageBuilder
  });
  if (!image || !image.asset) {
    return null;
  }
  return imageProps;
}

jsconfig.json

Optional. Used for calling paths as @lib / @utils anywhere in our project instead of ../../lib.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@lib/*": [ "./lib/*"],
       "@utils/*": ["./utils/*"],
    }
  }
}

Creating the Schema

Now, open the /studio/schemas/ folder and add a schema. See Sanity Docs

First, create a post.js file inside /schemas folder. The path can be anywhere but make sure you linked them properly in the schema.js file (see below).

/schemas/post.js

import { HiOutlineDocumentAdd } from "react-icons/hi";

export default {
  name: "post",
  title: "Posts",
  icon: HiOutlineDocumentAdd,
  type: "document",
  fields: [
    {
      name: "title",
      title: "Title",
      type: "string",
      validation: Rule => Rule.required()
    },
    {
      name: "slug",
      title: "Slug",
      type: "slug",
      validation: Rule => Rule.required(),
      options: {
        source: "title",
        maxLength: 96
      }
    },
    {
      name: "excerpt",
      description:
        "Write a short pararaph of this post (For SEO Purposes)",
      title: "Excerpt",
      rows: 5,
      type: "text",
      validation: Rule =>
        Rule.max(160).error(
          "SEO descriptions are usually better when its below 160"
        )
    },
    {
      name: "body",
      title: "Body",
      type: "blockContent",
      validation: Rule => Rule.required()
    },
    {
      name: "author",
      title: "Author",
      type: "reference",
      to: { type: "author" },
      validation: Rule => Rule.required()
    },
    {
      name: "mainImage",
      title: "Main image",
      type: "image",
      fields: [
        {
          name: "alt",
          type: "string",
          title: "Alternative text",
          description: "Important for SEO and accessiblity.",
          options: {
            isHighlighted: true
          }
        }
      ],
      options: {
        hotspot: true
      }
    },
    {
      name: "categories",
      title: "Categories",
      type: "array",
      of: [{ type: "reference", to: { type: "category" } }],
      validation: Rule => Rule.required()
    },
    {
      name: "publishedAt",
      title: "Published at",
      type: "datetime"
    }
  ],

  preview: {
    select: {
      title: "title",
      author: "author.name",
      media: "mainImage"
    },
    prepare(selection) {
      const { author } = selection;
      return Object.assign({}, selection, {
        subtitle: author && `by ${author}`
      });
    }
  }
};

The above page gives you an idea of what a schema can look like. There are a lot of customization available. Be sure to check the Official Docs.

In the above, you can see I have referenced author & category, so let's create them as well.

/schemas/author.js

export default {
  name: 'author',
  title: 'Author',
  type: 'document',
  fields: [
    {
      name: 'name',
      title: 'Name',
      type: 'string',
    },
    {
      name: 'slug',
      title: 'Slug',
      type: 'slug',
      options: {
        source: 'name',
        maxLength: 96,
      },
    },
    {
      name: 'image',
      title: 'Image',
      type: 'image',
      options: {
        hotspot: true,
      },
    },
    {
      name: 'bio',
      title: 'Bio',
      type: 'array',
      type: 'blockContent',
    },
  ],
  preview: {
    select: {
      title: 'name',
      media: 'image',
    },
  },
}

/schemas/category.js

export default {
  name: "category",
  title: "Category",
  type: "document",
  fields: [
    {
      name: "title",
      title: "Title",
      type: "string"
    },
    {
      name: "slug",
      title: "Slug",
      type: "slug",
      options: {
        source: "title",
        maxLength: 96
      },
      validation: Rule => Rule.required()
    },
    {
      name: "description",
      title: "Description",
      type: "text"
    }
  ]
};

/schemas/blockContent.js

/**
 * This is the schema definition for the rich text fields used for
 * for this blog studio. When you import it in schemas.js it can be
 * reused in other parts of the studio with:
 *  {
 *    name: 'someName',
 *    title: 'Some title',
 *    type: 'blockContent'
 *  }
 */
export default {
  title: "Block Content",
  name: "blockContent",
  type: "array",
  of: [
    {
      title: "Block",
      type: "block",
      // Styles let you set what your user can mark up blocks with. These
      // correspond with HTML tags, but you can set any title or value
      // you want and decide how you want to deal with it where you want to
      // use your content.
      styles: [
        { title: "Normal", value: "normal" },
        // {title: 'H1', value: 'h1'},
        { title: "H2", value: "h2" },
        { title: "H3", value: "h3" },
        { title: "H4", value: "h4" },
        { title: "Quote", value: "blockquote" }
      ],
      lists: [{ title: "Bullet", value: "bullet" }],
      // Marks let you mark up inline text in the block editor.
      marks: {
        // Decorators usually describe a single property – e.g. a typographic
        // preference or highlighting by editors.
        decorators: [
          { title: "Strong", value: "strong" },
          { title: "Emphasis", value: "em" }
        ],
        // Annotations can be any object structure – e.g. a link or a footnote.
        annotations: [
          {
            title: "URL",
            name: "link",
            type: "object",
            fields: [
              {
                title: "URL",
                name: "href",
                type: "url"
              }
            ]
          }
        ]
      }
    },
    // You can add additional types here. Note that you can't use
    // primitive types such as 'string' and 'number' in the same array
    // as a block type.
    {
      type: "image",
      options: { hotspot: true }
    }
  ]
};

Now open the schemas/schema.js file and make sure to import the post and add it to the schemaTypes.concat Array.

// First, we must import the schema creator
import createSchema from "part:@sanity/base/schema-creator";

// Then import schema types from any plugins that might expose them
import schemaTypes from "all:part:@sanity/base/schema-type";


// We import object and document schemas
import post from "./post";
import author from "./author";
import category from "./category";
import blockContent from "./blockContent";
// Then we give our schema to the builder and provide the result to Sanity export default createSchema({ // We name our schema name: "default", // Then proceed to concatenate our document type // to the ones provided by any plugins that are installed types: schemaTypes.concat([ // The following are document types which will appear
// in the studio.
post,
author,
category,
blockContent
]) });

Don't fret, we only need to import the file and add schema type inside the types, the rest of the code is already there for us. Pretty neat!

Making it Singleton (one-off)

Posts are good for articles, but sometimes you need to make a page for Site Settings or any other pages where you don't need an array. Instead, you only want that setting to appear once.

Sanity doesn't support that by default, but they provide some options to achieve what we want. Psst.. That's the flexibility of this platform.

Skip this step if you don't plan to use a singleton.

1. Create the Schema

export default {
  name: "siteconfig",
  type: "document",
  title: "Site Settings",
__experimental_actions: [
/* "create", "delete", */ "update", "publish"
], fields: [ { name: "title", type: "string", title: "Site title" } // other fields // ... ] }

Did you notice the __experimental_actions part? This is where we disable the "create" and "delete" actions so that that particular file can only be created once.

Now, add the siteconfig to the /schemas/schema.js as well.

// other schema content ...
import siteconfig from "./siteConfig";

export default createSchema({
// ... types: schemaTypes.concat([ // ..., siteconfig ]) });

Protip

Make sure to enable `create` first and add a new document and publish it. Now come back and disable it. Otherwise, you won't be able to create any files.

2. Hide it from the UI

Now we also need to hide the extra view using the deskStructure.

deskStructure.js

import S from "@sanity/desk-tool/structure-builder";
import { HiOutlineCog } from "react-icons/hi";

// Add Schema type to hidden
const hiddenDocTypes = listItem =>
!["siteconfig",].includes(
listItem.getId()
);
// Render a custom UI to view siteconfig & pages // and showing other items except mentioed in the hiddendoctypes export default () => S.list() .title("Content Manager") .items([ S.listItem() .title("Site config") .icon(HiOutlineCog) .child(
S.editor()
.schemaType("siteconfig")
.documentId("siteconfig")
), // Add a visual divider (optional) S.divider(), ...S.documentTypeListItems().filter(hiddenDocTypes) ]);

Read more about Structure Builder on Sanity Docs

Now, we need to add the path to deskStructure in sanity.json. Open the file and add the following lines.

 {
   "parts": [{
      "name": "part:@sanity/base/schema",
      "path": "./schemas/schema"
    },
    {
      "name": "part:@sanity/desk-tool/structure",
      "path": "./deskStructure.js"
    }
  ]
}

That's it, we are good to go now.

Adding Content in Sanity CMS

It's time to add content to our Sanity Database. Run the following commands to start Next.js & Sanity.

# Terminal 1

yarn dev

# Terminal 2

yarn sanity

Then open. http://localhost:3000 and http://localhost:3333

🥳 Our Sanity Studio is live (if followed the steps correctly), Now login to your sanity account (I prefer Github Login). Once logged in, click on our newly created schema and publish it.

Getting the data in Next.js

Now comes the final part, getting sanity content inside our next.js page. for that, there are a few steps.

First, you need to know the query language called groq. That's what sanity is using by default. Also, they do provide an option for graphql. if you want, you can use that as well. But GROQ is so much powerful and works well with Sanity as they are the creator of both.

Here's a sample page to fetch the data.

index.js

import Head from "next/head";
import { useRouter } from "next/router";
import { getClient, usePreviewSubscription } from "@lib/sanity";
import { groq } from "next-sanity";


const query = groq`
*[_type == "post"] | order(_createdAt desc) {
  ..., 
  author->,
  categories[]->
}
`;

export default function Post(props) {
  const { postdata, preview } = props;

  const router = useRouter();

  const { data: posts } = usePreviewSubscription(query, {
    initialData: postdata,
    enabled: preview || router.query.preview !== undefined,
  });
  return (
    <>
      {posts &&
        posts.map((post) => (
          <article>
            <h3 className="text-lg"> {post.title} </h3>
            <p className="mt-3">{post.excerpt}</p>
          </article>
        ))}
    </>
  );
}

export async function getStaticProps({ params, preview = false }) {
  const post = await getClient(preview).fetch(query);

  return {
    props: {
      postdata: post,
      preview,
    },
    revalidate: 10,
  };
}

Explanation

So, here's what we did in the above file.

  1. We imported the required functions from the sanity library.
  2. Imported next/router for detecting ?preview so that we can live preview without publishing.
  3. Fetched postdata and used preview subscription when the user is authenticated
  4. Looped the data to render it on the frontend
  5. Groq query to fetch posts. (More details on the docs)
  6. getStaticProps function to return the data from Sanity.

🥂🥳 Yaaaayyy!!! Now refresh your browser and see your data.

Creating Blog Pages

Let's create an individual page using Next.js & Sanity.

/post/[slug.js]

import Image from "next/image";
import { useRouter } from "next/router";
import client, {
  getClient,
  usePreviewSubscription,
  PortableText,
} from "@lib/sanity";
import ErrorPage from "next/error";
import GetImage from "@utils/getImage";

const singlequery = groq`
*[_type == "post" && slug.current == $slug][0] {
  ...,
  author->,
  categories[]->,
}
`;

const pathquery = groq`
*[_type == "post"] {
  'slug': slug.current,
}
`;


export default function Post(props) {
  const { postdata, preview } = props;

  const router = useRouter();
  const { slug } = router.query;

  const { data: post } = usePreviewSubscription(singlequery, {
    params: { slug: slug },
    initialData: postdata,
    enabled: preview || router.query.preview !== undefined,
  });

  if (!router.isFallback && !post?.slug) {
    return <ErrorPage statusCode={404} />;
  } 

  return (
    <>
      {post && (
        <article className="max-w-screen-md mx-auto ">
          <h1 className="mt-2 mb-3 text-3xl font-semibold tracking-tight text-center lg:leading-snug text-brand-primary lg:text-4xl dark:text-white">
            {post.title}
          </h1>
          <p className="text-gray-800 dark:text-gray-400">
            {post.author.name}
          </p>
          <div className="relative z-0 max-w-screen-lg mx-auto overflow-hidden lg:rounded-lg aspect-video">
            {post?.mainImage && (
              <Image
                {...GetImage(post?.mainImage)}
                placeholder="blur" 
              />
            )}
          </div>
          <div className="mx-auto my-3 prose prose-base dark:prose-invert prose-a:text-blue-500">
            {post.body && <PortableText value={post.body} />}
          </div>
        </article>
      )}
    </>
  );
}

export async function getStaticProps({ params, preview = false }) {
  //console.log(params);
  const post = await getClient(preview).fetch(singlequery, {
    slug: params.slug,
  });

  return {
    props: {
      postdata: { ...post },
      preview,
    },
    revalidate: 10,
  };
}

export async function getStaticPaths() {
  const allPosts = await client.fetch(pathquery);
  return {
    paths:
      allPosts?.map((page) => ({
        params: {
          slug: page.slug,
        },
      })) || [],
    fallback: true,
  };
}

Conclusion

That's it. Now you got a working Next.js project with Sanity and TailwindCSS enabled. Hope this tutorial helps you get started. Don't forget to check out the Sanity Docs for more information & help. They also have some nice starter templates.

If you have any questions or feedback, let me know on Twitter