Image of an astronaut space suit holding a string attached to the helmet that is floating like a helium balloon. The space suit is empty, floating in space, overlooking the earth below.

The Best of Both Worlds

How to build a blog with fully custom pages while keeping database features

The Challenge

When building a developer blog, we often face a dilemma:

Traditional CMS Approach
  • Content stored in database
  • Easy to search and filter
  • Limited interactive components
  • Difficult to add custom code demos
Custom Page Approach
  • Full React component freedom
  • Can include interactive elements
  • Hard to maintain a central index
  • Difficult to implement search/filtering

But what if we could have the best of both worlds? This blog implements exactly that: fully custom React components with database indexing.

How It Works

1. The Post Architecture

Each blog post is a standard Next.js page component in the App Router. For example, this post lives at:

src/app/blog/custom-developer-blog/page.tsx

The magic happens with a special frontmatter-style comment at the top of each page:

/* 
---bm title: Custom Developer 
Blog excerpt: How to create custom blog posts with rich features... 
coverImage: /images/blog/default.png
publishedAt: 2025-04-02 
featured: true 
published: true 
tags:
nextjs,typescript,tutorial 
--- 
*/

2. Build-Time Indexing

At build time, a custom script scans through all directories in the src/app/blog/folder, reads this metadata, and upserts it to the database:

// From src/scripts/blog-indexer.js

export async function indexBlogPosts() {
  // ... setup code ...
  
  for (const slug of slugDirs) {
    const postDir = path.join(postsDirectory, slug);
    const pageFile = path.join(postDir, "page.tsx");
    
    // Read the page file
    const fileContent = fs.readFileSync(pageFile, "utf8");
    
    // Extract metadata from frontmatter-style comments
    const metadataRegex = /---bm\s*([\s\S]*?)\s*---/;
    const match = metadataRegex.exec(fileContent);
    
    if (!match?.[1]) continue;
    
    // Extract the YAML content and validate with Zod
    const { data: rawMetadata } = matter(`---\n${match[1]}\n---`);
    const result = PostMetadataSchema.safeParse(rawMetadata);
    
    // Upsert post metadata to the database
    await prisma.post.upsert({
      where: { slug },
      update: postData,
      create: postData,
    });
    
    // Also index tags for filtering
    // ...
  }
}
        

This script runs automatically during the prebuild process via this NPM script:

// package.json
{
  ...more...
  "scripts": {
    "blog:index": "node src/scripts/index-blog.js",
    "prebuild": "npm run blog:index"
  }
  ...rest...
}

3. tRPC API for Data Access

The data is exposed through tRPC endpoints that allow for filtering, pagination, and search features:

// From src/server/api/routers/blog.ts
export const blogRouter = createTRPCRouter({
  getAll: publicProcedure
    .input(postSearchParamsSchema)
    .query(async ({ ctx, input }) => {
      const { q, tag, featured, year, month, page, perPage } = input;
      
      // Build complex database query with all filters
      const query: Prisma.PostFindManyArgs = {
        where: {
          published: true,
          ...(featured ? { featured: true } : {}),
          ...(tag ? { tags: { some: { slug: tag } } } : {}),
          ...(q ? {
            OR: [
              { title: { contains: q, mode: "insensitive" }},
              { content: { contains: q, mode: "insensitive" }},
            ],
          } : {}),
          ...(year ? {
            publishedAt: {
              gte: new Date(year, month ?? 0, 1),
              lt: new Date(year, month ?? 12, month ? 32 : 1),
            },
          } : {}),
        },
        // ... other query options
      };
      
      // Return posts with pagination info
      // ...
    }),
  // Other endpoints for tags, dates, etc.
}

4. Client-Side Components for Filtering

In the blog index page, we use React client components for search, filtering, and pagination:

Blog Search
export function BlogSearch() {
              // Search form with React Hook Form
              function onSubmit(values: FormValues) {
                const params = new URLSearchParams(
                  searchParams.toString()
                );
                params.set("q", values.query);
                router.push(`/blog?${params}`);
              }
              // ...
            }
Date Filter
export function DateFilter() {
                // Fetch aggregated date data
                const { data: dateAggregations } =
                  api.blog.getDateAggregations.useQuery();
                  
                // Create an accordion UI for year/month
                // ...
              }

Interactive Components

Because each post is a full Next.js page, we can include any React component we want — including interactive client components like this counter:

Interactive Counter

0

This is a client component that maintains state

This component is a standard React client component, with its own state:

"use client";

          import { useState } from "react";
          import { Button } from "~/components/ui/button";
          import { PlusIcon, MinusIcon } from "lucide-react";
          
          export function BlogExampleCounter() {
            const [count, setCount] = useState(0);
          
            return (
              <div className="flex flex-col items-center gap-4 rounded-xl 
                            border p-6 shadow-sm">
                <h3 className="text-xl font-semibold">Interactive Counter</h3>
                
                <div className="flex items-center gap-4">
                  <Button
                    variant="outline"
                    size="icon"
                    onClick={() => setCount((prev) => prev - 1)}
                    aria-label="Decrement counter"
                  >
                    <MinusIcon className="h-4 w-4" />
                  </Button>
                  
                  <span className="flex h-16 w-16 items-center justify-center 
                                 rounded-full bg-primary/10 text-2xl font-bold">
                    {count}
                  </span>
                  
                  <Button
                    variant="outline" 
                    size="icon"
                    onClick={() => setCount((prev) => prev + 1)}
                    aria-label="Increment counter"
                  >
                    <PlusIcon className="h-4 w-4" />
                  </Button>
                </div>
              </div>
            );
          }

Benefits of This Approach

1

Full React Component Freedom

Each post is a full React component with complete control over its presentation and functionality.

2

Database Indexing

Posts are indexed in the database, enabling search, filtering, categorization, and pagination.

3

Automatic Indexing

The build process automatically extracts metadata and keeps the database in sync, no manual curation needed.

4

Best Dev Experience

Write code in your IDE with full TypeScript support, no context switching to a CMS or markdown files.

Try It Yourself

This approach offers an excellent solution for developer blogs where you want the freedom to create rich, interactive content while maintaining standard blog features like search and filtering.

Check out the source code on GitHub to see how it's implemented:

Key files to look at: