
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:
- Content stored in database
- Easy to search and filter
- Limited interactive components
- Difficult to add custom code demos
- 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:
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}`);
}
// ...
}
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
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
Full React Component Freedom
Each post is a full React component with complete control over its presentation and functionality.
Database Indexing
Posts are indexed in the database, enabling search, filtering, categorization, and pagination.
Automatic Indexing
The build process automatically extracts metadata and keeps the database in sync, no manual curation needed.
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:
- src/scripts/blog-indexer.js - Handles the extraction of metadata from page components
- src/server/api/routers/blog.ts - tRPC endpoints for querying blog data
- src/app/blog/page.tsx - The main blog listing page with filters
- src/components/blog/blog-search.tsx - Search component for filtering posts