Step by step tutorial to create a simple blog
A multilingual blog using next js and sanity
Table of contents
An overview
Next.js is a popular React-based framework for building web applications, including blogs. It provides powerful features such as server-side rendering, automatic code splitting, and static exporting, which help to optimize the performance and SEO of your blog.
Sanity is a content management system (CMS) that provides a flexible and customizable data model, rich text editing, and a variety of integrations with popular front-end frameworks such as React and Next.js. It allows you to easily manage your blog's content, and provides APIs that can be used to fetch this content in your Next.js application.
Combining Next.js and Sanity allows you to build a fast and flexible blog. In this tutorial, we'll see how i build this blog step-by-step, covering the steps of setting up a Next.js project, configuring Sanity as a CMS, and creating the blog.
Setting up Sanity
To set up Sanity, you'll need to follow a few simple steps:
- Create an account on Sanity's website if you haven't already.
- Install the Sanity CLI by running 'npm install -g @sanity/cli'.
- Create a new project in your root directory by running 'npm -y create sanity@latest'.
- Follow the CLI prompts to configure your project:
- Enter a name for the project.
- Use the default data set 'production'.
- Choose the path for the project (e.g. your-directory/sanity-backend).
- Choose the option to create a clean project with no predefined schemas.
- Choose whether or not to use TypeScript (this tutorial will not use it).
- Choose your preferred package manager.
Following these steps will get your Sanity project set up and ready to use.
Here is the folder structure of the newly created Sanity project:
You can now try running Sanity Studio by entering the command 'npm run dev' in your terminal, then navigating to 'localhost:3333' in your web browser. This will launch the Sanity Studio interface, where you can manage your content.
If you see a message asking you to create a new dataset, you are on track with us!
Creating locale types in Sanity studio
For our multilingual blog, we'll be supporting both Arabic and English in this tutorial, but you can add as many languages as you'd like, or even just use a single language. To get started, we need to create a file that defines our supported languages.
Here's how you can do that:
- In the schemas folder of your Sanity project, create a new folder called locale.
- Inside the locale folder, create a file called supportedLanguages.js.
- Add the following code to supportedLanguages.js:
export const supportedLanguages = [
{ id: 'en', title: 'English', isDefault: true },
{ id: 'ar', title: 'Arabic' }
]
export const baseLanguage = supportedLanguages.find(l => l.isDefault)
Sanity provides three different text types for storing different types of text: 'string' for short text, 'text' for longer text, and 'block' for rich text with formatting. To support multiple languages for these text types, we need to create locale versions of each one.
Here's how you can do that:
- In the 'locale' folder of your Sanity project, create three new files: 'localeText.js', 'localeString.js', and 'localeBlock.js'.
- Add the following code to each of these files:
import { supportedLanguages } from './supportedLanguages'
export const localeString = {
title: 'Localized string',
name: 'localeString',
type: 'object',
fieldsets: [
{
title: 'Translations',
name: 'translations',
options: { collapsible: true }
}
],
fields: supportedLanguages.map(lang => ({
title: lang.title,
name: lang.id,
type: 'string',
fieldset: lang.isDefault ? null : 'translations'
}))
}
import { supportedLanguages } from './supportedLanguages'
export const localeText = {
title: 'Localized text',
name: 'localeText',
type: 'object',
fieldsets: [
{
title: 'Translations',
name: 'translations',
options: { collapsible: true }
}
],
fields: supportedLanguages.map(lang => ({
title: lang.title,
name: lang.id,
type: 'text',
fieldset: lang.isDefault ? null : 'translations'
}))
}
import { defineArrayMember } from 'sanity'
import { supportedLanguages } from './supportedLanguages'
export const localeBlock = {
title: 'Localized block',
name: 'localeBlock',
type: 'object',
fieldsets: [
{
title: 'Translations',
name: 'translations',
options: { collapsible: true }
}
],
fields: supportedLanguages.map(lang => ({
title: lang.title,
name: lang.id,
type: 'array',
of: [
defineArrayMember({
title: 'Block',
type: 'block',
styles: [
{title: 'Normal', value: 'normal'},
{title: 'H1', value: 'h1'},
{title: 'H2', value: 'h2'},
{title: 'H3', value: 'h3'},
{title: 'H4', value: 'h4'},
{title: 'H5', value: 'h5'},
{title: 'H6', value: 'h6'},
{title: 'Quote', value: 'blockquote'}
],
lists: [
{title: 'Bullet', value: 'bullet'},
{title: 'Numbered', value: 'number'}
],
marks: {
decorators: [
{title: 'Strong', value: 'strong'},
{title: 'Emphasis', value: 'em'},
],
annotations: [
{
title: 'URL',
name: 'link',
type: 'object',
fields: [
{
title: 'URL',
name: 'href',
type: 'url',
},
],
},
],
},
}),
defineArrayMember({
type: 'image',
options: {hotspot: true},
}),
],
fieldset: lang.isDefault ? null : 'translations'
}))
}
This process of creating locale versions for each text type is called "fields-level translation." If you want to learn more about localisation in Sanity, you can check out this link
Creating the blog post document
Our blog will feature code snippets within our rich text, which will contain the entire blog post. To enable this functionality, we need to install a plugin for Sanity Studio.
To do this, open your terminal and enter the command 'npm install @sanity/code-input'. Once the installation is complete, you need to import the 'codeInput' plugin in your 'sanity.config.js' file and add it to the 'plugins' array:
plugins: [deskTool(), visionTool(), codeInput()],
Next, we need to add some code to the 'localeBlock.js' file. After the last 'arrayMember' in that file, add the following code:
defineArrayMember({
type: 'code'
})
To include the new types in our Sanity project, we need to add them to the schemaTypes array in the index.js file. This is a simple process:
- Open the 'index.js' file in your Sanity project.
- Locate the 'schemaTypes' array.
- Add the three new types we created ('localeText', 'localeString', and 'localeBlock') to the array.
With the new types added to your Sanity project, we can now move on to creating our document. To do this, navigate to the schemas folder and create a new file called post.js (you can choose any name you like).
Once you've created the new file, add the following code to it:
import {defineField, defineType} from 'sanity'
export default defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'localeString',
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title.en',
maxLength: 96,
},
}),
defineField({
name: 'subtitle',
title: 'Subtitle',
type: 'localeString',
}),
defineField({
name: 'publishedAt',
title: 'Published at',
type: 'datetime',
}),
defineField({
name: 'readTime',
title: 'Estimated read time',
type: 'localeString',
}),
defineField({
name: 'keywords',
title: 'Keywords',
type: 'array',
of: [{ type: 'localeString' }]
}),
defineField({
name: 'body',
title: 'Body',
type: 'localeBlock',
}),
],
preview: {
select: {
title: 'title.en',
subtitle: 'subtitle.en'
},
},
})
In this code, we are defining the fields of our blog post document. These fields include:
- A 'title' field
- A 'subtitle' field
- A 'slug' field, which is a unique string used to identify each post. This will be useful for routing later on.
- A 'publicationDate' field
- A 'readTime' field, which we will calculate manually by counting the number of words in the post. There are many tools available to help with this task.
- A 'keywords' field, which is an array used for SEO purposes.
- A 'body' field, which will contain the main content of the post.
Now that we have defined the post schema, we need to add it to the schemaTypes array in the index.js file. Once we've done that, our backend is complete and ready to go. To test it out, let's run 'npm run dev' and navigate to 'localhost:3333' in our browser. You should see the Sanity Studio interface with the 'post' type now available for us to use.
Now that our backend is set up, we can start adding content to it. Let's add an article to showcase how it works. In the Studio interface, create a new document of the 'post' type and fill in the fields with your desired content. To make it easier to display our article later on the front-end, we'll add some headings (as h2 tags) to the body text for each section of our article. We'll explain why shortly.
You can also deploy sanity studio to use it online by typing this command: 'npm run deploy', and follow the CLI. After the deployment is complete, you can use the studio under your project name in sanity.io/manage.
Now, let's move on to building our Next.js application which will serve as the front-end of our project.
Setting up the Next js app
There are two ways to start your Next.js app: either by using create-next-app in the root directory, or by starting from scratch, which is what I will do in this tutorial. If you used create-next-app, you can skip the next steps.
To set up the Next.js app from scratch, follow these steps:
- Run the command npm init -y in the root folder.
- Install the following dependencies: 'next', 'react', 'react-dom', '@sanity/client', '@sanity/block-content-to-react,' 'next-sanity-image', and 'react-refractor'. We will explain later why we need these packages.
- Add the following scripts to the 'package.json' file:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
After installing the dependencies, create the necessary files and folders that make our app functional. Firstly, add the following folders in the root directory: '/pages', '/components', '/public', '/styles', and '/lib'. Then, inside the 'pages' folder, create three files: '_app.js', 'index.js', and '[slug].js'. These files will just contain a boilerplate code for now.
'app.js':
const MyApp = ({ Component, pageProps }) => {
return (
<>
<Component {...pageProps} />
</>
)
}
export default MyApp
'index.js':
const Home = () => {
return (
<>
<div>Home</div>
</>
)
}
export default Home
'[slug].js':
const PostPage = () => {
return (
<>
<div>PostPage</div>
</>
)
}
export default PostPage
In the [slug].js file, we will use dynamic routing to navigate to each individual blog post. You can learn more about Next.js routing here.
Setting up multi languages support in the Next js App
Next.js offers built-in internationalized routing support. You can provide a list of locales, the default locale, and Next.js will automatically handle the routing. You can read more about this topic on the Next.js documentation here.
i18n: {
locales: ['en-US', 'ar-DZ'],
defaultLocale: 'en-US',
},
To handle languages that start writing from right to left, such as Arabic, you can wrap the global component with a container that changes its 'dir' attribute according to the current locale. Here's an example of how the '_app.js' file should look like:
import { useRouter } from 'next/router'
const MyApp = ({ Component, pageProps }) => {
const { locale } = useRouter()
return (
<div dir={ locale == 'ar-DZ' ? 'rtl' : 'ltr' }>
<Component {...pageProps} />
</div>
)
}
export default MyApp
Setting up Sanity Client
In order to connect our app to Sanity, we need a Sanity client. Let's set up our client. Create a file called 'client.js' inside the 'lib' folder, and put this code in it:
import { createClient } from '@sanity/client'
export const client = createClient({
projectId: [your-project-id],
dataset: 'production',
apiVersion: [your-api-version], // use current UTC date
useCdn: true, // `false` if you want to ensure fresh data
token: process.env.SANITY_TOKEN
})
To get your project ID, go to sanity.io/manage and navigate to your project page. Replace the API version with the date you created the project. Note that the date format should be 'yyyy-mm-dd'. Next, you need a token to connect to your Sanity project. To create a new token, go to the API tab and click on Tokens on the side menu. Click "Add API token", choose a name for your token, and select a permission. If you choose "editor," you will have all permissions. Copy the token and create a '.env.local' file in your root folder. Add your token to this file. Since we will only use the client on the server side, we don't need to name the environment variable 'NEXT_PUBLIC'. By doing this, we have also ensured that the token is secure.
We will use this client later when we fetch data from Sanity. Finally, to inform Next.js to use the Sanity domain for images, add the following code to your next.config.js file:
images: {
domains: ['cdn.sanity.io']
}
Now let’s fetch our posts to display them on the home page. Since it is a small and basic blog, I am going to use client-side rendering to gain more performance. You can read more about rendering in Next.js here.
To get started, add this code to the index.js file:
export const getStaticProps = async () => {
const query = '*[_type == "post"]{_id, slug, title, subtitle, publishedAt, readTime}'
const blogPosts = await client.fetch(query)
return {
props: { blogPosts }
}
}
To fetch our posts and display them on the homepage, we used the Sanity client that we previously created. Using the client, we fetched an array of all the blog posts and include only the necessary data for each post to display a card that will act as a link to the post. Finally, we returned the list as a page prop.
By console logging the posts list this is what we get:
Now, let's start building our blog posts component to display the list of articles. But before that, let's add all the styles, as we mentioned earlier, we won't explain the styles because it is outside the scope of this tutorial.
Adding the styles
Inside the 'styles' folder create a 'global.css' file and import it in the '_app.js' file. Here is the styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Roboto', 'Vazirmatn', sans-serif;
}
:root {
--navbar-height: 100px;
}
::-webkit-scrollbar {
width: 8px;
z-index: 999;
}
::-webkit-scrollbar-track {
background: #CDCDCD;
}
::-webkit-scrollbar-thumb {
background: #0283E9;
border-radius: 5px;
}
pre span {
font-family: 'IBM Plex Mono', monospace;
}
body {
background-color: #18181b;
}
a {
text-decoration: none;
color: inherit;
}
.main {
color: #CDCDCD;
padding: 50px 8%;
}
.metadata {
display: flex;
gap: 10px;
}
.page-title {
text-align: center;
font-size: 36px;
margin-bottom: 60px;
}
.cards {
display: flex;
flex-direction: column;
}
.card {
padding: 20px;
display: flex;
flex-direction: column;
gap: 5px;
border-bottom: 1px solid grey;
cursor: pointer;
}
.card p {
color: gray;
font-size: 14px;
}
.card h2 {
color: white;
font-size: 28px;
}
.card h5 {
font-size: 16px;
font-weight: 400;
}
.card:hover {
background-color: #1f1f22;
}
/* -------------------------------- post -------------------------------- */
.main-post {
color: #CDCDCD;
padding: 50px 6%;
}
.metadata-post {
display: flex;
gap: 10px;
}
.main-info p {
color: gray;
font-size: 16px;
margin-bottom: 5px;
}
.main-info h1 {
font-size: 40px;
color: white;
margin-bottom: 10px;
}
.main-info h3 {
font-size: 20px;
font-weight: 400;
}
.table-of-content-container {
margin-top: 40px;
border: 1px solid grey;
border-radius: 5px;
}
.table-title, .table-title-show {
background-color: #212121;
border-radius: 5px;
padding: 15px;
display: flex;
gap: 10px;
align-items: center;
cursor: pointer;
}
.table-title-show {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.chevron-icon-ar {
transform: rotate(180deg);
}
.chevron-icon, .chevron-icon-ar {
width: 15px;
height: 15px;
}
.table-title-show .chevron-icon, .table-title-show .chevron-icon-ar {
transform: rotate(90deg);
}
.table-title h4, .table-title-show h4 {
font-weight: 400;
font-size: 18px;
color: white;
}
.table-content {
display: flex;
flex-direction: column;
gap: 8px;
background-color: #141414;
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
padding: 20px;
}
.table-content p {
font-size: 18px;
color: #0283E9;
text-decoration: underline;
cursor: pointer;
}
.body-text-container {
margin-top: 50px;
}
.body-text-container h2 {
font-size: 28px;
margin-bottom: 15px;
color: white;
}
.body-text-container p {
font-size: 18px;
margin-bottom: 20px;
line-height: 28px;
}
.body-text-container img {
margin-bottom: 30px;
}
@media only screen and (max-width: 550px) {
.main-post {
padding: 50px 20px;
}
}
@media only screen and (max-width: 500px) {
.body-text-container p {
font-size: 16px;
}
}
@media only screen and (max-width: 400px) {
.main-info h1 {
font-size: 32px;
}
.main-info {
padding: 50px 10px;
}
}
/* -------------------------------- navbar -------------------------------- */
nav {
width: 100%;
height: var(--navbar-height);
background-color: rgb(24, 24, 27);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 80px;
}
nav h1 {
color: #CDCDCD;
font-size: 28px;
cursor: pointer;
}
.lang-btn {
display: flex;
align-items: center;
gap: 10px;
outline: none;
border: none;
padding: 6px 10px;
border-radius: 5px;
cursor: pointer;
background-color: black;
color: #CDCDCD;
}
.lang-btn:hover {
background-color: rgba(0, 0, 0, 0.5);
}
.lang-btn img {
height: 24px;
width: 24px;
}
.lang-switcher {
display: flex;
align-items: center;
}
.lang-switcher img {
height: 22px;
width: auto;
}
.lang {
font-size: 14px;
}
.active-lang {
font-weight: 700;
color: #0283E9;
}
Building the Blog component
Here is the full code of the Blog component:
import { useRouter } from 'next/router'
import Link from 'next/link'
import { getMonthName } from '../lib/helpers'
const Blog = ({ posts }) => {
const { locale } = useRouter()
return (
<section className='main'>
<h1 className='page-title'>
{ locale == 'ar-DZ' ? 'المدونة' : 'Blog' }
</h1>
<div className='cards'>
{
posts.map(post => {
const { _id, publishedAt, slug, subtitle, title, readTime } = post
const date = new Date(publishedAt)
return (
<Link href={`/${slug.current}`} className='card' key={_id}>
<div className='metadata'>
<p>
{ `${getMonthName(date, locale)} ${date.getDate()}, ${date.getFullYear()}` }
</p>
<p>
{ locale == 'ar-DZ' ? readTime.ar : readTime.en }
</p>
</div>
<h2>
{ locale == 'ar-DZ' ? title.ar : title.en }
</h2>
<h5>
{ locale == 'ar-DZ' ? subtitle.ar : subtitle.en }
</h5>
</Link>
)
})
}
</div>
</section>
)
}
export default Blog
- The 'Blog' component contains a list of cards, each card is a link to its associated post, and each card contains information about the post: title, subtitle, publication date, read time.
- You can copy the code provided above and add it to a new file named 'Blog.js' in the 'components' folder of your Next.js project.
- Once you've added the 'Blog' component, you can import it into the 'index.js' file and return it there to display the list of blog posts on the homepage.
Now the page should like like this:
Getting the post data
In the 'slug.js' file, we fetch the post data using the 'slug'. We do this by utilizing the 'getStaticProps' function, which has an argument called 'context'. We then destructure a another object called 'params' from the 'context' object then we destructure the 'slug' from the 'params' object.
Next, we make a query to Sanity to retrieve the post data based on the 'slug'. The query result is then returned as a prop.
Now the '[slug].js' file should look like this:
import { client } from '../lib/client'
const PostPage = ({ post }) => {
return (
<>
<div>PostPage</div>
</>
)
}
export default PostPage
export const getStaticProps = async ({ params: { slug } }) => {
const query = `*[_type == "post" && slug.current == "${slug}"][0]`
const post = await client.fetch(query)
return {
props: { post }
}
}
This code alone will give us an error because we are using 'getStaticProps' in a dynamic page, in static generation Next js should know in advance what are all the avaiable paths, you can read more about this here.
We need to use 'getStaticPaths' to define all the available routes in advance, 'getStaticPaths' should return an array of objects, where each object contains 'params' key, this object contains another object that contains 'slug' as a key, and the 'slug' actual value as value.
put this code inside '[slug].js' file after 'getStaticProps':
export const getStaticPaths = async () => {
const query = '*[_type == "post" && !(_id in path("drafts.**"))]{ slug { current } }'
const posts = await client.fetch(query)
const paths = posts.map(post => ({
params: {
slug: post.slug.current
}
}))
return {
paths,
fallback: 'blocking'
}
}
If we try to 'console.log' the post that is returned as a prop in the 'PostPage' component, we should get this:
Now, let's start building our post component to display the content of an article.
Building the Post component
To build the Post component, follow these steps:
- Create a new file called 'Post.js' inside the components folder.
- Create a basic React component template inside 'Post.js'.
- Import 'Post.js' into our '[slug].js' file and return it there.
- Pass the post data as a prop to the 'Post.js' component.
- Inside the Post component, destructure our data from the post object.
The Post component is divided into three main sections, each serving a specific purpose in displaying the post:
- The main data about the post
- The table of contents
- The post itself
In the following steps, we will build each section of the Post component.
Post main data section
In this section, we will create a 'div' with a 'className' of 'main-info' that will hold all the basic information about the post, including publication date, title, subtitle, and read time. First, we need to move the function we created earlier that returns the month name to a separate file. To do this, we will create a new file called 'helpers.js' inside the 'lib' folder, paste the function there, and import it to both the Blog component and Post component. After that, we will create a new date from the 'publishedAt' given by Sanity to use in our function.
const date = new Date(publishedAt)
Here is what the Post component will return for now:
<div className='main-post'>
<div className='main-info'>
<div className='metadata-post'>
<p>{`${getMonthName(date, locale)} ${date.getDate()}, ${date.getFullYear()}`}</p>
<p>
{locale == 'ar-DZ' ? readTime.ar : readTime.en}
</p>
</div>
<h1>{locale == 'ar-DZ' ? title.ar : title.en}</h1>
<h3>{locale == 'ar-DZ' ? subtitle.ar : subtitle.en}</h3>
</div>
</div>
Post table of contents section
In the this section, there are two main containers: the title container, which will control showing and hiding the table content, and the table content container, which will contain a list of all the 'h2' elements we created earlier in Sanity Studio in the rich text. When we click on any of those headers, it will scroll into its content, just like a regular table of contents.
To achieve this, we firstly need a state to control showing and hiding the table content and an icon that will rotate when we show the content. Inside the 'public' folder, create a folder called 'icons' and place your chevron icon there. We will use the same folder to store all the icons later.
Let's declare our state:
const [showTable, setShowTable] = useState(false)
Don't forget to import 'useState' from 'react' first.
Here is the table content container for now:
<div className='table-of-content-container'>
<div
className={showTable ? 'table-title-show' : 'table-title'}
onClick={() => setShowTable(v => !v)}
>
<Image
src={chevronIcon}
priority
alt='chevron'
className={locale == 'ar-DZ' ? 'chevron-icon-ar' : 'chevron-icon'}
/>
<h4>{locale == 'ar-DZ' ? 'الفهرس' : 'Table of contents'}</h4>
</div>
</div>
By using the ternary operator in the code above to change the 'className' of our container we control the rotation of the icon.
Now we need to extract our headers 'h2' from the rich text, we need two arrays that holds our headers, one for each language, paste this code in the component and we will explain it afterwards:
let titlesElementsEn = []
let titlesElementsAr = []
body.en?.forEach(element => {
if (element.style === 'h2') titlesElementsEn.push(element)
})
body.ar?.forEach(element => {
if (element.style === 'h2') titlesElementsAr.push(element)
})
let titlesEn = []
let titlesAr = []
titlesElementsEn?.forEach(element => {
element.children.forEach(child => {
titlesEn.push(child.text)
})
})
titlesElementsAr?.forEach(element => {
element.children.forEach(child => {
titlesAr.push(child.text)
})
})
That code above initializes two empty arrays (one for each language), 'titlesElementsEn' and 'titlesElementsAr', and then iterates through the 'body.en' and 'body.ar'. If an element within these arrays has a style of 'h2', then it is pushed into the respective 'titlesElementsEn' or 'titlesElementsAr' array.
Afterwards, two new empty arrays, 'titlesEn' and 'titlesAr', are initialized. The code then iterates through each of the 'titlesElementsEn' and 'titlesElementsAr' arrays, and for each element, it iterates through its children (assuming they exist) and pushes the text of each child into the respective 'titlesEn' or 'titlesAr' array.
The end result is that the 'titlesEn' and 'titlesAr' arrays contain an array of titles extracted from the 'body.en' and 'body.ar' rich text objects, respectively, that have an 'h2' style.
I know this code might look ugly, but i will try to imporve it more in the future.
The next thing we need to do is to scroll down to the the assosiated content of each 'h2' by click on it, to achieve tis we create this function:
const scrollToTitle = (id) => {
const target = document.getElementById(id)
const offest = 100
const targetPos = target.getBoundingClientRect().top + window.scrollY - offest
window.scrollTo({ top: targetPos })
}
I added an offset to add more margin top when scrolling to an element in case of using a fixed Navbar at the top.
Now here is the code of the table content container, put it right after the table title 'div':
{
showTable &&
<div className='table-content'>
{
locale == 'ar-DZ' ? (
titlesAr.length > 0 &&
titlesAr.map((element, index) => {
return (
<p key={index} onClick={() => scrollToTitle(element.replaceAll(' ', '-'))}>
{element}
</p>
)
})
) :
(
titlesEn.length > 0 &&
titlesEn.map((element, index) => {
return (
<p key={index} onClick={() => scrollToTitle(element.replaceAll(' ', '-'))}>
{element}
</p>
)
})
)
}
</div>
}
Here is what the table of content when its open should look like:
Post body section
This section requires the use of the 'BlockContent' component from '@sanity/block-content-to-react' in order to display a rich text from Sanity to React. This component has two main props: 'blocks', which is our body, and 'serializers', which is an object that goes through the rich text and returns a React component for each element inside the rich text. There are three main types in our rich text: the 'block' type which is the text containing all text elements, the 'code' type which contains the code snippets we talked about earlier, and the 'image' type.
Let’s start with the 'block' type:
const serializers = {
types: {
block: props => {
switch(props.node.style) {
case 'h2':
return <h2 id={props.children[0].replaceAll(' ', '-')}>{props.children}</h2>
default:
return <p>{props.children}</p>
}
},
}
}
We return an' h2' React element with an 'id' of its text replacing spaces with dashes for each 'h2' in the rich text, then we return a 'p' React element as default element for other text blocks.
Now let’s deal with the 'code' type. To return a code snippet with formatted code that keeps the spaces, new lines, and changes colors like an IDE, we will use a Refractor component from '@react/refractor'. This component takes two props: a language and a value, which is the code itself. Both are provided by the rich text.
Add this code to the 'serializers' right after the block type:
code: props => {
return (
<Refractor
language={props.node.language}
value={props.node.code}
/>
)
},
Next, let's deal with the image type. To handle sanity images, we will use 'useNextSanityImage' hook. This hook takes a sanity client and the image object returned from the rich text and returns a props object that we can use on an Image component to take advantage of using built-in next images.
To make things cleaner and more concise, we created a separate component that takes the image object returned from the rich text as props and returns an Image element.
Inside the component folder create a new file called 'SanityImage.js' and paste this code:
import { useNextSanityImage } from 'next-sanity-image'
import Image from 'next/image'
import { client } from '../lib/client'
const SanityImage = ({ asset, alt }) => {
const imageProps = useNextSanityImage(client, asset);
if (!imageProps) return null
return (
<Image
{...imageProps}
style={{ width: '100%', height: '100%', objectFit:'cover' }}
loader={imageProps.loader}
sizes='100vw'
alt={alt}
priority
/>
)
}
export default SanityImage
Now in our 'serializers' we return this SanityImage component for each image in the rich text, add this code after the 'code' type:
image: props => {
return <SanityImage asset={props.node} alt={`${title.en} blog image`} />
}
By now our 'serializers' is complete.
And this is our body text container, place it right after table of contents container:
<div className='body-text-container'>
<BlockContent
blocks={locale == 'ar-DZ' ? body.ar : body.en}
serializers={serializers}
/>
</div>
And now the Post component is complete, here is the full final code:
import { useRouter } from 'next/router'
import { useState } from 'react'
import Image from 'next/image'
import Refractor from 'react-refractor'
import BlockContent from '@sanity/block-content-to-react'
import SanityImage from './SanityImage'
import { getMonthName } from '../lib/helpers'
import chevronIcon from '../public/icons/chevron-right-50.png'
const Post = ({ post }) => {
const { body, title, subtitle, publishedAt, readTime } = post
const date = new Date(publishedAt)
const [showTable, setShowTable] = useState(false)
const { locale } = useRouter()
let titlesElementsEn = []
let titlesElementsAr = []
body.en?.forEach(element => {
if (element.style === 'h2') titlesElementsEn.push(element)
})
body.ar?.forEach(element => {
if (element.style === 'h2') titlesElementsAr.push(element)
})
let titlesEn = []
let titlesAr = []
titlesElementsEn?.forEach(element => {
element.children.forEach(child => {
titlesEn.push(child.text)
})
})
titlesElementsAr?.forEach(element => {
element.children.forEach(child => {
titlesAr.push(child.text)
})
})
const scrollToTitle = (id) => {
const target = document.getElementById(id)
const offest = 100
const targetPos = target.getBoundingClientRect().top + window.scrollY - offest
window.scrollTo({ top: targetPos })
}
const serializers = {
types: {
block: props => {
switch(props.node.style) {
case 'h2':
return <h2 id={props.children[0].replaceAll(' ', '-')}>{props.children}</h2>
default:
return <p>{props.children}</p>
}
},
code: props => {
return (
<Refractor
language={props.node.language}
value={props.node.code}
/>
)
},
image: props => {
return <SanityImage asset={props.node} alt={`${title.en} blog image`} />
}
}
}
return (
<div className='main-post'>
<div className='main-info'>
<div className='metadata-post'>
<p>{`${getMonthName(date, locale)} ${date.getDate()}, ${date.getFullYear()}`}</p>
<p>
{locale == 'ar-DZ' ? readTime.ar : readTime.en}
</p>
</div>
<h1>{locale == 'ar-DZ' ? title.ar : title.en}</h1>
<h3>{locale == 'ar-DZ' ? subtitle.ar : subtitle.en}</h3>
</div>
<div className='table-of-content-container'>
<div
className={showTable ? 'table-title-show' : 'table-title'}
onClick={() => setShowTable(v => !v)}
>
<Image
src={chevronIcon}
priority
alt='chevron'
className={locale == 'ar-DZ' ? 'chevron-icon-ar' : 'chevron-icon'}
/>
<h4>{locale == 'ar-DZ' ? 'الفهرس' : 'Table of contents'}</h4>
</div>
{
showTable &&
<div className='table-content'>
{
locale == 'ar-DZ' ? (
titlesAr.length > 0 &&
titlesAr.map((element, index) => {
return (
<p key={index} onClick={() => scrollToTitle(element.replaceAll(' ', '-'))}>
{element}
</p>
)
})
) :
(
titlesEn.length > 0 &&
titlesEn.map((element, index) => {
return (
<p key={index} onClick={() => scrollToTitle(element.replaceAll(' ', '-'))}>
{element}
</p>
)
})
)
}
</div>
}
</div>
<div className='body-text-container'>
<BlockContent
blocks={locale == 'ar-DZ' ? body.ar : body.en}
serializers={serializers}
/>
</div>
</div>
)
}
export default Post
One last thing is to style the code Refractor, there is pre made themes you can use or modify a little, you can find them here.
Code snippets styling
To use the theme just create a new css file with the name of the theme, paste the theme styles in it, and import it in '_app.js' file.
In this tutorial I used prism-dark theme and modified a little, this is the styles:
code[class*="language-"],
pre[class*="language-"] {
font-family: 'IBM Plex Mono', monospace;
color: #ccc;
background: none;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
direction: ltr;
background: #0f1016;
border-radius: 10px;
border: 1px solid gray;
margin-bottom: 20px;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #999;
}
.token.punctuation {
color: #ccc;
}
.token.tag,
.token.attr-name,
.token.namespace,
.token.deleted {
color: #e2777a;
}
.token.function-name {
color: #6196cc;
}
.token.boolean,
.token.number,
.token.function {
color: #f08d49;
}
.token.property,
.token.class-name,
.token.constant,
.token.symbol {
color: #f8c555;
}
.token.selector,
.token.important,
.token.atrule,
.token.keyword,
.token.builtin {
color: #cc99cd;
}
.token.string,
.token.char,
.token.attr-value,
.token.regex,
.token.variable {
color: #7ec699;
}
.token.operator,
.token.entity,
.token.url {
color: #67cdcc;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.inserted {
color: green;
}
Our blog now is almost complete, but still there is one last thing left to do.
Setting up the language switcher
To add a language switcher to our blog, we need to create a Navbar component that will contain the switcher element. We'll create the component in the 'components' folder and return a basic React component. Then we will create a Layout component in the same folder that wraps the 'Component' component in '_app.js' and put the Navbar in it. This way, the Navbar will be visible in all pages.
Here is the content of the 'Layout.js' file:
import { useRouter } from 'next/router'
import Navbar from './Navbar'
const Layout = ({ children }) => {
const { locale } = useRouter()
return (
<div dir={ locale == 'ar-DZ' ? 'rtl' : 'ltr' }>
<header>
<Navbar />
</header>
<main>
{ children }
</main>
</div>
)
}
export default Layout
And here is what '_app.js' will look like after the modifications:
import Layout from '../components/Layout'
import '../styles/global.css'
import '../styles/prism-dark.css'
const MyApp = ({ Component, pageProps }) => {
return (
<Layout>
<Component {...pageProps} />
</Layout>
)
}
export default MyApp
Now let's build the Navbar.
Building the Navbar
Firstly we need a state to control which language is selected, then we need a button to switch the language, here is the final code of the Navbar component:
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import verticalBarIcon from '../public/icons/vertical-line-30.png'
import langIcon from '../public/icons/geography-50.png'
const Navbar = () => {
const router = useRouter()
const { pathname, query, asPath, locale } = router
const [isArabic, setIsArabic] = useState(false)
const handleSwitchLang = () => {
setIsArabic(v => !v)
}
useEffect(() => {
if (isArabic) router.push({ pathname, query }, asPath, { locale: 'ar-DZ' })
else router.push({ pathname, query }, asPath, { locale: 'en-US' })
}, [isArabic])
return (
<nav>
<Link href={'/'}>
<h1>{locale == 'ar-DZ' ? 'مدونتي' : 'My blog'}</h1>
</Link>
<button className='lang-btn' onClick={handleSwitchLang}>
<Image src={langIcon} priority alt='lang-icon' />
<div className='lang-switcher'>
<span className={isArabic ? 'lang' : 'lang active-lang'}>
{locale == 'ar-DZ' ? 'الانكليزية' : 'English'}
</span>
<Image src={verticalBarIcon} priority alt='seperator' />
<span className={isArabic ? 'lang active-lang' : 'lang'}>
{locale == 'ar-DZ' ? 'العربية' : 'Arabic'}
</span>
</div>
</button>
</nav>
)
}
export default Navbar
I used a boolean state with a button because I only have two languages, you can customize this part to match your needs if you have more than two languages.
I also used 'useEffect' to handle router push between our languages.
The 'h1' element is used as logo, as well as a link to the home page too.
And by that our blog is now completed!
Conclusion
This article explains how to build a simple multilingual blog using Next.js and Sanity.io. It covers the creation of a blog page that fetches data from Sanity.io, renders the blog content using Next.js components, and includes a language switcher. The article explains each step in detail, from setting up the project to implementing the various features, and provides code snippets and helpful tips along the way. By following this tutorial, readers can learn how to create a simple, scalable multilingual blog using modern web development tools.
Note that we can use the keyword array we created in Sanity to imporve SEO, and that's by adding them to html head provided by Next js. I left it up to you to include them or not.
And finally I hope you enjoyed and found this helpful.