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

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:
- Single Page view
- Read more blogs Component
- Comment Feature
- and more...
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.

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;

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.

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

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

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.

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

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

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.

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.

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

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.

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

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

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.

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.

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.

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

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>
}

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>
)
}

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>
)

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}
/>

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.

It works, but there are two problems.
- If there are five total posts, it's going to show the whole five, so that means, we have to set a limit to what it can show.
- We already clicked dummy post 1, dummy post 1 shouldn't show in the component anymore.
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}`}>
...

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:

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.

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:

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.

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

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.

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

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.

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.

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


Then click Deploy

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 coffeeShare post
Read more blogs

October 3, 2023
How to convert a NextJS 13 app to a Progressive Web App (PWA)
Progressive Web Apps have gained significant traction in recent years due to their ability to combine the best of web and mobile app experiences. They are responsive and available on a variety of devices, and they provide offline access, quick load times, and a smooth user interface. This tutorial teaches how to convert your normal NextJS app to a Progressive Web App.
Read Post

September 26, 2023
How to create a fitness app with NextJS and Typescript
In today's fast-paced world, staying healthy and fit is a priority for many of us. In this tutorial, we will embark on a journey to develop a cutting-edge fitness application that leverages the power of Next.js and TypeScript.
Read Post
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.