Content Management
Full CMS for articles and standalone pages with TipTap rich-text editor, draft/publish/schedule workflow, categories, tags, SEO fields, and in-article product embeds.
Articles vs Pages
| Articles | Pages | |
|---|---|---|
type value | article | page |
| Listed in blog index | ✅ | ❌ |
| Categories & tags | ✅ | optional |
| Scheduled publish | ✅ | ✅ |
| SEO fields | ✅ | ✅ |
| Product embeds | ✅ | ✅ |
Rich Text Editor
Content is edited with a TipTap-based rich-text editor that supports:
- Headings (H1–H6), bold, italic, underline, strikethrough
- Ordered and unordered lists, blockquotes, code blocks
- Hyperlinks with target/rel configuration
- Image upload (drag-and-drop or file picker) via the File Upload system
- oEmbed / iframe embeds (YouTube, Twitter, etc.)
- In-article product cards — embed a purchasable product inline
Product Card Embed
While editing an article, click Insert → Product and select a product. The embed stores the product ID and renders as a styled card on the front end:
// The TipTap extension stores a node like:
// { type: 'productCard', attrs: { productId: 'prod_xxx' } }
// The renderer fetches and displays the product at read time
import { ProductCard } from '@/components/content/ProductCard'Status Workflow
Every post moves through a clear status pipeline:
Draft → Published
→ Scheduled (auto-publishes at scheduledAt datetime)
→ Trash| Status | Visible to public | Notes |
|---|---|---|
draft | ❌ | Work in progress |
published | ✅ | Live immediately |
scheduled | ❌ | Published automatically at scheduledAt |
trash | ❌ | Soft-deleted; can be restored |
Scheduled publishing is handled by a cron-compatible API route. Point your scheduler (Vercel Cron, GitHub Actions, etc.) at POST /api/admin/posts/publish-scheduled.
Categories
Categories support a 3-level hierarchy (root → second level → third level). Each category has a name, slug, optional description, an optional parentId pointing to its parent, and a locale field that ties the category to a specific language. A post can belong to multiple categories.
| Level | Description |
|---|---|
| Level 1 (root) | Top-level category, no parent |
| Level 2 | Child of a root category |
| Level 3 | Child of a level-2 category; cannot have further children |
Locale-aware Categories
Every category carries a locale value (e.g. en, zh). In the Admin Panel the category list is grouped by language, so English and Chinese categories are managed separately. When creating a child category, only parents with the same locale are shown in the parent selector.
Manage via API
// Create a root category (English)
const root = await prisma.category.create({
data: { name: 'AI', slug: 'ai', locale: 'en', description: 'Artificial intelligence topics' },
})
// Create a child category (level 2, same locale)
const child = await prisma.category.create({
data: { name: 'LLMs', slug: 'llms', parentId: root.id, locale: 'en' },
})
// Fetch full category tree with post counts
const categories = await prisma.category.findMany({
where: { parentId: null }, // fetch roots only
include: {
children: {
include: {
children: { include: { _count: { select: { posts: true } } } },
_count: { select: { posts: true } },
},
},
_count: { select: { posts: true } },
},
orderBy: { name: 'asc' },
})Tags
Tags are free-form and many-to-many. A post can have unlimited tags; each tag can appear on many posts.
const post = await prisma.post.create({
data: {
title: 'My Article',
// ...
tags: {
connectOrCreate: ['nextjs', 'typescript'].map(name => ({
where: { name },
create: { name, slug: name },
})),
},
},
})SEO Fields
Every post has dedicated SEO fields independent of the main title and content:
| Field | Purpose |
|---|---|
seoTitle | Overrides <title> tag |
seoDescription | <meta name="description"> |
featuredImage | OG image, Twitter Card image |
If seoTitle / seoDescription are empty the system falls back to title / excerpt automatically.
Featured Image
Set the featured image in one of two ways:
- Upload — drag a file onto the image picker in the editor; stored via the File Upload system
- URL — paste any external image URL directly
Querying Content
List Published Articles
import { prisma } from '@/lib/db/prisma'
const articles = await prisma.post.findMany({
where: {
type: 'article',
status: 'published',
},
select: {
id: true, title: true, slug: true, excerpt: true,
featuredImage: true, publishedAt: true,
categories: { select: { name: true, slug: true } },
tags: { select: { name: true } },
},
orderBy: { publishedAt: 'desc' },
})Single Article by Slug
const article = await prisma.post.findFirst({
where: { slug: params.slug, type: 'article', status: 'published' },
include: {
categories: true,
tags: true,
},
})Admin Panel Integration
All content is manageable from the Admin Panel without touching code:
- Admin → Posts — create, edit, preview, change status, delete
- Admin → Pages — same workflow for standalone pages
- Admin → Categories — manage categories
- Admin → Tags — view and merge tags
See Admin Panel for details on the editorial UI.
Next Steps
- Admin Panel — manage content from the dashboard
- File Upload — configure image storage for the editor
- SEO — how post SEO fields are rendered
- Payments — in-article product embed setup
Payments
Dual payment provider integration with Stripe and PayPal — one-time payments, subscriptions, webhook auto-sync, guest checkout, and automated email notifications.
Admin Panel
Feature-rich dashboard for managing users, roles, products, orders, and content — with stats cards and Recharts line charts.