
While I love designing and building server-side code, I have to admit I’m having much more fun coding the frontend. There’s something satisfying about writing the code and seeing the results live. I enjoy the calmness that comes with frontend development, which is why I prefer to use minimal libraries for it.
When it comes to writing blog content, however, I prefer the backend. I find that I need a clear plan before diving into backend development, and this allows me to gather more resources for a blog post.
I’ve been putting off sharing this for a while, but I’m really proud of how I built this entire blog page. I love the design and how smooth every interaction is. So, today, I’m excited to share a tutorial on how I built the toggle category bar step by step. This will be part of a series, as I have so much more to share about this blog.

I had a blog before this blog. My very first blog was built with just React, which really gave me lots of trouble when it came to server-side rendering.
And I didn’t know much about how to actually build a text editor, and let me tell you, it wasn’t an easy journey.
You can read this article to see how I hustled my way to make everything work.
Looking back now, it makes me cry just thinking about the time I spent building the text editor code by code. I had to type every line of code to render each h1, h2, and so on. It makes me very emotional whenever I look back on the time I started my coding journey; it was very, VERY tough.

I even had to inject the meta content somehow - I had zero knowledge about how SSR works at that time, but it was kind of brilliant and very creative.
I also wrote a blog post about it so you can see for yourself how I did it, if you’re ever curious.
Anyways, since this is going to be the first part of my blog coding journey, I got too emotional about it; I talked too much! Let’s get started now!
Project Structure
frontend/
├── app/ # App Router pages & API routes
│ ├── about/
│ ├── blog/[slug]/
│ ├── pricing/
│ ├── search/
│ ├── studio/ # Embedded Sanity Studio
│ └── api/
│ ├── estimates/calculate/
│ ├── features/
│ └── subscribers/
├── components/ # Reusable UI components (card, layout, ui)
├── sections/ # Page-level composed sections (main, blog, content)
├── context/ # React Context (service, category, search tag)
├── hooks/ # Custom hooks
├── lib/
│ ├── prisma.ts # Prisma client
│ ├── repository/ # Data access layer
│ ├── service/ # Business logic (e.g. email)
│ ├── validations/ # Zod schemas
│ └── dropinblog.js, resend.js
├── sanity/ # Sanity client, queries, schema
├── emails/ # react-email templates
├── prisma/ # Prisma schema & seed
└── docker-compose.yml # Local PostgreSQL1. The Category Buttons
You have to go to components > ui > Category.jsx
"use client";
import React from "react";
import TagRound from "./ui/TagRound";
import { useCategory } from "../context/category";
const Category = ({ categories = [] }) => {
const { selectedCategory, setSelectedCategory } = useCategory();
return (
<div className="mb-12 flex h-auto w-full flex-col items-start justify-start gap-6">
<h1 className="text-4xl font-bold">
Explore by category
</h1>
<div className="flex h-auto w-full flex-row flex-wrap gap-4 overflow-auto">
<button
type="button"
onClick={() => setSelectedCategory("all")}
>
<TagRound
tag="All"
useFor="blog"
bg={selectedCategory === "all" ? "bg-accent" : "bg-primary"}
/>
</button>
{categories.map((category) => (
<button
key={category._id}
type="button"
onClick={() => setSelectedCategory(category.title)}
>
<TagRound
tag={category.title}
useFor="blog"
bg={
selectedCategory === category.title
? "bg-accent"
: "bg-primary"
}
/>
</button>
))}
</div>
</div>
);
};
export default Category;This it a very simple code, I really wanted get minimum help from AI tool for this project, so I had to approach everything with what I had. - I’m not against using AI to build something, but this was part of my educational journey, and it’s been very helpful to restrict AI usage.
What it does:
- Renders one button per category, plus an "All" button
- Reads
selectedCategoryfrom context to know which button should look active - Calls
setSelectedCategoryon click, no local state here at all
This component does not know or care who else is reading selectedCategory. That separation is the whole idea.
2. The Shared State
"use client";
import { createContext, useContext, useState } from "react";
const CategoryContext = createContext(null);
export function CategoryProvider({ children }) {
const [selectedCategory, setSelectedCategory] = useState("all");
return (
<CategoryContext.Provider
value={{
selectedCategory,
setSelectedCategory,
}}
>
{children}
</CategoryContext.Provider>
);
}
export function useCategory() {
const context = useContext(CategoryContext);
if (!context) {
throw new Error("useCategory must be used inside CategoryProvider");
}
return context;
}No Redux, no Zustand, no extra library. Just:
- One
useStateholding the current category - One Context to make it accessible anywhere
- One custom hook,
useCategory, so any component can plug in
I really like React for this. You can go a long way with just hooks and context before you need a full state management library.
3. Fetching and Filtering Server Side
// app/blog/page.jsx - Blog ListCard Page
export const revalidate = 300;
import BlogBanner from "../../components/card/BlogBanner"
import Category from "../../components/Category"
import BlogList from "../../sections/blog/BlogList"
import SidePanel from "../../sections/blog/SidePanel"
import { client } from "../../sanity/client"
import { POSTS_QUERY } from "../../sanity/queries"
import { CATEGORY_QUERY } from "../../sanity/queries"
export const metadata = {
title: "Blog | Ria Choi",
description: "Development articles and projects",
};
export default async function Blog() {
const posts = await client.fetch(POSTS_QUERY);
const categories = await client.fetch(CATEGORY_QUERY);
const filteredPosts = posts.filter(
(post) =>
!post.categories?.some((category) =>
["About", "Services"].includes(category.title)
)
);
const filteredCategories = categories.filter(
(category) =>
category.title !== "About" &&
category.title !== "Services"
);
return (
<main className="min-h-screen p-4 xl:px-10 2xl:px-48 bg-background">
<Category posts={filteredPosts} categories={filteredCategories} />
<BlogBanner posts={filteredPosts} categories={filteredCategories} />
<div className="w-full h-auto gap-8 flex flex-col md:flex-row">
<BlogList posts={filteredPosts} />
<div className="w-full md:w-2/4 xl:w-1/3 h-auto ">
<SidePanel posts={filteredPosts} />
</div>
</div>
</main>
)
}Two things happen here before anything reaches the UI:
- Posts tagged "About" or "Services" get removed, since those belong elsewhere on the site
- The category list gets the same treatment, so those two categories never even show up as buttons
revalidate = 300 caches the page for five minutes. Good enough for a blog that does not change every minute, still fresh enough to feel current.
Note that filteredPosts here has nothing to do with selectedCategory. It is just cleanup. The actual category filtering happens one level down.
4. Filtering by Category and Sorting Pinned Posts
"use client"
import React from 'react'
import BlogListCard from "../../components/card/BlogListCard";
import { useCategory } from "../../context/category";
const BlogList = ({ posts = [] }) => {
const { selectedCategory } = useCategory();
const filteredPosts =
selectedCategory === "all"
? posts
: posts.filter((post) =>
post.categories?.some(
(category) => category.title === selectedCategory
)
);
const sortedPosts = [...filteredPosts].sort((a, b) => {
const aPinned = a.pinned === 1 || a.pinned === "1" || a.pinned === true;
const bPinned = b.pinned === 1 || b.pinned === "1" || b.pinned === true;
return Number(bPinned) - Number(aPinned);
});
return (
<section className="w-full md:w-2/4 xl:w-2/3 grid grid-cols-1 xl:grid-cols-2 gap-8">
{sortedPosts.map((post, index) => (
<BlogListCard key={post.id || index} post={post} />
))}
</section>
)
}
export default BlogListThis is where everything connects. BlogList never talks to Category.jsx directly, no props passed between them. They both just read and write the same context.
Breaking down the logic:
- Filtering: if
selectedCategoryis"all", every post passes through. Otherwise, keep only posts whosecategoriesarray contains a matching title. - Sorting: pinned posts float to the top.
aPinnedandbPinnedaccept1,"1", ortrue, since the pinned value does not always come typed the same way. - The sort trick:
Number(bPinned) - Number(aPinned)works becausetruebecomes1andfalsebecomes0. No if statement needed. - The spread:
[...filteredPosts]before.sort()is technically not required here since.filter()already returns a new array, but it keeps the intent obvious and protects against future changes.
5. Rendering Each Post
"use client"
import React from "react";
import Tag from "../ui/Tag";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Pin } from "lucide-react";
import { urlFor } from "../../sanity/image";
const BlogListCard = ({post}) => {
const tags =
post?.keyword
?.split(" ")
.map((tag) => tag.trim())
.filter(Boolean) || [];
const pinned = post?.pinned === 1 || post?.pinned === "1" || post?.pinned === true;
const pathname = usePathname();
const isHome = pathname === "/";
const imageUrl =
post?.featuredImageUrl ||
(post?.featuredImage?.asset?._ref
? urlFor(post.featuredImage).width(800).height(450).url()
: null);
function formatDate(dateString) {
const date = new Date(dateString);
const day = date.getDate();
const getOrdinal = (n) => {
if (n > 3 && n < 21) return "th";
switch (n % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
};
const month = date.toLocaleString("en-US", { month: "long" });
const year = date.getFullYear();
return `${month} ${day}${getOrdinal(day)}, ${year}`;
}
return (
<Link
href={`/blog/${post.slug}`}
className="bg-primary flex h-fit flex-col gap-4 rounded-2xl p-2 shadow-[0_1px_2px_rgba(0,0,0,0.08)] group"
>
{imageUrl && (
<div className="relative aspect-video w-full overflow-hidden rounded-xl">
<Image
src={imageUrl}
alt={post.title || "Blog Thumbnail"}
fill
className="rounded-xl object-cover transition-all duration-200 ease-in group-hover:scale-102"
/>
{pinned && !isHome && (
<div className="absolute top-4 right-1 w-10 h-10">
<Pin className="w-6 h-6 text-[#eeeeee] rotate-45" />
</div>
)}
</div>
)}
<div className="pt-4 px-4 flex flex-col justify-between items-between">
<div className="flex h-auto w-full flex-col gap-2">
<div className="w-auto h-auto flex flex-row gap-2 overflow-auto flex-wrap">
{tags.map((tag, index) => (
<div key={index} className="flex flex-row gap-2 overflow-auto">
<Tag tag={tag} />
</div>
))}
</div>
<h1 className="text-foreground text-2xl font-semibold group-hover:text-accent transition-all duration-200 ease-in-out">
{post.title}
</h1>
<p className="text-gray-500 font-semibold">{post.summary}</p>
</div>
<div className="mt-4 mb-4 flex w-full flex-row gap-2">
<p>{post?.author?.name}</p>
<p>•</p>
<p>{formatDate(post.publishedAt)}</p>
</div>
</div>
</Link>
);
};
export default BlogListCard;A few details worth knowing:
- The whole card is one
Link, so clicking anywhere goes to/blog/[slug] tagscome from a plainkeywordstring, split on spaces and cleaned up so extra whitespace never turns into an empty tagimageUrlchecks for a plain URL first, and only falls back to building one from Sanity'surlForwhen there is an actual image asset- The pin icon only shows on non home pages, so pinned posts do not get double emphasis on a homepage where they might already stand out
formatDatewrites out dates like "September 9th, 2026" by hand, no date library needed for something this small
How It All Fits Together
- A click in
Category.jsxcallssetSelectedCategory - That update lives in one shared context,
context/category.js BlogList.jsxreads that same context and filterspostsdown to a match- Pinned posts get sorted to the top
- Each surviving post gets handed to
BlogListCard.jsxfor rendering
No prop drilling, no external state library, just one small hook doing exactly one job.
Heads Up!
I’ll be back with a tutorial on how I implemented Sanity on my blog.
