- Payload CMS Integration: Full headless CMS backend for documentation
- Custom Source Adapter: Transform Payload data into fumadocs format
- Role-Based Access Control (RBAC): Owner, Admin and User roles for RBAC
- Sidebar Tabs: Each category becomes an isolated sidebar tab
- Hierarchical Docs: Parent/child relationships for nested documentation
- Lexical Editor: Rich text editing with HTML serialization
- Search: Built-in search via fumadocs
- OG Images: Dynamic OpenGraph image generation
payload-cms/
├── app/
│ ├── (fumadocs)/ # Public documentation routes
│ │ ├── (home)/ # Landing page with category cards
│ │ ├── [[...slug]]/ # Documentation pages
│ │ │ └── layout.tsx # Docs layout with sidebar tabs
│ │ ├── api/search/ # Search API endpoint
│ │ ├── og/ # OpenGraph image generation
│ └── (payload)/ # Payload admin (protected)
├── collections/
│ ├── Categories.ts # Doc categories
│ ├── Docs.ts # Documentation pages
├── components/
│ └── ui/ # UI components
├── lib/
│ ├── source.ts # 🔑 Fumadocs source adapter
│ ├── lexical-serializer.ts # Lexical to HTML converter
│ └── utils.ts # Helper functions
└── payload.config.ts # Payload CMS config
-
Install dependencies:
bun install
-
Configure environment:
cp .env.example .env.local
-
Start development:
bun run dev
Organize documentation into sections:
title: Category nameslug: URL identifier (e.g., "getting-started")description: Brief descriptionorder: Display order (ascending)
Documentation pages:
title: Page titleslug: URL-friendly slugdescription: Page excerpt/descriptioncontent: Rich content (Lexical editor)category: Belongs to which categoryparent: Optional parent doc (for nesting)order: Sort order within category (ascending)_status: Draft or Published
The heart of this example is lib/source.ts - the fumadocs source adapter:
import { loader } from "fumadocs-core/source";
import { getPayload } from "payload";
// Create cached source
export const getSource = cache(async () => {
const payloadSource = await createPayloadSource();
return loader({
baseUrl: "/",
source: payloadSource,
});
});What it does:
- Fetches categories and docs from Payload
- Transforms Payload data into fumadocs
VirtualFileformat - Builds hierarchical paths (e.g.,
/category/parent/child) - Creates meta files for sidebar tabs and ordering
- Provides standard fumadocs APIs
In your routes:
const source = await getSource();
const page = source.getPage(slugs);
const tree = source.pageTree;Each category becomes an isolated sidebar tab:
- Meta files with
root: truemark categories as root folders - Pages array defines document order (preserves Payload
orderfield) - Auto-detection by fumadocs creates the tab interface
When viewing a doc, only that category's docs appear in the sidebar.
-
Add a Category (Admin → Categories):
- Set title, slug, and order
- Upload an icon (optional)
-
Create Docs (Admin → Docs):
- Assign to a category
- Set order for positioning
- Use parent field for nesting
- Write content in Lexical editor
-
Publish:
- Change status to "Published"
- Content appears immediately (with revalidation)
To create nested docs:
- Create parent doc (leave
parentempty) - Create child doc, set
parentto the parent doc - Order determines child position under parent
Example:
Getting Started (order: 1)
├── Installation (order: 1, parent: Getting Started)
└── Configuration (order: 2, parent: Getting Started)
Documents are ordered by the order field (ascending) within their level:
- Categories: Sorted by
order(sidebar tab order) - Top-level docs: Sorted by
orderwithin category - Child docs: Sorted by
orderunder their parent
The source adapter preserves this order using pages arrays in meta files.
source.pageTree getter requires async access:
// ❌ This won't work (synchronous access)
import { source } from '@/lib/source';
const tree = source.pageTree; // Error!
// ✅ Do this instead (async access)
import { getSource } from '@/lib/source';
const source = await getSource();
const tree = source.pageTree; // Works!This is due to React's cache() requiring async initialization.
The source adapter uses meta files with pages arrays to preserve order:
// Category meta file
{
title: "Getting Started",
root: true,
pages: ["installation", "configuration"] // Explicit order
}Without this, fumadocs sorts alphabetically. The adapter automatically generates these based on Payload's order field.
The pages array only includes top-level docs (no parent):
- ✅ Docs without a parent
- ❌ Child docs (they appear under their parent automatically)
This prevents duplicates and maintains hierarchy.
Lexical content must be serialized to HTML:
import { serializeLexical } from '@/lib/lexical-serializer';
const htmlContent = await serializeLexical(doc.content, payload);The serializer handles:
- Headings, paragraphs, lists
- Links, images, code blocks
- Custom Lexical nodes
- Table of contents extraction
The template includes support for Payload's database KV adapter, which provides:
- Key-Value Storage: Efficient storage for cache, sessions, and temporary data
- Performance: Faster access to frequently used data
- Scalability: Better handling of high-traffic scenarios
- Integration: Seamless integration with DB for persistent storage
The KV adapter is automatically configured and works alongside your main database for optimal performance.
When querying Payload, use depth: 2 for collections:
const { docs } = await payload.find({
collection: 'docs',
depth: 2, // Resolves category and parent relationships
});This ensures relationships are populated, not just IDs.
-
Update Collection (
collections/Docs.ts):fields: [ // ... existing fields { name: 'author', type: 'text', } ]
-
Update Source Adapter (
lib/source.ts):data: { ...doc, author: doc.author, // Include new field }
-
Use in Pages:
const page = source.getPage(slugs); console.log(page.data.author);
You're trying to access source.pageTree directly. Use:
const src = await getSource();
const tree = src.pageTree;Check:
- Doc is Published (not Draft)
- Doc is assigned to a category
- Category exists and has an
ordervalue - Clear cache and restart dev server
The source adapter preserves Payload's order field. Verify:
- Docs have
ordervalues set - Order is ascending (1, 2, 3...)
- No duplicate orders at the same level