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
Head of Developer Community and Education
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:
- Sanity Studio – a React app that connects to the hosted API with all your blog content
- 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.
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.
Go back to your blog post, and attach this author in the Author field, like this:
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.
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.
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.