Skip to content
Back to Blog
DevOps

Building a Complete Blog Platform: Search, Filtering, Code Highlighting & RSS Feed

A comprehensive guide to building a production-ready blog platform with full-text search, category filtering, syntax highlighting, and RSS feed generation using Next.js, Shiki, and Drizzle ORM.

Aug 2025
22 min read

Building a Complete Blog Platform: Search, Filtering, Code Highlighting & RSS Feed

This post covers the architecture and implementation of the blog platform powering this very site — built with Next.js 15, Drizzle ORM, SQLite, and Shiki.

Tech Stack

ComponentTechnology
FrameworkNext.js 15 (App Router)
DatabaseSQLite + Drizzle ORM
SearchFull-text search via SQLite FTS5
Code highlightingShiki (server-side)
RSSCustom XML generator
StylingTailwind CSS

Database Schema

TYPESCRIPT
// schema.ts
export const blogPosts = sqliteTable('blog_posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  slug: text('slug').notNull().unique(),
  titleEn: text('title_en').notNull(),
  titleFa: text('title_fa').notNull(),
  contentEn: text('content_en'),
  contentFa: text('content_fa'),
  categoryId: integer('category_id').references(() => blogCategories.id),
  status: text('status').default('draft'),
  featured: integer('featured').default(0),
  publishedAtEn: text('published_at_en'),
})

Full-Text Search with SQLite FTS5

SQL
-- Create virtual FTS table
CREATE VIRTUAL TABLE blog_fts USING fts5(
  slug UNINDEXED,
  title_en,
  title_fa,
  excerpt_en,
  excerpt_fa,
  content_en,
  content_fa,
  content='blog_posts',
  content_rowid='id'
);

-- Trigger to keep FTS in sync
CREATE TRIGGER blog_posts_ai AFTER INSERT ON blog_posts BEGIN
  INSERT INTO blog_fts(rowid, slug, title_en, title_fa, excerpt_en, excerpt_fa, content_en, content_fa)
  VALUES (new.id, new.slug, new.title_en, new.title_fa, new.excerpt_en, new.excerpt_fa, new.content_en, new.content_fa);
END;
TYPESCRIPT
// Search API
export async function searchPosts(query: string, locale: string) {
  const db = getDb()
  const results = db.prepare(`
    SELECT bp.slug, bp.title_en, bp.title_fa, bp.excerpt_en, bp.excerpt_fa,
           rank
    FROM blog_fts 
    JOIN blog_posts bp ON bp.id = blog_fts.rowid
    WHERE blog_fts MATCH ?
      AND bp.status = 'published'
    ORDER BY rank
    LIMIT 20
  `).all(`${query}*`)
  return results
}

Code Highlighting with Shiki

TYPESCRIPT
import { codeToHtml } from 'shiki'
import { visit } from 'unist-util-visit'

export async function highlightCode(tree: Root) {
  const nodes: [Code, Parent, number][] = []
  
  visit(tree, 'code', (node: Code, index, parent: Parent) => {
    nodes.push([node, parent, index as number])
  })

  await Promise.all(nodes.map(async ([node, parent, index]) => {
    const html = await codeToHtml(node.value, {
      lang: node.lang || 'text',
      theme: 'github-dark',
    })
    parent.children.splice(index, 1, {
      type: 'html',
      value: html,
    })
  }))
}

Category Filtering

TYPESCRIPT
// app/[locale]/blog/page.tsx
export default async function BlogPage({ searchParams }: { searchParams: { category?: string } }) {
  const db = getDb()
  const posts = db.prepare(`
    SELECT bp.*, bc.name_en as cat_en, bc.name_fa as cat_fa
    FROM blog_posts bp
    LEFT JOIN blog_categories bc ON bc.id = bp.category_id
    WHERE bp.status = 'published'
      ${searchParams.category ? 'AND bc.slug = ?' : ''}
    ORDER BY bp.id DESC
  `).all(...(searchParams.category ? [searchParams.category] : []))

  return <BlogList posts={posts} />
}

RSS Feed Generation

TYPESCRIPT
// app/rss.xml/route.ts
export async function GET() {
  const db = getDb()
  const posts = db.prepare(`
    SELECT * FROM blog_posts WHERE status = 'published' ORDER BY id DESC LIMIT 50
  `).all() as BlogPost[]

  const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>HBZ Blog — Infrastructure Engineering</title>
    <link>https://habibazar.ir/en/blog</link>
    <description>Network, Security, and Infrastructure insights</description>
    <atom:link href="https://habibazar.ir/rss.xml" rel="self" type="application/rss+xml"/>
    ${posts.map(p => `
    <item>
      <title>${p.title_en}</title>
      <link>https://habibazar.ir/en/blog/${p.slug}</link>
      <description>${p.excerpt_en}</description>
      <pubDate>${new Date(p.created_at).toUTCString()}</pubDate>
      <guid>https://habibazar.ir/en/blog/${p.slug}</guid>
    </item>
    `).join('')}
  </channel>
</rss>`

  return new Response(rss, {
    headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }
  })
}

Reading Time Calculation

TYPESCRIPT
export function calculateReadTime(content: string, locale: 'en' | 'fa'): string {
  const wpm = locale === 'fa' ? 200 : 238  // Persian readers slightly slower
  const words = content.trim().split(/s+/).length
  const minutes = Math.ceil(words / wpm)
  return locale === 'fa' ? `${minutes} دقیقه مطالعه` : `${minutes} min read`
}

This platform serves this blog in both English and Farsi, with full RTL support, shared content storage, and a single admin interface.