Unlock seamless workflows and faster delivery with our latest releases – get the details

Tutorial: Make a blog with Next.js, React, and Sanity

Sometimes you just need a blog. So why not build it with something shiny like Sanity Headless CMS, React, and Next.js?

Updated

  • Knut Melvær

    Knut Melvær

    Head of Developer Community and Education

  • Hidde de Vries

    Hidde de Vries

    Developer Relations Specialist at Sanity.io

Sometimes you just need a blog. While there are loads of dedicated blogging platforms, there might be good reasons for having your blog content live along with your other content. Be it documentation (as in our case), products, a portfolio, or what have you. The content model, or the data schema, for a blog, is also an easy place to get started with making something headless with Sanity and a detached frontend.

In this tutorial, we'll make a blog with Sanity as the content backend and the React-based framework Next.js for rendering web pages.

If you don't feel like typing all the below, you can also:

0. Create a project folder and a monorepo

In this project, you will have two separate web apps:

  1. Sanity Studio – a React app that connects to the hosted API with all your blog content
  2. The blog frontend - a website built with Next.js

It can be useful to keep these codebases in the same folder and git repository (but you don't have to), so you'd have a studio folder and a frontend folder.

You can also place .gitignore, .editorconfig, or other config files in the root, as well as a README.md. If you want to track the project with git, run the command git init in the root folder in your terminal (or add the folder to your Git GUI tool of choice).

1. Install Sanity and the preconfigured blog schemas

We'll start by setting up the Sanity Studio using node package manager (how to install npm). To set up, run:

npm create sanity@latest

You'll be asked to create an account with your Google or Github login, or you can choose to log in with a dedicated email and password. Afterward, you can create a new project, where you'll be asked to choose a project template. Select the blog schema template. First, though, you'll need to give your project and dataset a name (you can add more datasets if you need one for testing) and choose the path to your studio folder. You can also choose if you want to use TypeScript and which package manager to use.

$ Select project to use: Create new project
$ Your project name: sanity-tutorial-blog
$ Use the default dataset configuration? Yes
$ Project output path: ~/Sites/my-blog/studio
$ Select project template: Blog (schema)
$ Do you want to use TypeScript? Yes
$ Package manager to use for installing dependencies? npm

When the installation is done, you run npm run dev inside the studio folder. This launches the Studio on a local development server so you can open it in your browser and start editing your content. This content will be instantly synced to the Content Lake and is available through the public APIs once you hit publish.

By running you'll upload the studio and make it available on the web for those with access (you can add users by navigating to sanity.io/manage.).

There's a lot you can do with the schemas now stored in your studio folder (in the schemas folder), but that's for another tutorial. For now, we just want our blog up and running!

2. Install Next.js and get it running

Now, let's install Next.js. It has a neat setup for making a website with React and can statically generate and revalidate content, as well as lots of other useful features. If you are used to React or have tried out create-react-app, it shouldn't be too hard to get started. There is an excellent tutorial that goes a bit deeper on nextjs.org, but you should be able to tag along with this for now.

In your main project folder, run:

npx create-next-app@latest

We‘ll choose ‘front-end’ as our project name; this will install Next.js in a folder with that name. Otherwise, we'll use the default options:

$ What is your project named? … frontend
$ Would you like to use TypeScript with this project? … Yes
$ Would you like to use ESLint with this project? … Yes
$ Would you like to use `src/` directory with this project? … No
$ Would you like to use experimental `app/` directory with this project? … No 
$ What import alias would you like configured? … @/*

Now your folder structure should look like this:

~/Sites/my-blog 
├── studio
├── frontend

In the frontend folder, the package.json should look similar to this:

{
  "name": "frontend",
  "version": "0.1.0",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@next/font": "13.1.6",
    "@types/node": "18.11.18",
    "@types/react": "18.0.27",
    "@types/react-dom": "18.0.10",
    "eslint": "8.33.0",
    "eslint-config-next": "13.1.6",
    "next": "13.1.6",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "typescript": "4.9.5"
  }
}

Next.js handles routing out of the box based on where you structure files on your filesystem. If you add a folder called pages and add to it index.tsx it will become the front page of your site. Likewise, if you add about.tsx in /pages, this will show up on localhost:3000/about once you spin up the project locally (we're not using the app directory convention as it handles more advanced needs than required here).

The create-next-app will have added a file named index.tsx. Open this file and replace what's there with this simpler “Hello world”:

// index.tsx

const Index = () => {
    return (
      <div>
        <p>Hello world!</p>
      </div>
    )
}

export default Index;

As we won't use it, also remove the existing CSS: delete the styles folder and any references to it, including in _app.tsx. Alternatively you can just delete the styles inside of the file and replace them with your own CSS once you're done with this tutorial.

Now run npm run dev. You should have a greeting to the world if you head to localhost:3000 in your browser.

3. Make a dynamic page template

So far, so good, but now for the interesting part: Let’s fetch some content from Sanity‘s Content Lake and render it with React. Quit the next dev server with ctrl + c. Begin by installing the necessary dependencies in the frontend folder for connecting to the Sanity API: npm install @sanity/client. Create a new file called client.ts in the root frontend folder. Open the file and put in the following:

// client.ts
import sanityClient from '@sanity/client'

export default sanityClient({
  projectId: 'your-project-id', // you can find this in sanity.json
  dataset: 'production', // or the name you chose in step 1
  useCdn: true // `false` if you want to ensure fresh data
})

You can import this client where you want to fetch some content from your Sanity project. The values for projectId and dataset should be the same as those you'll find in your sanity.config.ts file in the studio folder.

Adding a new file for every new blog entry would be impractical. A hassle even. So let's make a page template that allows us to use the URL slugs from Sanity.

Add a post.tsx file to your pages folder as well, it should now look like this:

~/Sites/my-blog/frontend
├── client.tsx
├── package-lock.json
├── package.json
└── pages
    ├── post.tsx
    ├── index.tsx

Open post.tsx and add the following code to it:

// post.tsx
import { useRouter } from 'next/router'

const Post = () => {
  const router = useRouter()
  
  return (
    <article>
      <h1>{router.query.slug}</h1>
    </article>
  )
}

export default Post

If you start your dev server again (npm run dev) and go to localhost:3000/post?slug=whatever you should now see “whatever” printed as an H1 on the page.

Wouldn't it be neat to have prettier URLs? Next.js lets you do clean URLs with dynamic routing. First, we have to add a folder called post inside of our pages folder, move your post.tsx file inside of the post folder and rename it to [slug].tsx – yes, with the square brackets. Now you can go to localhost:3000/post/whatever and see the same result as before: "whatever" printer as an H1.

4. Get some content from Sanity

Let's create some content. In your terminal, return to the studio folder, run npm run dev to start the development server. Now you can open your Studio in the browser (on localhost:3333). Create a post titled "Hello world!" and hit Generate for the slug. Remember to hit the Publish button to make the content available in the public API.

The studio interface showing fields for title, slug, author, main image and categories. In the right bottom corner is a big green Publish button
If you greet the world, the world will eventually greet you back

Next.js comes with a function called getStaticProps that is called and returns props to the React component before rendering the templates in /pages. This is a perfect place for fetching the data you want for a page. You need to use this in tandem with another function called getStaticPaths in order to tell Next.js upfront which posts exist.

We have now set up Next.js with a template for the front page (index.tsx), and a dynamic routing that makes it possible for the [slug].tsx template to take a slug under /post/ as a query. Now the fun part begins; let's add some Sanity to the mix:

// ./frontend/pages/post/[slug].tsx

import client from '../../client'

const Post = ({post}) => {
  
  return (
    <article>
      <h1>{post?.slug?.current}</h1>
    </article>
  )
}

export async function getStaticPaths() {
  const paths = await client.fetch(
    `*[_type == "post" && defined(slug.current)][].slug.current`
  )

  return {
    paths: paths.map((slug) => ({params: {slug}})),
    fallback: true,
  }
}

export async function getStaticProps(context) {
  // It's important to default the slug so that it doesn't return "undefined"
  const { slug = "" } = context.params
  const post = await client.fetch(`
    *[_type == "post" && slug.current == $slug][0]
  `, { slug })
  
  return {
    props: {
      post
    }
  }
}

export default Post

We’re using an async function as we’re requesting some data from the Sanity API, and we need to wait until we receive that data before returning it.
We have also removed the router since the function for getStaticProps gets the same information in params. The fetch() function of the Sanity client (not to be confused with the Fetch API) takes two arguments: a GROQ query and an object with parameters and values.

To allow the frontend server to get content from Sanity, we must add its domain to CORS settings. In other words, we have to add localhost:3000 (and eventually, the domain you're hosting your blog on) to Sanity’s CORS origin settings. If you run the command npx sanity manage inside your studio folder, you'll be taken to the project’s settings in your browser. Navigate to the API tab. Under CORS origins, add http://localhost:3000 as a new origin.

Go to http://localhost:3000/post/hello-world and confirm that the heading spells “Hello world!” (If it doesn't work, make sure that the slug field in the Studio says hello-world, and that the post is published).

You have now successfully connected your blog's frontend with Sanity. 🎉

5. Add a byline with author and categories

In the Studio, you'll discover, you can also add entries for authors and categories. Go and add at least one author with an image.

Studio with authors selected, editing author named Hidde de Vries

Go back to your blog post, and attach this author in the Author field, like this:

The author reference field input with an author selected

Publish the changes. We've now referenced an author from the blog post. References are a powerful part of Sanity and make it possible to connect and reuse content across types. If you inspect your block document (Ctrl + Alt + i on Windows, or Ctrl + Opt + i on macOS) in the Studio you'll see that the object looks something like this:

"author": {
  "_ref": "fdbf38ad-8ac5-4568-8184-1db8eede5d54",
  "_type": "reference"
}

This is the content we would get if we now just pulled out the author variable (const { title, author } = await client.fetch('*[slug.current == $slug][0]',{ slug })), which is not very useful to us in this case. This is where projections in GROQ come in handy. Projections are a powerful feature of GROQ and allow us to specify the API response to our needs. Head back to the editor and add the projection {title, "name": author->name} right after the filter (*[_type == "post" && slug.current == $slug][0]):

// [slug].tsx

import client from '../../client'

const Post = (props) => {
  const { title = 'Missing title', name = 'Missing name' } = props.post
  return (
    <article>
      <h1>{title}</h1>
      <span>By {name}</span>
    </article>
  )
}

export async function getStaticPaths() {
  const paths = await client.fetch(
    `*[_type == "post" && defined(slug.current)][].slug.current`
  )

  return {
    paths: paths.map((slug) => ({params: {slug}})),
    fallback: true,
  }
}

export async function getStaticProps(context) {
  // It's important to default the slug so that it doesn't return "undefined"
  const { slug = "" } = context.params
  const post = await client.fetch(`
    *[_type == "post" && slug.current == $slug][0]{title, "name": author->name}
  `, { slug })
  return {
    props: {
      post
    }
  }
}

export default Post

We added the projection {title, "name": author->name} to our query, to specify what in the document we want to be returned. First, we made a key for the author's name (“name”), then we follow the reference to the name property on the author document with an arrow ->. In other words: we ask Sanity to follow the id under _ref, and return only the value for the variable called name from that document.

Adding categories

Let's try to do the same with categories. First, create at least two categories in the Studio; remember to publish them. Then, in our “Hello world” post, select these categories.

Categories field with two categories selected: web and frontend
Add the categories to your blog post

This adds an array of references to categories in our blog post. If you take a peek in the document inspector (find it in the menu with tree dots above on the right of the document form), you'll see that these show up just as the author entry, objects with a _ref-id. So we have to use projections to get those as well. Now the GROQ query has grown, so we'll change it to a variable and add the npm package groq (npm install groq) to get support for syntax highlighting (in VS Code).

// [slug].js

import groq from 'groq'
import client from '../../client'

const Post = ({post}) => {
  const { title = 'Missing title', name = 'Missing name', categories } = post
  return (
    <article>
      <h1>{title}</h1>
      <span>By {name}</span>
      {categories && (
        <ul>
          Posted in
          {categories.map(category => <li key={category}>{category}</li>)}
        </ul>
      )}
    </article>
  )
}

const query = groq`*[_type == "post" && slug.current == $slug][0]{
  title,
  "name": author->name,
  "categories": categories[]->title
}`

export async function getStaticPaths() {
  const paths = await client.fetch(
    groq`*[_type == "post" && defined(slug.current)][].slug.current`
  )

  return {
    paths: paths.map((slug) => ({params: {slug}})),
    fallback: true,
  }
}

export async function getStaticProps(context) {
  // It's important to default the slug so that it doesn't return "undefined"
  const { slug = "" } = context.params
  const post = await client.fetch(query, { slug })
  return {
    props: {
      post
    }
  }
}
export default Post

The projection for categories is made similarly to the author reference. The only difference is that we attach square brackets to the key categories because it is an array of references. So, categories[]->title means "loop through all the entries in categories and return the title from the referenced document."

Adding an author photo

But we also want to add the author's photo to the byline! Images and file assets in Sanity are also references, which means that to get the author image, we have to follow the reference to the author document and then to the image asset. We could retrieve the imageUrl directly by accessing "imageUrl": author->image.asset->url, but this is where it's easier to use the Image URL package we've made. Install the package in the frontend project with npm i @sanity/image-url. It takes the image object and figures out where to get the image. It also makes it easier to use more advanced features, like image hot spots.

// [slug].tsx

import groq from 'groq'
import imageUrlBuilder from '@sanity/image-url'
import client from '../../client'

function urlFor (source) {
  return imageUrlBuilder(client).image(source)
}

const Post = ({post}) => {
  const {
    title = 'Missing title',
    name = 'Missing name',
    categories,
    authorImage
  } = post
  return (
    <article>
      <h1>{title}</h1>
      <span>By {name}</span>
      {categories && (
        <ul>
          Posted in
          {categories.map(category => <li key={category}>{category}</li>)}
        </ul>
      )}
      {authorImage && (
        <div>
          <img
            src={urlFor(authorImage)
              .width(50)
              .url()}
          />
        </div>
      )}
    </article>
  )
}

const query = groq`*[_type == "post" && slug.current == $slug][0]{
  title,
  "name": author->name,
  "categories": categories[]->title,
  "authorImage": author->image
}`

export async function getStaticPaths() {
  const paths = await client.fetch(
    groq`*[_type == "post" && defined(slug.current)][].slug.current`
  )

  return {
    paths: paths.map((slug) => ({params: {slug}})),
    fallback: true,
  }
}

export async function getStaticProps(context) {
  // It's important to default the slug so that it doesn't return "undefined"
  const { slug = "" } = context.params
  const post = await client.fetch(query, { slug })
  return {
    props: {
      post
    }
  }
}
export default Post

Having put in the code lines for the Image URL builder, we can send in the image object from Sanity in the urlFor() function, and append the different methods (e.g. .width(50)) with the .url()-method at the end.

6. Add rich text and block content with Portable Text

A blog wouldn't be much without great support for rich text and block content. Sanity store these as Portable Text. This lets us use it in many different contexts: from HTML in the browser to speech fulfillment in voice interfaces. There's a lot to be said about Portable Text and its extensibility, but in this tutorial, we'll use the out-of-the-box features that come with the package @portabletext/react. Install it with npm install @portabletext/react.

// [slug].tsx

import groq from 'groq'
import imageUrlBuilder from '@sanity/image-url'
import {PortableText} from '@portabletext/react'
import client from '../../client'

function urlFor (source) {
  return imageUrlBuilder(client).image(source)
}

const ptComponents = {
  types: {
    image: ({ value }) => {
      if (!value?.asset?._ref) {
        return null
      }
      return (
        <img
          alt={value.alt || ' '}
          loading="lazy"
          src={urlFor(value).width(320).height(240).fit('max').auto('format')}
        />
      )
    }
  }
}

const Post = ({post}) => {
  const {
    title = 'Missing title',
    name = 'Missing name',
    categories,
    authorImage,
    body = []
  } = post
  return (
    <article>
      <h1>{title}</h1>
      <span>By {name}</span>
      {categories && (
        <ul>
          Posted in
          {categories.map(category => <li key={category}>{category}</li>)}
        </ul>
      )}
      {authorImage && (
        <div>
          <img
            src={urlFor(authorImage)
              .width(50)
              .url()}
            alt={`${name}'s picture`}
          />
        </div>
      )}
      <PortableText
        value={body}
        components={ptComponents}
      />
    </article>
  )
}

const query = groq`*[_type == "post" && slug.current == $slug][0]{
  title,
  "name": author->name,
  "categories": categories[]->title,
  "authorImage": author->image,
  body
}`
export async function getStaticPaths() {
  const paths = await client.fetch(
    groq`*[_type == "post" && defined(slug.current)][].slug.current`
  )

  return {
    paths: paths.map((slug) => ({params: {slug}})),
    fallback: true,
  }
}

export async function getStaticProps(context) {
  // It's important to default the slug so that it doesn't return "undefined"
  const { slug = "" } = context.params
  const post = await client.fetch(query, { slug })
  return {
    props: {
      post
    }
  }
}
export default Post

We import the React component as PortableText, and get the body from the post document. We send in the body as a prop called value.

We also added a components prop to determine how blocks of type image should be rendered. And that's it! You can customize the output of different elements, and even add your own custom block types.

A blog post without styling titled Hello world by Hidde de Vries posted in Web and frontend, with two images.
If everything went well, you should have a bare-bones blog template in place

7. Add the blog posts to the frontpage

Now we want to have a simple list of your blog posts on the home page, that is, in index.tsx. We have already been through most of the requirements using the client to fetch data from Content Lake, and getInitialProps to make the data available in the frontend template.

// frontend/pages/index.tsx

import Link from 'next/link'
import groq from 'groq'
import client from '../client'

const Index = ({posts}) => {
    return (
      <div>
        <h1>Welcome to a blog!</h1>
        {posts.length > 0 && posts.map(
          ({ _id, title = '', slug = '', publishedAt = '' }) =>
            slug && (
              <li key={_id}>
                <Link href={`/post/${encodeURIComponent(slug.current)}`}>
                  {title}
                </Link>{' '}
                ({new Date(publishedAt).toDateString()})
              </li>
            )
        )}
      </div>
    )
}

export async function getStaticProps() {
    const posts = await client.fetch(groq`
      *[_type == "post" && publishedAt < now()] | order(publishedAt desc)
    `)
    return {
      props: {
        posts
      }
    }
}

export default Index

If you look closer at the GROQ query in getStaticProps, you'll notice that we look for documents that have _type == "post" and has a publish date that is lesser (i.e. before) whatever time it is at the moment. We also order the results after the publishing data so that the newest posts end up first on the list.

In the template, we loop (map) over the array of posts, and build the links using Next.js Link component. We point the href to the post‘s URL path and slug.

8. Deploy your blog on the web with vercel

To get your new blog on the web you can use Vercel. Install the CLI with npm i -g vercel and run the command vercel login to create an account. Once that's done, you can run vercel in the web folder to deploy the blog. Remember to add your URL to the CORS origin settings. You can also connect a custom domain to your deployment on Vercel (remember CORS for that too).

Next steps

And that's it for this tutorial! We have now covered a lot of ground when it comes to coding a frontend layer for a pretty common content setup, and yet just scraped the iceberg of features and nifty things we can do with the combination of Sanity and React. Of course, your work doesn't end here. Now you should make this blog your own with more HTML, CSS, and JavaScript.

Something else you could try is to embed your Studio. In this tutorial, you've installed the Studio in its own folder, separate from your front-end project. From Sanity Studio v3, you can also embed your Studio directly into any React application, including Next.js applications. This makes it easier to render previews of your front-end (as they're the same application) and could simplify hosting too (one thing to host, rather than two). For more info, head to the documentation.