
Adding Categories with Sanity.io and Next.js 14
I start having a bit more content, and some upgrades are necessary. Last time, I created a"Load More" button, and now it's time to set some categories for the blog. It's more complex than it looks like, and it required several steps to be done properly. I already had a category type in Sanity, but it was not comprehensive enough for my needs.
What needs to be done
Add category slug property in the Sanity category
Update query function for getting article by slug and getting category content
Create category page in NextJs. This one is almost a copy/paste of the article page with an updated get all article query that also takes a category slug as optional parameter.
Adding category tags in article
Add a new sitemap endpoint
And voilà, a pretty category page:

Tags at the beginning of the article:

Code snippets
These are the parts that created the most issues for me.
Updated category schema for Sanity. Small little tweak, I also used the prepare() function so I can see which categories are currently empty from Sanity studio
typescriptimport { defineField, defineType } from "sanity"; export default defineType({ name: "category", title: "Category", type: "document", fields: [ defineField({ name: "title", title: "Title", type: "string", }), defineField({ name: "description", title: "Description", type: "text", }), defineField({ name: "slug", title: "Slug", type: "slug", options: { source: "title", }, }), ], preview: { select: { title: "title", slug: "slug.current", }, prepare({ title, slug }) { return { title, subtitle: slug ? `/${slug}/` : "Missing slug", }; }, }, });
Updating the article query was a bit tricky, I had to start using Sanity expanded references. Took me a minute to get it right. Also updated getAllArticles() to accept slug as optional parameters. This function starts looking a bit fat, I will have to refactor it eventually
typescriptimport { createClient } from "@sanity/client"; import imageUrlBuilder from "@sanity/image-url"; const mySanityClient = createClient({ projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, // you can find this in sanity.json dataset: process.env.NEXT_PUBLIC_SANITY_DATASET, // or the name you chose in step 1 useCdn: false, // `false` if you want to ensure fresh data apiVersion: "2024-01-01", // use a UTC date string }); const getArticleBySlug = async (slug: string) => { const item = await mySanityClient.fetch( `*[_type == "post" && slug.current == $slug][0]{ title, _id, publishedAt, description, body, mainImage, seo, "slug": slug.current, categories[]->{ title, description, _id, "slug": slug.current } }`, { slug } ); if (!item) { throw new Error("Item not found"); } return item; }; const getArticleById = async (id: string) => { const item = await mySanityClient.fetch( `*[_type == "post" && _id == $id][0]{ title, _id, publishedAt, description, body, mainImage, seo, "slug": slug.current, categories[]->{ title, description, _id, "slug": slug.current } }`, { id } ); return item; }; const getAllArticles = async ({ page = -1, pageSize = 0, category = "", }: { page?: number; pageSize?: number; category?: string } = {}) => { let skip: number = 0; let filter: string = ""; let categoryFilter: string = ""; if (page > 0 && pageSize > 0) { skip = (page - 1) * pageSize; // Calculate the number of items to skip filter = `[${skip}...${skip + pageSize}]`; // Add the filter to the query } if (category) { categoryFilter = ` && "${category}" in categories[]->slug.current`; } const items = await mySanityClient.fetch( `*[_type == "post" ${categoryFilter}]{ title, _id, _createdAt, publishedAt, description, body, mainImage, "slug": slug.current } | order(publishedAt desc)${filter}` ); return items; }; const getAllCategories = async () => { const categories = await mySanityClient.fetch( `*[_type == "category"]{ title, _id, "slug": slug.current, description }` ); return categories; }; const builder = imageUrlBuilder(mySanityClient); const urlFor = (source: any) => { return builder.image(source); }; export { mySanityClient, getAllCategories, getArticleBySlug, getArticleById, getAllArticles, urlFor, };
Last step was to update the sitemap and submit it on Google Console, but that's quite easy to do especially sinceI already have one for the regular articles.
Keep Reading

A big cleanup day for this blog
April 11, 2026 | Today I migrated the blog away from Sanity, tightened the Markdown pipeline, cleaned old assets, updated tooli...
Learn More
The Illusion of Mobile Productivity
April 11, 2026 | We often mistake accessibility for efficiency. This post explores why mobile devices are great for triage but ...
Learn More