Why Build Comments Into Your Stack?
Third-party comment widgets solve a problem, but they introduce a new one: your user-generated content lives in a silo, disconnected from the content it belongs to. You also inherit someone else's JavaScript, their monetization choices, and their data ownership model—trade-offs that many Jamstack sites accept without much thought.
A more cohesive approach keeps comments in the same data store and CMS as your actual content. That way, editors manage everything in one place, the front end queries it through one unified API, and developers don't have to stitch together disparate services. With Next.js and Sanity.io, you can build that commenting engine yourself.
Where the Pieces Fit
Next.js handles the rendering and server-side logic. Its API routes function as serverless endpoints, giving us a secure place to process form submissions without exposing credentials to the browser. The static site generation capabilities also mean our pages can be served quickly while still pulling real-time data.
Sanity.io provides the structured content backbone. It's more than a headless CMS—it's a data store that encourages modeling content as structured, queryable data. Comments fit naturally alongside posts and authors in that model.
The starter project we'll use is a modified version of Vercel's Next.js blog template, combining the front end and Sanity Studio into a single repository so everything runs from one place.
Getting the Project Running Locally
Starting the Sanity Studio
Clone the starter repository and you'll find two main directories: the Next.js front end and the Sanity Studio. Before we can do anything, both need to be running locally.

From the /studio directory, install the required dependencies for the Studio and its API client.
# Install the Sanity CLI globally
npm install -g @sanity/cli
# Move into the Studio directory and install the Studio's dependencies
cd studio
npm install
Then run the Sanity CLI to initialize a new project:
# If you're not logged into Sanity via the CLI already
sanity login
# Run init to set up a new project (or connect an existing project)
sanity init
The CLI will prompt you to reconfigure the existing setup and either connect to an existing project or create a new one. Creating a new project is the straightforward path here. You'll also name your dataset—the default production is fine for most cases.
The init command writes your project ID and dataset name back to studio/sanity.json. Keep that file accessible; you'll need the project ID later.
Start the Studio in development mode:
# From within /studio
npm run start
The Studio compiles and serves at http://localhost:3333. Before moving to the front end, create at least one author and one blog post in the Studio. Changes save to the data store in real time, even when working locally. Make sure to publish everything so the content is publicly available through the API.
Connecting Next.js to Sanity
The Next.js front end lives in the /blog-frontend directory. The template includes an example environment file at /blog-frontend/.env.local.example. Rename it to .env.local to start configuring the connection.
You'll need three values. The first is an API token. In the Sanity dashboard under Settings → API, create a new token. Since our application writes data back to Sanity, this needs Read + Write permissions. Copy the token value immediately—Sanity only shows it once.

The project ID is also in the dashboard at the top of the project page. Paste it as NEXT_PUBLIC_SANITY_PROJECT_ID in your .env.local file. The preview secret can stay empty for this demo; it only matters for Next.js preview mode.
Finally, you need to configure CORS origins so the local front end can make requests. Under Settings → API in the Sanity dashboard, add http://localhost:3000 as a new CORS origin. You'll add your production URL to this list when you deploy.
With the environment configured, start the Next.js development server:
# From inside /blog-frontend
npm run dev
Visit http://localhost:3000 and you should see a working blog pulling content from Sanity.
Modeling Comments in the Schema

To store comments, we need a new document type in the Studio. Create a new file at /studio/schemas/comment.js. This JavaScript file exports an object that defines the document structure and tells the Studio how to display and handle the data.
A comment needs the basics: a name, an email, and a text body. It also needs a reference field to associate it with a specific post. Finally, a boolean field for approval status lets you moderate comments before they appear on the live site.
export default {
name: 'comment',
type: 'document',
title: 'Comment',
fields: [
{
name: 'name',
type: 'string',
},
{
title: 'Approved',
name: 'approved',
type: 'boolean',
description: "Comments won't show on the site without approval"
},
{
name: 'email',
type: 'string',
},
{
name: 'comment',
type: 'text',
},
{
name: 'post',
type: 'reference',
to: [
{type: 'post'}
]
}
],
}
Register the new schema by adding it to /studio/schemas/schema.js:
import createSchema from 'part:@sanity/base/schema-creator'
import schemaTypes from 'all:part:@sanity/base/schema-type'
import blockContent from './blockContent'
import category from './category'
import post from './post'
import author from './author'
import comment from './comment' // <- Import our new Schema
export default createSchema({
name: 'default',
types: schemaTypes.concat([
post,
author,
category,
comment, // <- Use our new Schema
blockContent
])
})
Once registered, the Studio's main content list will show a Comment section. You can manually add your first comment here to test that the schema works, even before any front-end UI exists.
After adding a few comments, you'll notice the list view preview isn't very helpful—it just shows the document type. You can improve that output substantially with a small amount of configuration.
Improving the Studio Comment Preview

Every OpenPreview script
Schema can include a preview object that controls what shows up in Sanity's list views. Add a select property to pull relevant fields, and a prepare() method that transforms them into display-ready data.
export default {
// ... Fields information
preview: {
select: {
name: 'name',
comment: 'comment',
post: 'post.title'
},
prepare({name, comment, post}) {
return {
title: `${name} on ${post}`,
subtitle: comment
}
}
}
}
}
The title displays larger and more prominently, with the subtitle smaller and faded below it. This configuration takes the commenter's name and the post title for the title and uses the comment body for the subtitle—a much more scannable list.
Querying Comments for Each Post

The front end has a dedicated API module at /blog-frontend/lib/api.js with functions for fetching Sanity data. We need to modify getPostAndMorePosts, which handles data fetching for individual post pages. This function runs two GROQ queries: one for the current post and one for related posts. We're modifying the first one.
GROQ queries have three components: a filter that determines which documents are retrieved, an optional pipeline for transformations like ordering, and an optional projection that specifies exactly which fields to return. The existing post query already returns the necessary fields plus the content body.
To attach a comment array to that result, we chain a sub-query as part of the main projection. The syntax filters for all documents where the comment's post reference matches the current post's _id and where the comment has been approved for publication:
*[_type == "comment" && post._ref == ^._id && approved == true]
The query checks that each document is a comment, that its post reference matches the current post, and that it's already approved. The selected projection returns name, publish date, and the comment text.
curClient.fetch(
`*[_type == "post" && slug.current == $slug] | order(_updatedAt desc) {
${postFields}
body,
'comments': *[_type == "comment" && post._ref == ^._id && approved == true]{
_id,
name,
email,
comment,
_createdAt
}
}`,
{ slug }
)
.then((res) => res?.[0]),
Because the query may return an array, the code picks the first result at index zero.
Rendering the Comments Component
Individual post pages render from /blog-frontend/pages/posts/[slug].js. The updated API function already passes comments down to this page component. Add the new comments component right after the closing </article> tag, where reader comments typically appear.
// ... The rest of the component
</article>
// The comments list component with comments being passed in
<Comments comments={post?.comments} />
Create the component file at /blog-frontend/components/comments.js. Following the template's conventions, it accepts the comments array and maps each item to a list element with proper markup. The existing <Date /> component formats the timestamps consistently.
# /blog-frontend/components/comments.js
import Date from './date'
export default function Comments({ comments = [] }) {
return (
<>
<h2 className="mt-10 mb-4 text-4xl lg:text-6xl leading-tight">Comments:</h2>
<ul>
{comments?.map(({ _id, _createdAt, name, email, comment }) => (
<li key={_id} className="mb-5">
<hr className="mb-5" />
<h4 className="mb-2 leading-tight"><a href={`mailto:${email}`}>{name}</a> (<Date dateString={_createdAt}/>)</h4>
<p>{comment}</p>
<hr className="mt-5 mb-5" />
</li>
))
</ul>
</>
)
}
Import the component at the top of the post page file, and posts with approved comments will display them automatically.
import Comments from '../../components/comments'
That covers displaying manually entered comments. The more interesting problem is letting readers submit their own.
Building the Comment Form
The project uses the react-hook-form package for form handling—standard React tooling that avoids reinventing validation and submission logic.
npm install react-hook-form
In the post page component, add a <Form /> component directly after the comments list. The post's _id needs to be passed as a prop because it's what ties each comment to its parent post.
// ... Rest of the component
<Comments comments={post.comments} />
<Form _id={post._id} />
The form component lives at /blog-frontend/components/form.js. Its core responsibilities are capturing the form values, validating required fields, and submitting the payload to the backend:
export default function Form ({_id}) {
// Sets up basic data state
const [formData, setFormData] = useState()
// Sets up our form states
const [isSubmitting, setIsSubmitting] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState(false)
// Prepares the functions from react-hook-form
const { register, handleSubmit, watch, errors } = useForm()
// Function for handling the form submission
const onSubmit = async data => {
// ... Submit handler
}
if (isSubmitting) {
// Returns a "Submitting comment" state if being processed
return <h3>Submitting comment…</h3>
}
if (hasSubmitted) {
// Returns the data that the user submitted for them to preview after submission
return (
<>
<h3>Thanks for your comment!</h3>
<ul>
<li>
Name: {formData.name} <br />
Email: {formData.email} <br />
Comment: {formData.comment}
</li>
</ul>
</>
)
}
return (
// Sets up the Form markup
)
}
The component's markup includes a hidden input containing the post reference ID, rendered in the form's JSX:
// Sets up the Form markup
<form onSubmit={handleSubmit(onSubmit)} className="w-full max-w-lg" disabled>
<input ref={register} type="hidden" name="_id" value={_id} />
<label className="block mb-5">
<span className="text-gray-700">Name</span>
<input name="name" ref={register({required: true})} className="form-input mt-1 block w-full" placeholder="John Appleseed"/>
</label>
<label className="block mb-5">
<span className="text-gray-700">Email</span>
<input name="email" type="email" ref={register({required: true})} className="form-input mt-1 block w-full" placeholder="[email protected]"/>
</label>
<label className="block mb-5">
<span className="text-gray-700">Comment</span>
<textarea ref={register({required: true})} name="comment" className="form-textarea mt-1 block w-full" rows="8" placeholder="Enter some long form content."></textarea>
</label>
{/* errors will return when field validation fails */}
{errors.exampleRequired && <span>This field is required</span>}
<input type="submit" className="shadow bg-purple-500 hover:bg-purple-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded" />
</form>
The <form> element uses the handleSubmit() hook from react-hook-form to wrap our custom submission logic. Each required input registers with ref for validation. The actual submission handler onSubmit() manages two things: it updates the component's local state to reflect the submission progress, and it POSTs the form data to a serverless function with a fetch() request.
// Function for handling the form submission
const onSubmit = async data => {
setIsSubmitting(true)
setFormData(data)
try {
await fetch('/api/createComment', {
method: 'POST',
body: JSON.stringify(data),
type: 'application/json'
})
setIsSubmitting(false)
setHasSubmitted(true)
} catch (err) {
setFormData(err)
}
}
Posting directly from the browser to the Sanity API would expose our write access token—a security risk we don't need to take. The serverless function acts as a proxy, receiving the public request, applying our server-side credentials, and forwarding the document creation to Sanity.
The API Route for Comment Creation
Next.js API routes live alongside page routes in /blog-frontend/pages/api. Create a new file at /blog-frontend/pages/api/createComment.js for this endpoint.
The route needs a Sanity client with write permissions. The module at /blog-frontend/lib/sanity.js already exports a readonly client for public queries and a read+write client using the preview token from your environment file. The latter is what we'll use here.
Inside the route's default handler, destructure the form data from the request body. Then call the client's create() method with the new document object. The document needs a _type matching our schema name, and each field we want to persist. Posting to the right post requires converting the submitted _id into a proper reference structure:
// This Next.js template already is configured to write with this Sanity Client
import {previewClient} from '../../lib/sanity'
export default async function createComment(req, res) {
// Destructure the pieces of our request
const { _id, name, email, comment} = JSON.parse(req.body)
try {
// Use our Client to create a new document in Sanity with an object
await previewClient.create({
_type: 'comment',
post: {
_type: 'reference',
_ref: _id,
},
name,
email,
comment
})
} catch (err) {
console.error(err)
return res.status(500).json({message: `Couldn't submit comment`, err})
}
return res.status(200).json({ message: 'Comment submitted' })
}
The route returns a success or error status based on Sanity's response. After this is in place, submitting the form on a blog post creates a new comment document. Because we enforce an approval workflow, you'll need to log into the Sanity Studio and approve the comment before it becomes visible on the site.
Extending the Comment Engine
This foundation gives you full control over both sides of the commenting flow. Since comments live in the same content store as your posts, editors and developers get one unified view of a running site. Good next steps when you build this out for production:
- Trigger an email notification when new comments arrive, via SendGrid or a similar service.
- Use Sanity's structure builder to create custom views for approved, pending, and flagged comments.
- Add spam protection with Google's invisible reCAPTCHA, integrated into the existing form.
- Fetch and store Gravatar avatars when comments are approved.



