Skip to content
Back to skills

Blog Engine

ASecurity

Build SEO-optimized blog systems for Next.js + shadcn/ui projects. MDX content, pagination, categories/tags, RSS feeds, reading time, TOC, related posts, search (Pagefind/Algolia), author pages, and social share cards. Use when: user wants a blog, content system, MDX setup, RSS feed, or content-driven pages.

  • 6 stars
  • 0 votes
  • 1 copy
  • 3 views
  • Added May 27, 2026
developmenttypescriptgobashreactnextjs

Works with

  • cli

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned May 27, 2026

npx -y skills add KevinZai/commander --skill blog-engine --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Blog Engine?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Blog Engine
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/kevinzai-blog-engine/badge)](https://www.skillsdirectory.com/skills/kevinzai-blog-engine)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: blog-engine
description: "Build SEO-optimized blog systems for Next.js + shadcn/ui projects. MDX content, pagination, categories/tags, RSS feeds, reading time, TOC, related posts, search (Pagefind/Algolia), author pages, and social share cards. Use when: user wants a blog, content system, MDX setup, RSS feed, or content-driven pages."
---

# Blog Engine Skill

Build content/blog systems for **Next.js (App Router) + shadcn/ui + Tailwind CSS + TypeScript** projects.

All output MUST be:
- Next.js App Router with RSC (React Server Components) where possible
- MDX for content with type-safe frontmatter
- shadcn/ui components for UI elements
- SEO-optimized (use `seo-optimizer` skill patterns)

## 1. Content Layer Setup

### Option A: Contentlayer (recommended for static)
```bash
npm i contentlayer2 next-contentlayer2
```

```ts
// contentlayer.config.ts
import { defineDocumentType, makeSource } from "contentlayer2/source-files"

export const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: "blog/**/*.mdx",
  contentType: "mdx",
  fields: {
    title: { type: "string", required: true },
    description: { type: "string", required: true },
    date: { type: "date", required: true },
    updated: { type: "date" },
    author: { type: "string", required: true },
    tags: { type: "list", of: { type: "string" }, default: [] },
    category: { type: "string", required: true },
    image: { type: "string" },
    draft: { type: "boolean", default: false },
  },
  computedFields: {
    slug: { type: "string", resolve: (doc) => doc._raw.flattenedPath.replace("blog/", "") },
    readingTime: {
      type: "string",
      resolve: (doc) => {
        const words = doc.body.raw.split(/\s+/).length
        const minutes = Math.ceil(words / 200)
        return `${minutes} min read`
      },
    },
  },
}))

export default makeSource({ contentDirPath: "content", documentTypes: [Post] })
```

### Option B: Local MDX with gray-matter
```bash
npm i gray-matter next-mdx-remote
```

```ts
// lib/posts.ts
import fs from "fs"
import path from "path"
import matter from "gray-matter"

const postsDir = path.join(process.cwd(), "content/blog")

export interface PostMeta {
  title: string
  description: string
  date: string
  updated?: string
  author: string
  tags: string[]
  category: string
  image?: string
  draft?: boolean
  slug: string
  readingTime: string
}

export function getAllPosts(): PostMeta[] {
  const files = fs.readdirSync(postsDir).filter(f => f.endsWith(".mdx"))
  return files
    .map(file => {
      const raw = fs.readFileSync(path.join(postsDir, file), "utf-8")
      const { data, content } = matter(raw)
      const words = content.split(/\s+/).length
      return {
        ...data,
        slug: file.replace(".mdx", ""),
        readingTime: `${Math.ceil(words / 200)} min read`,
      } as PostMeta
    })
    .filter(p => !p.draft)
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}

export function getPostBySlug(slug: string) {
  const raw = fs.readFileSync(path.join(postsDir, `${slug}.mdx`), "utf-8")
  const { data, content } = matter(raw)
  return { meta: { ...data, slug } as PostMeta, content }
}
```

## 2. Blog Index with Pagination

```tsx
// app/blog/page.tsx
import { getAllPosts } from "@/lib/posts"
import { PostCard } from "@/components/blog/post-card"
import { Pagination } from "@/components/blog/pagination"

const POSTS_PER_PAGE = 12

interface Props { searchParams: Promise<{ page?: string }> }

export default async function BlogPage({ searchParams }: Props) {
  const { page } = await searchParams
  const currentPage = Number(page) || 1
  const allPosts = getAllPosts()
  const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE)
  const posts = allPosts.slice((currentPage - 1) * POSTS_PER_PAGE, currentPage * POSTS_PER_PAGE)

  return (
    <div className="container mx-auto py-12">
      <h1 className="text-4xl font-bold tracking-tight mb-8">Blog</h1>
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.map(post => <PostCard key={post.slug} post={post} />)}
      </div>
      <Pagination currentPage={currentPage} totalPages={totalPages} basePath="/blog" />
    </div>
  )
}
```

### PostCard Component
```tsx
import Link from "next/link"
import Image from "next/image"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import type { PostMeta } from "@/lib/posts"

export function PostCard({ post }: { post: PostMeta }) {
  return (
    <Card className="group overflow-hidden">
      <Link href={`/blog/${post.slug}`}>
        {post.image && (
          <div className="aspect-video overflow-hidden">
            <Image src={post.image} alt={post.title} width={600} height={340}
              className="object-cover transition-transform group-hover:scale-105" />
          </div>
        )}
        <CardHeader>
          <div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
            <time dateTime={post.date}>{new Date(post.date).toLocaleDateString()}</time>
            <span>·</span>
            <span>{post.readingTime}</span>
          </div>
          <h2 className="text-xl font-semibold group-hover:text-primary transition-colors">{post.title}</h2>
        </CardHeader>
        <CardContent>
          <p className="text-muted-foreground line-clamp-2">{post.description}</p>
          <div className="flex gap-2 mt-4">
            {post.tags.slice(0, 3).map(tag => <Badge key={tag} variant="secondary">{tag}</Badge>)}
          </div>
        </CardContent>
      </Link>
    </Card>
  )
}
```

## 3. Category & Tag Pages

```tsx
// app/blog/category/[category]/page.tsx
export async function generateStaticParams() {
  const posts = getAllPosts()
  const categories = [...new Set(posts.map(p => p.category))]
  return categories.map(category => ({ category }))
}

// app/blog/tag/[tag]/page.tsx
export async function generateStaticParams() {
  const posts = getAllPosts()
  const tags = [...new Set(posts.flatMap(p => p.tags))]
  return tags.map(tag => ({ tag }))
}
```

## 4. Table of Contents

```tsx
"use client"
import { useEffect, useState } from "react"
import { cn } from "@/lib/utils"

interface TOCItem { id: string; text: string; level: number }

export function TableOfContents() {
  const [headings, setHeadings] = useState<TOCItem[]>([])
  const [activeId, setActiveId] = useState("")

  useEffect(() => {
    const elements = document.querySelectorAll("article h2, article h3")
    const items: TOCItem[] = Array.from(elements).map(el => ({
      id: el.id,
      text: el.textContent ?? "",
      level: Number(el.tagName[1]),
    }))
    setHeadings(items)

    const observer = new IntersectionObserver(
      entries => {
        const visible = entries.find(e => e.isIntersecting)
        if (visible) setActiveId(visible.target.id)
      },
      { rootMargin: "-80px 0px -80% 0px" }
    )
    elements.forEach(el => observer.observe(el))
    return () => observer.disconnect()
  }, [])

  return (
    <nav className="sticky top-24 space-y-1">
      <p className="font-semibold text-sm mb-3">On this page</p>
      {headings.map(h => (
        <a
          key={h.id}
          href={`#${h.id}`}
          className={cn(
            "block text-sm py-1 transition-colors hover:text-foreground",
            h.level === 3 && "pl-4",
            activeId === h.id ? "text-primary font-medium" : "text-muted-foreground"
          )}
        >
          {h.text}
        </a>
      ))}
    </nav>
  )
}
```

## 5. RSS Feed

```tsx
// app/feed.xml/route.ts
import { getAllPosts } from "@/lib/posts"

export async function GET() {
  const posts = getAllPosts()
  const siteUrl = "https://example.com"

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Site Name</title>
    <link>${siteUrl}</link>
    <description>Site description</description>
    <language>en-us</language>
    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
    <atom:link href="${siteUrl}/feed.xml" rel="self" type="application/rss+xml"/>
    ${posts.map(post => `
    <item>
      <title><![CDATA[${post.title}]]></title>
      <link>${siteUrl}/blog/${post.slug}</link>
      <guid isPermaLink="true">${siteUrl}/blog/${post.slug}</guid>
      <description><![CDATA[${post.description}]]></description>
      <pubDate>${new Date(post.date).toUTCString()}</pubDate>
      ${post.tags.map(tag => `<category>${tag}</category>`).join("")}
    </item>`).join("")}
  </channel>
</rss>`

  return new Response(xml, { headers: { "Content-Type": "application/xml" } })
}
```

## 6. Search

### Pagefind (Static, zero-config)
```bash
npx pagefind --site .next/server/app
```

```tsx
"use client"
// Load Pagefind dynamically
useEffect(() => {
  async function load() {
    const pf = await import(/* webpackIgnore: true */ "/pagefind/pagefind.js")
    await pf.init()
    // pf.search("query") returns results
  }
  load()
}, [])
```

### Related Posts
```tsx
export function getRelatedPosts(currentSlug: string, tags: string[], limit = 3): PostMeta[] {
  const all = getAllPosts().filter(p => p.slug !== currentSlug)
  return all
    .map(post => ({
      post,
      score: post.tags.filter(t => tags.includes(t)).length,
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, limit)
    .map(({ post }) => post)
}
```

## 7. Social Share & OG Images

### Dynamic OG Image
```tsx
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og"

export const size = { width: 1200, height: 630 }
export const contentType = "image/png"

export default async function OGImage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)

  return new ImageResponse(
    <div style={{ display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, width: "100%", height: "100%", background: "linear-gradient(135deg, #0f172a, #1e293b)" }}>
      <div style={{ fontSize: 48, fontWeight: 700, color: "white", lineHeight: 1.2 }}>{post.title}</div>
      <div style={{ fontSize: 24, color: "#94a3b8", marginTop: 20 }}>{post.description}</div>
    </div>,
    { ...size }
  )
}
```

## 8. MDX Components

```tsx
// components/mdx-components.tsx
import Image from "next/image"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

export const mdxComponents = {
  h2: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => {
    const id = String(children).toLowerCase().replace(/\s+/g, "-")
    return <h2 id={id} className="scroll-mt-24 text-2xl font-bold mt-10 mb-4" {...props}>{children}</h2>
  },
  h3: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => {
    const id = String(children).toLowerCase().replace(/\s+/g, "-")
    return <h3 id={id} className="scroll-mt-24 text-xl font-semibold mt-8 mb-3" {...props}>{children}</h3>
  },
  img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
    <Image src={src ?? ""} alt={alt ?? ""} width={800} height={450} className="rounded-lg my-6" {...props} />
  ),
  a: ({ href, children }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
    <a href={href} className="text-primary underline underline-offset-4 hover:text-primary/80" target={href?.startsWith("http") ? "_blank" : undefined} rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}>{children}</a>
  ),
  Alert,
  AlertDescription,
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
}
```

## Content Structure

```
content/
  blog/
    getting-started.mdx
    advanced-guide.mdx
  authors/
    author-name.json
```

### MDX Frontmatter Template
```mdx
---
title: "Post Title with Primary Keyword"
description: "150-160 char description for SEO"
date: "2026-03-17"
author: "Author Name"
category: "Engineering"
tags: ["react", "nextjs", "tutorial"]
image: "/images/blog/post-cover.jpg"
draft: false
---

Content here...
```

**Version:** 1.0.0
**Last Updated:** 2026-03-17

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.