Ezeigbo Emmanuel

How to build a blog with NextJs 13, Typescript and Tailwind CSS

August 19, 2023

Ezeigbo Emmanuel

Ezeigbo Emmanuel

Creating a blog in 2023 has never been so easy, sanity.io together with NextJS 13 and typescript does wonders. In this tutorial, I am going to share my knowledge on how I created this blog.

Typescript

What are we going to build?

After reading this article, you will have a complete blog in your portfolio.
Integrated with all modern features like:

Getting Started

To get started, we must install NextJS, Tailwind CSS, and Typescript. This is just the base dependencies, we are still going to install some more as we advance in this tutorial.

To install these dependencies,
just type:

terminal


npx create-next-app@latest

After pressing enter, you'll be asked some questions.

Blog Post Image

Then automatically, NextJS 13, Typescript and Tailwind CSS are installed.

NOTE: In this tutorial, we are going to use the new NextJS App router.

After installation, cd to your app name

terminal


cd my-app

then

terminal


npm run dev

to run in the local server

If you've done everything correctly, you should see this;

Blog Post Image

Next, clear out everything in app/page.tsx, leave something like this

app/page.tsx


export default function Home() {
  return (
    <div>
      <h1 className="text-4xl">My Blog</h1>
    </div>
  )
}

because that is going to be our Home page


You are going to see some lines in the home page.
To get rid of those lines, jump into globals.css then clear out everything leaving

globals.css


@tailwind base;
@tailwind components;
@tailwind utilities;

behind.

I really do not want to make this article so long so i'm going to focus more on the functionality, you can work on the design yourself but i will drop the github link at the end of the tutorial.

Configuring Sanity


Sanity is a content management system that really works well with NextJS. It manages your content for you and it is also easy to implement too.

To use sanity, jump into your terminal and insert

terminal


npm create sanity@latest

If you don' have an account, it's going to prompt you to create one, after creating and linking your account, you're going to configure sanity by answering some questions.

Blog Post Image

It might take sometime, once everything is installed, head over to your browser and type localhost:3000/studio or any output folder you chose.

Blog Post Image

You are going to get this error for sure, click on continue. You will be redirected to sanity.io/manage
Make sure you click on the button to add the URL to CORS origin.
After adding to CORS origin, you will be redirected back to localhost:3000/studio

Blog Post Image

This is very good feature that sanity provides - auth, so that only authorized admin can change anything in the studio.
Make sure to use the same account that you registered for sanity to login to the studio.
After logging in, you should have your studio ready.

Blog Post Image

Our studio has the Post, Author and Category because we used a blog schema template. The template is nice but not to my satisfaction, I need to add some things. I need a description for my post but it is not there, so I'll have to add it myself also there is no Comment schema there I'll also need to add that one myself.


Head over to schemas/post.ts to add the description field

Blog Post Image

To add the description field is very easy, just go under the slug field and add:

schemas/post.ts


defineField({
      name: 'description',
      title: 'Description',
      type: 'string',
    }),

save your file and check your studio again

Blog Post Image

The description field is now there. Easy right?

Now let's work on the Comment schema

Head over to the schemas folder then create a new file - comments.ts
add this to the file:

comments.ts


import {defineField, defineType} from 'sanity'

export default defineType({
    name: "comment",
    type: "document",
    title: "Comment",
  fields: [
    defineField({
        name: "name",
        type: "string",
    }),
    defineField({
        title: "Approved",
        name: "approved",
        type: "boolean", 
        description: "Comments won't show on the site until they are approved"
    }),
    defineField({
        name: "email",
        type: "string"
    }),
    defineField({
        name: "comment",
        type: "text"
    }),
    defineField({
        name: "post",
        type: "reference",
        to: [{type: "post"}]
    }),
  ],
})

Sanity is easily customizable so you can add or delete any field you want. If you save this file, nothing is going to change in the studio because you have not added it yet to your array of schemas. Let's do that right now. Go to sanity/schema.ts, then add your comments.ts file to the array of types(make sure to import it first)

sanity/schema.ts


import { type SchemaTypeDefinition } from 'sanity'

import blockContent from './schemas/blockContent'
import category from './schemas/category'
import post from './schemas/post'
import author from './schemas/author'
import comments from './schemas/comments'

export const schema: { types: SchemaTypeDefinition[] } = {
  types: [post, author, category, blockContent, comments],
}

Save the file and check your studio.

Blog Post Image

Congratulations! Sanity has been successfully configured.

Getting Post from Sanity

First let's create and publish a minimum of three dummy posts, make your you fill every detail. Create and publish a new author and category if you've not created them before.

Blog Post Image

To get your posts from sanity studio, the first thing you have to do is to set up your environment variables.
Go to your root folder(my-app) and create a new file (must be named .env)

.env


NEXT_PUBLIC_SANITY_PROJECT_ID="projectId" //ee4e03f0
NEXT_PUBLIC_SANITY_DATASET="production"
NEXT_PUBLIC_SANITY_API_VERSION="2023-07-14"

Don't know where to get your projectId?
Go to sanity.io/manage

Blog Post Image


After setting up your environment variables, go to app/page.tsx.
There, we will import our sanity client and use groq (sanity open-source query language) to query for our data.

app/page.tsx


import { client } from "@/sanity/lib/client";

Sanity has already created the client automatically (you can check yours by navigating to my-app/sanity/lib/client.ts) but if you do not have your client, you can create yours:

my-app/sanity/lib/client.ts


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

export const client = createClient({
	projectId,
	dataset,
	apiVersion,
	useCdn: false,
});

Next we need to define our query variable using groq(To learn groq, you can go to the official documentation)
Move to the vision tab in your sanity studio to check what you are going to get with your query.

Blog Post Image

Then create a query variable and add the qroq query in it using backticks:

app/page.tsx


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

We have gotten our client and our groq query, now what we have to do is to fetch it.

app/page.tsx



import { groq } from "next-sanity";
import { client } from "@/sanity/lib/client";
...
export default async function Home() {
  const posts = await client.fetch(query);
  console.log(posts)
  return (
    <div>
      <h1 className="text-4xl">My Blog</h1>
    </div>
  )
}

Notice that i added async to the function, that is because we are awaiting the data from sanity. Save the file and check your terminal

Blog Post Image

You can see the data in your terminal and not in your browser console because NextJS page.tsx is a server component by default but can be changed to client by adding "use client"; at the top of the page.

We have gotten our data, stored in the posts variable, now what we have to do is to render each post individually and to do that, we have to use the famous .map() method.
I personally prefer to keep all blog items in a separate component for simplicity sake.
So inside my-app folder i'm going to create a new folder - components, then create a new file - BlogItem.tsx

BlogItem.tsx


import React from 'react'

function BlogItem({posts}) {
  return (
    <div>BlogItem</div>
  )
}

export default BlogItem

This BlogItem is going to take that posts as a prop from the page.tsx file
Now this is where typescript comes in, there's an error with the prop - Binding element 'posts' implicitly has an 'any' type.
Which means we have to create a type for our posts prop.

BlogItem.tsx


type Props = {
  posts: Post[]
}

function BlogItem({posts}: Props) {
  return (
    <div>BlogItem</div>
  )
}

export default BlogItem

Now there is no longer an error with the prop, the error is now with the type and that is because we haven't created it yet.
To remove that error, we have to create a typings.d.ts file to declare all our type definitions.It should be created in the root folder (my-app).
Since we are using typescript, we have to write the type of every data that is coming from sanity studio.
The first thing to add is the base type.

typings.d.ts


type Base = {
	_createdAt: string;
	_id: string;
	_rev: string;
	_type: string;
	_updatedAt: string;
};

After that, title type.

typings.d.ts


interface Title {
	_type: "string";
	current: string;
}


After the title, then the category type

typings.d.ts


interface Category extends Base {
	title: string;
	description: string;
}

"extends Base" simply means the properties of Base + the ones specified in the interface.

Next is Reference, Image and MainImage

typings.d.ts


interface Reference {
	_ref: string;
	_type: "reference";
}

interface Image {
	_type: "image";
	asset: Reference;
}

interface MainImage {
	_type: "image";
	asset: Reference;
}

Then Span, Block and Slug

typings.d.ts


interface Span {
	_key: string;
	_type: "span";
	marks: string[];
	text: string;
}

interface Block {
	_key: string;
	_type: "block";
	children: Span[];
	markDefs: any[];
	style: "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote";
}

interface Slug {
	_type: "slug";
	current: string;
}

Then our Author type

typings.d.ts


interface Author extends Base {
	bio: Block[];
	image: Image;
	name: string;
	slug: Slug;
}

You can see "bio: Block[]" what this means is that the type of bio is and array of the Block type declared above.After Author, we have our Comment type.

typings.d.ts


interface Comment {
    approved: boolean;
    comment: string;
    email: string;
    name: string;
    post: {
        _ref: string;
        _type: string;
    };
    _createdAt: string;
    _id: string;
    _rev: string;
    _type: string;
    _updatedAt: string;
}

You might have been thinking that we haven't created the Post type that brought us here in the first place, that is because the Post type is the combination of those types declared above.

typings.d.ts


interface Post extends Base {
	author: Author;
	body: Block[];
	categories: Category[];
	mainImage: Image;
	slug: Slug;
	title: string;
	description: string;
	comments: Comment[];
}

Now you should understand what i'm saying.
Go back to BlogItem.tsx and you will see that the error is gone and that is because typings.d.ts is automatically imported in NextJS 13.

The next thing to do is to collect the posts data from page.tsx

app/page.tsx


import BlogItem from "@/components/BlogItem";
...
return (
    <div>
      <h1 className="text-4xl text-4xl bg-slate-500 text-white p-7">My Blog</h1>
      <BlogItem posts={posts} />
    </div>
  )

Let's go back to BlogItem.tsx to display the individual posts using the .map() method.

BlogItem.tsx


function BlogItem({posts}: Props) {
  return (
    <div>
      <h2 className="text-2xl mt-5">Blog Posts:</h2>
      {posts.map(post => (
        <div key={post._id}>
          <h1>{post.title}</h1>
        </div>
      ))}
    </div>
  )
}

Save your file and check your browser

Blog Post Image

Now you can see the title of each post on your browser, next we want to get the image, it is easy but not the same way we got our title.
To use an image in NextJS we need a URL that we can add to our src attribute.
Now let's check if our data from sanity contains an image URL. To do that, we have to map through the post and console.log(post.mainImage)

BlogItem.tsx



...
function BlogItem({posts}: Props) {
  posts.map(post => console.log(post.mainImage))
  return (
...

save your file and check the terminal

terminal


{
  _type: 'image',
  alt: 'post-img',
  asset: {
    _ref: 'image-e2ffee6c583f482bb2090ddf100d41d84f22699e-700x400-png',
    _type: 'reference'
  }
}
{
  _type: 'image',
  alt: 'dummy-img',
  asset: {
    _type: 'reference',
    _ref: 'image-c4b808ea2abaa6b227e50070aa7ed1d284e3b82f-606x359-png'
  }
}
{
  _type: 'image',
  alt: 'post-img',
  asset: {
    _ref: 'image-edd6e9efe920d1b208f138b90b727668c0510dc7-729x439-png',
    _type: 'reference'
  }
}
{
  _type: 'image',
  alt: 'post-img',
  asset: {
    _ref: 'image-e2ffee6c583f482bb2090ddf100d41d84f22699e-700x400-png',
    _type: 'reference'
  }
}
{
  _type: 'image',
  alt: 'dummy-img',
  asset: {
    _type: 'reference',
    _ref: 'image-c4b808ea2abaa6b227e50070aa7ed1d284e3b82f-606x359-png'
  }
}
{
  _type: 'image',
  alt: 'post-img',
  asset: {
    _ref: 'image-edd6e9efe920d1b208f138b90b727668c0510dc7-729x439-png',
    _type: 'reference'
  }
}
{
  _type: 'image',
  alt: 'post-img',
  asset: {
    _ref: 'image-e2ffee6c583f482bb2090ddf100d41d84f22699e-700x400-png',
    _type: 'reference'
  }
}
{
  _type: 'image',
  alt: 'dummy-img',
  asset: {
    _type: 'reference',
    _ref: 'image-c4b808ea2abaa6b227e50070aa7ed1d284e3b82f-606x359-png'
  }
}

You will see something like this, there is no URL there so what are we going to do?
Sanity gave us createImageUrlBuilder from '@sanity/image-url', this is what we can use to build a URL for our image but luckily sanity has already built it for us.
Head over to my-app/sanity/lib/image.ts
There you will find urlForImage function.
Now let's go back to BlogItem.tsx to add our image since we have gotten our URLBuilder.

BlogItem.tsx


import  {urlForImage}  from '@/sanity/lib/image'

...

<div key={post._id}>
          <Image
            width={200}
            height={200}
            src={urlForImage(post.mainImage).url()}
            alt="postImage"
           />
          <h1>{post.title}</h1>
        </div>

You are going to get an error - Argument of type 'Image' is not assignable to parameter of type 'Image_2'.
Index signature for type 'string' is missing in type 'Image'


This is because the image type we declared in our typings.d.ts is not used here, instead type Image from sanity is used. So we need to get rid of that. Go to sanity/lib/image.ts again, then remove - import type { Image } from 'sanity'

The error should be gone.
Save the file and check your browser.

Blog Post Image

Woah, another error. We are using NextJS so we have to configure any URL in the config.
hostname "cdn.sanity.io" is not configured under images in your `next.config.js`

To solve this, just go to next.config.js then add:

next.config.ts


    images: {
        domains: ["cdn.sanity.io"]
    }

Save your file, reload and see that the error is gone and your image is shown on the browser.

Blog Post Image

I guess it's time to do some basic styling :)

BlogItem.tsx


import { urlForImage } from '@/sanity/lib/image'
import Image from 'next/image'

type Props = {
  posts: Post[]
}

function BlogItem({ posts }: Props) {
  return (
    <div>
      <h2 className="text-2xl mt-5 px-5">Blog Posts:</h2>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 px-5 gap-5">
        {posts.map(post => (
          <div key={post._id} className="flex pb-7 flex-col group cursor-pointer rounded-lg hover:bg-slate-300 border border-gray-400">
            <div className='relative w-full h-80 '>
              <Image
                fill
                src={urlForImage(post.mainImage).url()}
                alt="postImage"
                className="object-cover"
              />
            </div>

            <div className="px-3 py-5">
              <h1 className="text-2xl md:text-3xl font-bold">{post.title}</h1>
              <p>{post.description}</p>
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

export default BlogItem

This is just basic Tailwind CSS styling.

Blog Post Image

Looks so much better now.

Single Page View

We need to make BlogItem.tsx clickable and dynamic. To do that, we have to create a client component which is going to change the URL dynamically when clicked.
Go to your components folder then create a new file - Route.tsx
The Route.tsx will accept two props - children(everything inside) and route(the URL)

Route.tsx



function Route({
    children,
    route,
  }: {
    children: React.ReactNode;
    route: string;
  }) {
  return (
    <div>Route</div>
  )
}

export default Route


We will wrap the BlogItem component with this Route component

BlogItem.tsx


import Route from './Route'
...
{posts.map(post => (
      <Route route={`/blog/${post.slug.current}`}>
          <div key={post._id} className="flex pb-7 flex-col group cursor-pointer rounded-lg hover:bg-slate-300 border border-gray-400">
...
          </div>
      </Route>
))}

You can see route={`/blog/${post.slug.current}`}, this is what we are going to use as a link.
Let's go back to Route.tsx and finish the component by removing the div and adding Link.

Route.tsx



return (
    <Link href={route}>{children}</Link>
  )


What this does is that, when the BlogItem component is clicked, it will link to the given route since BlogItem is a child of Route.tsx

Blog Post Image

You can see the 404 error, that is because the page is not yet created.

Creating Dynamic Pages

We need each post to have their own page dynamically. In NextJS dynamic page is name using [] - [folderName]

Note: page.tsx is a reserved word in NextJS 13, check the official documentation for more informaton.

This is how we want our routes to be - /blog/slug (/blog/dummy-post-1)
So that means we have to create a folder - blog, then a dynamic folder - [slug], then the page.tsx for the [slug].

The folder structure schould be like this - my-app/blog/[slug]/page.tsx
After creating the page.tsx, add this code to it:

blog/[slug]/page.tsx


export default function Page({ params }: { params: { slug: string } }) {
  return <div>My Post: {params.slug}</div>
}
Blog Post Image

Our slug is gotten from the URL, this slug is what we are going to use to fetch the individual data from sanity.
So just like the BlogItem.tsx, the first thing we are going to do is to query, using groq.

blog/[slug]/page.tsx


import { groq } from "next-sanity";
const query = groq`
  *[_type == "post" && slug.current == $slug][0] {
        ...,
        author->,
        categories[]->,
        'comments': *[
          _type == 'comment' &&
          post._ref == ^._id &&
          approved == true],
    }`;

After that, we are going to await our client, so don't forget to make your function async.

blog/[slug]/page.tsx


const post: Post = await client.fetch(query, { params });

(post is of type Post)
The code so far:

blog/[slug]/page.tsx


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

export default async function Page({ params }: { params: { slug: string } }) {
    const query = groq`
    *[_type == "post" && slug.current == $slug][0] {
          ...,
          author->,
          categories[]->,
          'comments': *[
            _type == 'comment' &&
            post._ref == ^._id &&
            approved == true],
      }`;
      const post: Post = await client.fetch(query, params);
    return(
        <article>
            <h1 className="text-6xl text-center">{post.title}</h1>
        </article>
    )
}
Blog Post Image

It works but there's a problem, the header is gone :(
We want the header to be in every page so we will have to create a new component - Header.tsx, so that we will add it in the layout.tsx file.

components/Header.tsx


function Header() {
  return (
    <div>Header</div>
  )
}

export default Header

Now go to app/page.tsx, then cut the h1 tag there and paste it in the Header.tsx component.

components/Header.tsx


return (
    <h1 className="text-4xl bg-slate-500 text-white p-7"><Link href="/">My Blog</Link></h1>
  )

Now what we have to do is just to add it to the layout.tsx file

layout.tsx


export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Header />
        {children}
      </body>
    </html>
  )
}

Styling Individual Pages

We need to first work on the title, date, author name and the main image. We will work on the body later.

blog/[slug]/page.tsx


    return (
        <article className="mt-10 max-w-6xl mx-auto">
            <h1 className="text-6xl text-center font-bold">{post.title}</h1>
            <div className="flex flex-row gap-9 items-center justify-center my-5">
                <p>Posted By: {post.author.name}</p>
                <p>
                    Published at: {new Date(post._createdAt).toLocaleDateString("en-US", {
                        day: "numeric",
                        month: "long",
                        year: "numeric",
                    })}
                </p>
            </div>
            <div className='relative w-full h-96 '>
                <Image
                    fill
                    src={urlForImage(post.mainImage).url()}
                    alt="postImage"
                    className="object-cover"
                />
            </div>
        </article>
    )
Blog Post Image

We can't work on the post body directly, we have to use the @portabletext/react package, first, we have to install it:

terminal


npm install --save @portabletext/react

the PortableText component takes two props, value - which is going to be our post body then components - our custom component that we are going to build to specify the styles of our tags.
You can read more on the npm documentation

You can now add it to your code

blog/[slug]/page.tsx


<PortableText
    value={post.body}    
/>
Blog Post Image

It worked :), but it's not styled. That is where our custom component comes in. Text.tsx is going to be an object with styles.

components/Text.tsx


export const Text =  {

}

export default Text

The first style i'm going to add, is the image type.

components/Text.tsx


types: {
        image: ({ value }: any) => {
          return (
            <div className="relative w-full h-96 m-5 mx-auto">
              <Image
                className="object-contain"
                src={urlForImage(value).url()}
                alt="post-img"
                fill
              />
            </div>
          );
        }
      },

Under types, i'm going to add the styles for lists

components/Text.tsx



  list: {
    bullet: ({ children }: any) => (
      <ul className="ml-10 py-3 list-disc space-y-2 text-[18px] font-normal leading-8 tracking-wide text-[#ecedf1]">{children}</ul>
    ),
    number: ({ children }: any) => (
      <ol className="mt-lg list-decimal text-[18px] font-normal leading-8 tracking-wide text-[#ecedf1]">{children}</ol>
    ),
  },

After the list, we are going to add the block content styles.

components/Text.tsx


block: {
    h1: ({ children }: any) => (
      <h1 className="text-5xl py-10 font-bold">{children}</h1>
    ),
    h2: ({ children }: any) => (
      <h2 className="text-4xl py-10 font-bold">{children}</h2>
    ),
    h3: ({ children }: any) => (
      <h3 className="text-3xl py-10 font-bold">{children}</h3>
    ),
    h4: ({ children }: any) => (
      <h4 className="text-2xl py-10 font-bold">{children}</h4>
    ),
    normal: ({ children }: any) => (
      <div className={inter.className}>
        <p className="text-[18px] font-normal leading-8 tracking-wide py-4 text-[#ecedf1]">{children}</p>
      </div>
      
    ),
    blockquote: ({ children }: any) => (
      <blockquote className="border-l-sky-700 border-l-4 pl-5 py-5 my-5 text-[18px] font-normal leading-8 tracking-wide text-[#ecedf1]">
        {children}
      </blockquote>
    ),
  },


I'm using Inter font, so make sure that is configured by adding it to the top of the file:

components/Text.tsx


import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })

After this, we would have our marks, which will contain the styles for our links.

components/Text.tsx


  marks: {
    link: ({ children, value }: any) => {
      const rel = !value.href.startsWith("/")
        ? "noopener noreferrer"
        : undefined;

      return (
        <Link
          href={value.href}
          rel={rel}
          className="underline text-sky-500 hover:text-white"
        >
          {children}
        </Link>
      );
    },
  },

The next thing to do is to pass this component as a prop in the PortableText component.

blog/[slug]/page.tsx


<PortableText
    value={post.body}  
    components={Text}  
/>

Read More Posts feature

For this feature we will create a new component - MoreBlogs.tsx
Then we will copy everything from BlogItem.tsx to MoreBlogs.tsx, after that, we are going to modify somethings.
The first thing I did was to change the name to MoreBlogs:

MoreBlogs.tsx


function MoreBlogs({ posts }: Props) {
...
export default MoreBlogs

then I changed the title.

MoreBlogs.tsx


<h2 className="text-2xl mt-10 px-5">Read More Posts</h2>

Next we need to add this component in the blog/[slug]/page.tsx file

MoreBlogs.tsx


<PortableText
   value={post.body}
   components={Text}
/>

<MoreBlogs />

You are going to see an error, that is because we need to pass all the posts data as a prop, but the only data that we have in blog/[slug]/page.tsx is a single post data. We have to query for all posts in the [slug]/page.tsx

[slug]/page.tsx


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

Add it under the query variable.
Now we just have to fetch it.

Under the post variable, declare a new variable - posts

[slug]/page.tsx


const posts = await client.fetch(allPosts);

All we need to do now is to pass the posts data as a prop

[slug]/page.tsx


<MoreBlogs posts={posts} />

Save your file and check your browser.

Blog Post Image

It works, but there are two problems.

To solve the second problem, we are going to use the title as a filter term, that means we will need the individual data from blog/[slug]/page.tsx, we are going to accept another prop in MoreBlogs.tsx

[slug]/page.ts


...
type Props = {
  posts: Post[];
  post: Post;
}

function MoreBlogs({ posts, post }: Props) {
  return (
...

Now let's go back to blog/[slug]/page.tsx to pass the post data

[slug]/page.tsx


<MoreBlogs posts={posts} post={post} />

Back to MoreBlogs.tsx, we are going to filter the posts:

MoreBlogs.tsx


const filteredPosts = posts.filter(item => item.title !== post.title)

This is going to return all posts that their title is not the same as the one clicked on.

Now we would map through our filteredPost instead posts.

MoreBlogs.tsx


...
{filteredPosts.map(post => (
      <Route route={`/blog/${post.slug.current}`}>
...
Blog Post Image

You can see that dummy post 1 is gone, the next thing to do is to restrict the number of posts.
We are just going to slice the filteredPosts

MoreBlogs.tsx


const filteredPosts = posts.filter(item => item.title !== post.title).slice(0, 3)


Since we just need three posts.

Comment Feature

Create a new component - Comments.tsx
The Comment component is going to take the post prop

Comments.tsx


type Props = {
  post: Post;
}

function Comments({post}: Props) {
  return (
    <div>Comments</div>
  )
}

export default Comments

Then add it under the MoreBlogs component in blog/[slug]/page.tsx

blog/[slug]/page.tsx


<MoreBlogs posts={posts} post={post} />
<Comments post={post} />

In Comments.tsx, we are basically going to have a form with inputs.

Comments.tsx


function Comments({post}: Props) {
  return (
    <form className="flex flex-col p-5 my-10 max-w-2xl mx-auto mb-10">
      <h1 className="text-3xl font-bold">Comment on blog</h1>
      <input
        className="shadow border border-gray-400 bg-transparent rounded py-2 px-3 form-input mt-1 block w-full"
        placeholder="Your name"
        type="text"
      />
      <input
        className="shadow border border-gray-400 bg-transparent rounded py-2 px-3 form-input mt-1 block w-full"
        placeholder="Your email"
        type="email"
      />
      <textarea
        className="shadow border border-gray-400 bg-transparent rounded py-2 px-3 form-input mt-1 block w-full"
        placeholder="Your comment"
        rows={8}
      />
    </form>
  )
}

This is just basic styling, no function yet.

For this tutorial, we are going to use react-hook-form to validate our form.
We first have to install it:

terminal


npm install react-hook-form

After installing, We are only going to need useform and SubmitHandler, so make sure you import that:

Comments.tsx


import { useForm, SubmitHandler } from "react-hook-form";

Then we will also need register, handleSubmit and formState from the useForm we imported:

Comments.tsx


...

function Comments({post}: Props) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

...

After this, we are going to create a type to tell it the fields we are going to work with:

Comments.tsx


type Input = {
  _id: string;
  name: string;
  email: string;
  comment: string;
}

function Comments({post}: Props) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } 

Note that we also added the type to the useForm - useForm<Input>
After saving your file, you are going to get an error - Error: createContext only works in Client Components. Add the "use client" directive at the top of the file to use it. Read more: https://nextjs.org/docs /messages/context -in-server-component

According to the error, we just need to add "use client" to the top of our page because somehow we've started working with a server component.

Look at our Input type, we have name, email,comment on our form but no _id, so we need a way to add _id to the form inputs.
Under the text area, add another input but make it hidden:

Comments.tsx


      <input
        type="hidden"
        name="_id"
        value={post._id}
      />

Next, we have to register all our fields using the register function form useForm

Comments.omments.tsxtsx


      <input
        {...register("name")}
        className="shadow border border-gray-400 bg-transparent rounded py-2 px-3 form-input mt-1 block w-full"
        placeholder="Your name"
        type="text"
      />
      <input
        {...register("email")}
        className="shadow border border-gray-400 bg-transparent rounded py-2 px-3 form-input mt-1 block w-full"
        placeholder="Your email"
        type="email"
      />
      <textarea
        {...register("comment")}
        className="shadow border border-gray-400 bg-transparent rounded py-2 px-3 form-input mt-1 block w-full"
        placeholder="Your comment"
        rows={8}
      />

      <input
        {...register("_id")}
        type="hidden"
        name="_id"
        value={post._id}
      />

Make sure you pass in the correct names.
It's okay but we don't want them to send in empty details, so we have to mark the name, email and comment as required.

Comments.tsx


      <input
        {...register("name", { required: true })}
        className="shadow border border-gray-400 bg-transparent rounded py-2 px-3 form-input mt-1 block w-full"
        placeholder="Your name"
        type="text"
      />

Add { required: true } as a second parameter to email and comment.

So once the user tries to submit an empty field, it is going to return an error.

After this, let's add out submit input

Comments.tsx


<input
    type="submit"
    className="shadow bg-sky-700 hover:bg-sky-500 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded cursor-pointer"
/>

Now, what happens when we click the submit button?
Let's create an onSubmit function which will be of type SubmitHandler from react-hook-form

Comments.tsx


const onSubmit: SubmitHandler<Input> = (data) => {
   console.log(data.name)
}

After creating this function, we'll add it to the form tag using the onSubmit event handler and wrapping the onSubmit function inside the handleSubmit function from useForm. Woah!! That seems to be a lot:

Comments.tsx


<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col p-5 my-10 max-w-2xl mx-auto mb-10">

Save you file, fill out the fields and press the submit button:

Blog Post Image

It works!

So now what happens when we try to submit an empty field?
Currently, when you submit an empty field, nothing happens but we don't want that.

Displaying the error

If you console.log() the errors from formState: { errors }, You will see that it returns an object with the name of any required field that is empty, now we have to use that to display the error to the user.

Before the submit input add the following:

Comments.tsx


<div className="flex flex-col p-5">
   {errors.name && (
       <span className="text-red-500">The name field is required!</span>
   )}
   {errors.email && (
       <span className="text-red-500">The email field is required!</span>
    )}
    {errors.comment && (
        <span className="text-red-500">
            The comment field is required!
         </span>
     )}
</div>

What this does is that it checks if there is the name, email or comment property in the errors object, if there is, it's going to display the error, if not, nothing is going to be displayed.

Pushing comment to sanity

The next step now is to push the data gotten from the form to sanity.
To do that, we'll make use of the .create() function that sanity provides.

Comments.tsx


import { client } from "@/sanity/lib/client";
...
  const onSubmit: SubmitHandler<Input> = (data) => {
    client.create({
      _type: "comment",
      post: {
        _type: "reference",
        _ref: data._id,
      },
      name: data.name,
      email: data.email,
      comment: data.comment,
    })
    .then(() => {
      console.log("Submitted: ", data);
    })
    .catch((err) => {
      console.log("Not submitted: ", err);
    });
  }

What this does is that it creates a new document then set the properties to what we got from the form. The code is easy to read.
Save your file, fill the comment and press submit.

Blog Post Image

Another error :( - This is because we don't have the permission to create a new document from our site. To get our permission, go to sanity.io/manage, click on your project then navigate to API, then move to Tokens, then click on Add API token, make sure it is an editor token:

Blog Post Image

Click save, then copy the token. You can paste it somewhere, because once you reload the page, you cannot copy it again.

Now that you have gotten the token, go back to your code, navigate to my-app/sanity/env.ts

Then paste this under your projectId:

my-app/sanity/env.ts


export const token = assertValue(
  "your token here",
  'Missing environment variable: SANITY_API_TOKEN'
)

Replace your token here with your copied token, then save the file.

After that go to my-app/sanity/lib/client.ts

then import it and add it to the client:

my-app/sanity/lib/client.ts


import { apiVersion, dataset, projectId, useCdn, token} from '../env'

export const client = createClient({
  apiVersion,
  dataset,
  projectId,
  useCdn,
  token
})

save the file and go back to your browser and try the comment feature again.

Blog Post Image

Now let's go to our studio to check if it has truly been created.

Blog Post Image

Wow it worked! As you can see the header remained in the studio, that's because the studio is also a child in the layout.tsx file.
You can leave yours like this but I prefer mine to be separated.
To separate it, you just need to create two invincible folders, user and admin. We use brackets to create invincible folders in nextJS, (user), (admin)
Now, move the studio folder to the (admin) folder then move the blog folder, page.tsx, layout.tsx to the (user) folder.
If you encounter any problem while moving the files, close your vscode, then move it traditionally, but make sure to update any imports.

Blog Post Image

Now the studio and our app have a different layout.

Back to our comment feature

The comment has been created in our sanity studio.


Displaying the comment

We,ve gotten the comment, so let's display it when approved.

Comments.tsx


      {/* Displaying Comments */}
      <div>
        <h1 className="text-4xl">Comments</h1>
        {post.comments.length < 1 ? (
          <p>Be the first to comment </p>
        ) : (
          <div>
            {post.comments.map((comment: any) => (
              <div key={comment._id}>
                <p className="">
                  <span className="text-slate-800 text-base font-bold">
                    {comment.name}:
                  </span>{" "}
                  {comment.comment}
                </p>
              </div>
            ))}
          </div>
        )}
      </div>

What this code does is that it checks if the there is any comment, if there is, it maps through and display them but if there is none, it'll display the text - Be the first to comment

Blog Post Image

now let's go back to our sanity studio and approve the comment

Your comment is not going to be displayed, that is because we have not created our API for the comment. Let's create a new folder - api in the app folder.
then create - commentApi.ts file in the api folder.

After that paste this code:

commentApi.ts


import type { NextApiRequest, NextApiResponse } from 'next'
import { client } from '@/sanity/lib/client';

export default async function createComment(
  req: NextApiRequest,
  res: NextApiResponse
) {
    const {_id, name, email, comment} = JSON.parse(req.body);

    try {
        await client.create({
            _type: "comment",
            post: {
                _type: "reference",
                _ref: _id
            },
            name,
            email,
            comment
        });
    } catch (err){
        return res.status(500).json({message: "Couldn't submit comment!", err})
    }

    console.log("comment submitted")
    return res.status(200).json({ message: 'Comment submited' })
}

Save your file and hard reload your page.

Blog Post Image

Congratulations, you are done with the most complex feature :)

Search Functionality

Take this as a little do it yourself.
Hint: Convert the search term to lower case then use the filterMethod, something like this:


const newFilter:Post[] = posts.filter(({ title }): boolean =>
   title.toLowerCase().includes(searchWord)
);

But Note that async and useState cannot be in the same file, because async must be a server component while useState must be a client component. So that means that you may have to move your components to a general component, then add the useState there with the "use client". Then add it to the main component with the async function.

Deployment

We are going to deplay to vercel, but first push your code to github.
Then go to https://vercel.com, create account if you haven't.
Create a new project and import your code from github.

Blog Post Image

Don't deploy immediately, we have to set up our environment variables

Blog Post Image

Blog Post Image

Then click Deploy

Blog Post Image

Thanks for reading. Don't forget to share.
Contact for source code!
The link will be sent to you immediately.

🚀 If you found value in this blog post and want to see more quality coding content, please consider supporting me with a virtual coffee ☕. Your support helps me keep creating and sharing knowledge with the coding community. It's the small gestures that make a big difference. Thank you!

Buy me a coffee

Share post

Read more blogs

Enjoyed this article?

Leave a comment


Comments


Idan: Idan

Bonsa Asafa Ayana: Thanks bro

Ben: Thanks so much for taking the time to explain this to me, I really appreciate it.

Jazz Dev: Really nice article. I will sure use this to integrate blog section into my portfolio.