RiaChoi Text Logo.
How I Built this Blog

React

Tailwind

How I Built this Blog

Part1. Toggleable Category Bar

Ria Choi • September 9th, 2026

Blog image

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.

 

My First Blog
My first 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.

my template code
My Precious Template Code

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 PostgreSQL

1. 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 selectedCategory from context to know which button should look active
  • Calls setSelectedCategory on 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

context/category.js

"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 useState holding 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.js

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

BlogList.jsx

"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 BlogList

This 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 selectedCategory is "all", every post passes through. Otherwise, keep only posts whose categories array contains a matching title.
  • Sorting: pinned posts float to the top. aPinned and bPinned accept 1, "1", or true, since the pinned value does not always come typed the same way.
  • The sort trick: Number(bPinned) - Number(aPinned) works because true becomes 1 and false becomes 0. 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

BlogListCard.jsx

"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]
  • tags come from a plain keyword string, split on spaces and cleaned up so extra whitespace never turns into an empty tag
  • imageUrl checks for a plain URL first, and only falls back to building one from Sanity's urlFor when 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
  • formatDate writes 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.jsx calls setSelectedCategory
  • That update lives in one shared context, context/category.js
  • BlogList.jsx reads that same context and filters posts down to a match
  • Pinned posts get sorted to the top
  • Each surviving post gets handed to BlogListCard.jsx for 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.

Share

Related contents