A schema-driven, branch-aware content management system for git-backed, statically-generated websites. CanopyCMS provides an editing interface on top of your existing git repository, enabling non-technical users to edit website content without touching Git directly. Content lives as MD/MDX/JSON files in your repo, changes happen on isolated branches, and publication flows through your existing GitHub PR workflow.
Key features:
- Schema-enforced content: Define your content structure with TypeScript - get runtime validation and type inference
- Flexible schema definition: Use config-based schemas,
.collection.jsonmeta files, or a hybrid approach - Branch-based editing: Every editor works on an isolated branch, preventing conflicts and enabling review workflows
- Git as source of truth: All content is versioned in git with full history, rollback, and PR-based review
- Live preview: See changes in real-time with click-to-focus field navigation
- Minimal integration: Just config, one editor component, and one API route
- Framework-agnostic core: Works with Next.js today, adaptable to other frameworks
- Quick Start
- Schema Registry and References
- Configuration Reference
- Content Identification and References
- Integration Guide
- Content Tree Builder
- Features
- AI-Ready Content
- Using the Editor
- Adopter Touchpoints Summary
- Environment Variables
- Documentation
npx canopycms initThe CLI will interactively ask for:
- Auth provider —
dev(local development, no real auth) orclerk(Clerk authentication). This only affects the post-init instructions; the generated code handles both providers at runtime via theCANOPY_AUTH_MODEenvironment variable. - Operating mode —
dev(direct editing in checkout) orprod-sim(simulates production with branch clones). This is written intocanopycms.config.ts. - App directory — where your Next.js app directory lives (default:
app, usesrc/appfor src-layout projects) - Include AI content endpoint? — generates route files to serve your content as AI-readable markdown (default: yes). See AI-Ready Content for details.
You can also pass flags to skip prompts:
npx canopycms init --auth dev --mode dev --app-dir appUse --non-interactive for CI (uses defaults), --force to overwrite existing files, or --no-ai to skip generating the AI content endpoint.
| File | Purpose |
|---|---|
canopycms.config.ts |
Main configuration (mode, editor settings) |
{appDir}/lib/canopy.ts |
Server-side context setup with auth plugin selection |
{appDir}/schemas.ts |
Entry schema definitions and registry |
{appDir}/api/canopycms/[...canopycms]/route.ts |
Single catch-all API route handler |
{appDir}/edit/page.tsx |
Editor page component |
{appDir}/ai/config.ts |
AI content configuration (included unless --no-ai is passed) |
{appDir}/ai/[...path]/route.ts |
AI content route handler (included unless --no-ai is passed) |
It also updates .gitignore to exclude CanopyCMS runtime directories (.canopy-dev/, .canopy-prod-sim/).
npm install canopycms canopycms-next canopycms-auth-dev canopycms-auth-clerkThe generated canopy.ts template imports both auth packages and selects the active one at runtime based on the CANOPY_AUTH_MODE environment variable (defaults to dev). Both packages must be installed.
Clerk peer dependencies: canopycms-auth-clerk declares @clerk/nextjs and @clerk/backend as peer dependencies. If you plan to use Clerk authentication, you must install them yourself:
npm install @clerk/nextjs @clerk/backendThese are not bundled with canopycms-auth-clerk so you control the Clerk SDK versions in your project. If you only use dev auth (the default), you can skip this step -- the Clerk peer dependency warnings are harmless when CANOPY_AUTH_MODE=dev.
Wrap your Next.js config with withCanopy():
// next.config.ts
import { withCanopy } from 'canopycms-next'
export default withCanopy({
// ...your existing Next.js config
})withCanopy() handles two things:
- Transpilation — Canopy packages export raw TypeScript; the wrapper adds them to
transpilePackagesautomatically. - React deduplication — When developing locally with
file:references or linked packages (npm link,pnpm link, etc.), the bundler can follow symlinks and load a second copy of React from the linked package'snode_modules, causing "Invalid hook call" crashes. The wrapper adds module aliases so React always resolves to your project's copy.
The React aliases are harmless when not strictly needed (e.g., when installing from npm), so withCanopy() is the recommended configuration for all adopters.
Edit {appDir}/schemas.ts with your content types. See Schema Registry and References for details.
If using Clerk auth, add middleware.ts to protect editor routes:
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isProtectedRoute = createRouteMatcher(['/edit(.*)', '/api/canopycms(.*)'])
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
await auth.protect()
}
})
export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
],
}npm run dev
# Visit http://localhost:3000/editThe init command adds .canopy-prod-sim/ and .canopy-dev/ to your .gitignore. Branch metadata (.canopy-meta/) is automatically excluded via git's info/exclude inside branch workspaces. In production modes, permissions and groups live on a separate git branch (canopycms-settings-{deploymentName}).
CanopyCMS supports two approaches for defining your content schema:
- Config-based schemas: Define everything in
canopycms.config.ts(traditional approach) - Meta file schemas: Define collections using
.collection.jsonfiles in your content directories (new approach) - Hybrid: Mix both approaches - use meta files for some collections and config for others
The meta file approach provides better separation of concerns by colocating schema definitions with content, making it easier to manage large content structures.
The schema references system has three key components:
- Schema Registry: A centralized registry of reusable field schemas defined in TypeScript
- Meta Files:
.collection.jsonfiles that reference schemas from the registry - Automatic Loading: CanopyCMS automatically scans your content directory for meta files and resolves schema references
Create a schemas file (e.g., app/schemas.ts) to define your field schemas and registry:
import { defineEntrySchema } from 'canopycms'
import { createEntrySchemaRegistry } from 'canopycms/server'
// Define your field schemas
export const postSchema = defineEntrySchema([
{ name: 'title', type: 'string', label: 'Title', required: true },
{
name: 'author',
type: 'reference',
label: 'Author',
collections: ['authors'],
displayField: 'name',
},
{ name: 'published', type: 'boolean', label: 'Published' },
{ name: 'body', type: 'markdown', label: 'Body' },
])
export const authorSchema = defineEntrySchema([
{ name: 'name', type: 'string', label: 'Name', required: true },
{ name: 'bio', type: 'string', label: 'Bio' },
{ name: 'avatar', type: 'image', label: 'Avatar' },
])
export const homeSchema = defineEntrySchema([
{ name: 'headline', type: 'string', label: 'Headline', required: true },
{ name: 'tagline', type: 'string', label: 'Tagline' },
{ name: 'content', type: 'markdown', label: 'Content' },
])
// Create the registry - validates schemas at creation time
export const entrySchemaRegistry = createEntrySchemaRegistry({
postSchema,
authorSchema,
homeSchema,
})Create .collection.json files in your content directories to define collections:
For a collection (content/posts/.collection.json):
{
"name": "posts",
"label": "Blog Posts",
"entries": [
{
"name": "post",
"format": "json",
"schema": "postSchema"
}
]
}For a singleton-like entry (content/pages/.collection.json):
{
"name": "pages",
"label": "Pages",
"entries": [
{
"name": "home",
"label": "Homepage",
"format": "json",
"schema": "homeSchema",
"maxItems": 1
}
]
}For nested collections (content/docs/.collection.json):
{
"name": "docs",
"label": "Documentation",
"entries": [
{
"name": "doc",
"format": "mdx",
"schema": "docSchema"
}
]
}Then create nested collections in subfolders (e.g., content/docs/guides/.collection.json):
{
"name": "guides",
"label": "Guides",
"entries": [
{
"name": "guide",
"format": "mdx",
"schema": "guideSchema"
}
]
}Pass your schema registry to createNextCanopyContext in app/lib/canopy.ts. The generated template handles auth provider selection at runtime:
import { createNextCanopyContext } from 'canopycms-next'
import { createClerkAuthPlugin, createClerkJwtVerifier } from 'canopycms-auth-clerk'
import { createDevAuthPlugin, createDevTokenVerifier } from 'canopycms-auth-dev'
import type { AuthPlugin } from 'canopycms/auth'
import { CachingAuthPlugin, FileBasedAuthCache } from 'canopycms/auth/cache'
import config from '../../canopycms.config'
import { entrySchemaRegistry } from '../schemas'
function getAuthPlugin(): AuthPlugin {
const mode = config.server.mode
const authMode = process.env.CANOPY_AUTH_MODE || 'dev'
// In prod/prod-sim: use CachingAuthPlugin (networkless JWT + file-based cache)
if (mode === 'prod' || mode === 'prod-sim') {
const cachePath =
process.env.CANOPY_AUTH_CACHE_PATH ??
(mode === 'prod-sim' ? '.canopy-prod-sim/.cache' : '/mnt/efs/workspace/.cache')
const tokenVerifier =
authMode === 'clerk'
? createClerkJwtVerifier({ jwtKey: process.env.CLERK_JWT_KEY ?? '' })
: createDevTokenVerifier()
return new CachingAuthPlugin(tokenVerifier, new FileBasedAuthCache(cachePath))
}
// In dev mode: use auth plugin directly
if (authMode === 'clerk') {
return createClerkAuthPlugin({ useOrganizationsAsGroups: true })
}
return createDevAuthPlugin()
}
// Static deployments don't need auth — no HTTP requests, no users.
// Server deployments should provide authPlugin for authenticated reads.
const isStaticDeploy = config.server.deployedAs === 'static'
const canopyContextPromise = createNextCanopyContext({
config: config.server,
...(!isStaticDeploy ? { authPlugin: getAuthPlugin() } : {}),
entrySchemaRegistry, // Enable .collection.json file support
})
export const getCanopy = async () => {
const context = await canopyContextPromise
return context.getCanopy()
}
export const getHandler = async () => {
const context = await canopyContextPromise
return context.handler
}.collection.json structure:
{
"name": "collectionName", // Required: collection identifier
"label": "Display Name", // Optional: human-readable label
"entries": [ // Optional: array of entry types in this collection
{
"name": "entryTypeName", // Required: entry type identifier
"label": "Display Name", // Optional: human-readable label
"format": "json" | "md" | "mdx", // Optional: defaults to json
"schema": "schemaRegistryKey", // Required: key from schema registry
"maxItems": 1 // Optional: limit instances (1 = singleton-like)
}
]
}Root .collection.json (content/.collection.json):
{
"entries": [ // Optional: entry types at root level
{
"name": "home",
"format": "json",
"schema": "homeSchema",
"maxItems": 1 // Singleton-like: only one homepage
}
]
}Here's how your content directory might look with meta files:
content/
├── pages/
│ ├── .collection.json # Pages collection (homepage entry type with maxItems: 1)
│ └── page.home.a1b2c3d4e5f6.json # Homepage entry (type.slug.id.ext)
├── posts/
│ ├── .collection.json # Posts collection definition
│ ├── post.my-first-post.x9y8z7w6v5u4.json
│ └── post.another-post.q3r4s5t6u7v8.json
├── authors/
│ ├── .collection.json # Authors collection definition
│ ├── alice.json
│ └── bob.json
└── docs/
├── .collection.json # Docs collection definition
├── intro.mdx
├── guides/
│ ├── .collection.json # Nested guides collection
│ ├── getting-started.mdx
│ └── advanced.mdx
└── api/
├── .collection.json # Nested API docs collection
└── reference.mdx
Separation of Concerns:
- Content structure lives near the content itself
- TypeScript schemas provide type safety and reusability
- Easy to reorganize content without touching config
Scalability:
- Add new collections by creating a folder and meta file
- Schema registry keeps field definitions DRY
- Large content structures are easier to navigate
Flexibility:
- Use meta files for some collections, config for others
- Override or extend meta file schemas in config if needed
- Gradual migration path from config-based to meta file approach
You can mix both approaches in the same project:
// canopycms.config.ts
export default defineCanopyConfig({
schema: {
// Config-based collection
collections: [
{
name: 'pages',
label: 'Pages',
path: 'pages',
entries: [
{
name: 'page',
format: 'mdx',
schema: pageSchema, // Inline schema definition
},
],
},
],
// Note: Collections defined in .collection.json files will be
// automatically merged with these config-based collections
},
// ...other config
})Collections defined in .collection.json files are automatically loaded and merged with any collections defined in your config. This gives you maximum flexibility to choose the best approach for each part of your content structure.
CanopyCMS validates schema references at startup:
- Missing schemas: Clear error messages if a referenced schema doesn't exist in the registry
- Invalid meta files: JSON validation with helpful error messages
- Type safety: Schema registry gets full TypeScript type checking
Example error message:
Error: Schema reference "postSchema" in collection "posts" not found in registry.
Available schemas: authorSchema, homeSchema, docSchema
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
schema |
RootCollectionConfig |
No* | - | Object with collections and entries arrays defining your content structure. *Required unless using .collection.json meta files |
gitBotAuthorName |
string |
Yes | - | Name used for git commits made by CanopyCMS |
gitBotAuthorEmail |
string |
Yes | - | Email used for git commits made by CanopyCMS |
mode |
'dev' | 'prod-sim' | 'prod' |
No | 'dev' |
Operating mode (see below) |
contentRoot |
string |
No | 'content' |
Root directory for content files relative to project root |
defaultBaseBranch |
string |
No | 'main' |
Default git branch to base edits on |
defaultBranchAccess |
'allow' | 'deny' |
No | 'deny' |
Default access policy for new branches |
defaultPathAccess |
'allow' | 'deny' |
No | 'allow' |
Default access policy for content paths |
deployedAs |
'server' | 'static' |
No | 'server' |
Deployment shape. 'static': site is pre-built with no live editor; all CMS API requests return 401 and authPlugin is not required. 'server': normal server-rendered deployment with auth enforced. |
media |
MediaConfig |
No | - | Asset storage configuration (local, s3, or lfs) |
editor |
EditorConfig |
No | - | Editor UI customization options |
Note: You must define your schema using at least one of these approaches:
- Config-based: Set the
schemaoption indefineCanopyConfig - Meta file-based: Create
.collection.jsonfiles in your content directory (requires passingentrySchemaRegistrytocreateNextCanopyContext) - Hybrid: Use both approaches together - schemas will be merged
See the Schema Registry and References section for details on using .collection.json meta files.
dev: Direct file editing in your current checkout. Best for solo development. Settings stored in.canopy-dev/.prod-sim: Simulates production locally with per-branch clones in.canopy-prod-sim/branches/. Use for testing branch workflows.prod: Full production deployment with branch workspaces on persistent storage (e.g., AWS Lambda + EFS).
The schema uses a unified collection-based structure. Collections contain entry types, which define the types of content allowed within that collection. Each entry type has its own schema (fields), format, and optional cardinality constraints.
Entry types define what kind of content can exist in a collection:
- For repeatable content (blog posts, products), create an entry type without restrictions
- For unique content (homepage, settings), create an entry type with
maxItems: 1 - You can mix multiple entry types in a single collection
const config = defineCanopyConfig({
// ...required fields...
schema: {
collections: [
// Collection with repeatable entries (e.g., blog posts)
{
name: 'posts',
label: 'Blog Posts',
path: 'posts', // Files at content/posts/*.json
entries: [
{
name: 'post',
format: 'json', // or 'md', 'mdx'
schema: [...],
},
],
},
// Collection with singleton-like entry (e.g., homepage)
{
name: 'pages',
label: 'Pages',
path: 'pages',
entries: [
{
name: 'home',
label: 'Homepage',
format: 'json',
schema: [...],
maxItems: 1, // Only one homepage allowed
},
],
},
// Collection with multiple entry types
{
name: 'docs',
label: 'Documentation',
path: 'docs',
entries: [
{
name: 'guide',
label: 'Guide',
format: 'mdx',
schema: [...],
},
{
name: 'tutorial',
label: 'Tutorial',
format: 'mdx',
schema: [...],
},
],
// Nested collections
collections: [
{
name: 'api',
label: 'API Reference',
path: 'api',
entries: [
{
name: 'endpoint',
format: 'mdx',
schema: [...],
},
],
},
],
},
],
// Entry types at root level (optional)
entries: [
{
name: 'settings',
label: 'Site Settings',
format: 'json',
schema: [...],
maxItems: 1, // Singleton-like at root level
},
],
},
})Key concepts:
- Collections are containers for content, organized by path (e.g.,
posts,docs/guides) - Entry types define the types of content within a collection, each with its own schema
- Multiple entry types: A collection can have multiple entry types (e.g., "guide" and "tutorial" in docs)
- Singleton-like behavior: Use
maxItems: 1to limit an entry type to a single instance - Nesting: Collections can contain nested collections for hierarchical content structures
- Root entries: The root schema can have entry types directly (useful for site-wide settings)
| Type | Description | Options |
|---|---|---|
string |
Single-line text | - |
number |
Numeric value | - |
boolean |
True/false toggle | - |
datetime |
Date and time picker | - |
markdown |
Markdown text editor | - |
mdx |
MDX editor with component support | - |
rich-text |
Rich text editor | - |
image |
Image upload/selection | - |
code |
Code editor with syntax highlighting | - |
select |
Dropdown selection | options: string[] | {label, value}[] |
reference |
Reference to another content entry (UUID-based) | collections: string[], displayField?: string |
object |
Nested object | fields: FieldConfig[] |
block |
Block-based content | templates: BlockTemplate[] |
Common field options:
{
name: 'fieldName', // Required: unique field identifier
type: 'string', // Required: field type
label: 'Field Label', // Optional: display label (defaults to name)
required: true, // Optional: validation requirement
list: true, // Optional: allow multiple values
}Example with reference field:
const schema = defineEntrySchema([
{ name: 'title', type: 'string', label: 'Title', required: true },
{ name: 'body', type: 'markdown', label: 'Content' },
{
name: 'author',
type: 'reference',
label: 'Author',
collections: ['authors'], // Load options from 'authors' collection
displayField: 'name', // Show the author's name in the dropdown
},
{
name: 'relatedPosts',
type: 'reference',
label: 'Related Posts',
collections: ['posts'],
displayField: 'title',
list: true, // Allow multiple references
},
])Example with all field types:
const schema = defineEntrySchema([
{ name: 'title', type: 'string', label: 'Title', required: true },
{ name: 'views', type: 'number', label: 'View Count' },
{ name: 'published', type: 'boolean', label: 'Published' },
{ name: 'publishDate', type: 'datetime', label: 'Publish Date' },
{ name: 'body', type: 'markdown', label: 'Content' },
{ name: 'featuredImage', type: 'image', label: 'Featured Image' },
{
name: 'category',
type: 'select',
label: 'Category',
options: ['tech', 'lifestyle', 'news'],
},
{
name: 'author',
type: 'reference',
label: 'Author',
collections: ['authors'],
displayField: 'name',
},
{
name: 'metadata',
type: 'object',
label: 'SEO Metadata',
fields: [
{ name: 'description', type: 'string' },
{ name: 'keywords', type: 'string', list: true },
],
},
{
name: 'blocks',
type: 'block',
label: 'Page Blocks',
templates: [
{
name: 'hero',
label: 'Hero Section',
fields: [
{ name: 'headline', type: 'string' },
{ name: 'body', type: 'markdown' },
],
},
{
name: 'cta',
label: 'Call to Action',
fields: [
{ name: 'text', type: 'string' },
{ name: 'link', type: 'string' },
],
},
],
},
])Every entry in your content automatically receives a unique, stable identifier. CanopyCMS uses 12-character UUIDs (Base58-encoded, truncated) that are:
- Stable across renames: The ID is embedded in the filename (e.g.,
my-post.a1b2c3d4e5f6.json), so it persists even when you change the slug portion - Globally unique: IDs are automatically generated and guaranteed unique across your entire site (~2.6 × 10^21 possible IDs)
- Git-friendly: IDs are visible in filenames, making them easy to track in git diffs and preserved through
git mv - Human-readable: Filenames show both the human-friendly slug and the unique ID
- Automatic: You never manually create or manage IDs - they're generated when entries are created
Reference fields let you create typed relationships between content entries. Unlike brittle string links or file paths, references use UUIDs to create robust, move-safe links:
const schema = defineEntrySchema([
{ name: 'title', type: 'string', label: 'Title' },
{
name: 'category',
type: 'reference',
label: 'Category',
collections: ['categories'], // Only allow references to entries in 'categories'
displayField: 'name', // Show the category name (not the ID) in the UI
},
{
name: 'tags',
type: 'reference',
label: 'Tags',
collections: ['tags'],
displayField: 'label',
list: true, // Allow multiple references
},
])Key benefits:
- Type safety: The editor validates that references always point to valid entries
- Dynamic options: The reference field automatically loads available options from the specified collections
- Move-safe: References survive file renames and directory moves - the ID is permanent
- No broken links: If you delete an entry, you'll see validation errors on any entries referencing it
- Display flexibility: Show any field from the referenced entry (title, name, slug, etc.) in dropdowns
When editing a reference field:
- Click the dropdown to see all available entries from the configured collections
- Search by the display field value (e.g., search for author names)
- Select an entry - CanopyCMS stores the UUID internally
- When reading content, the UUID is resolved to the actual entry data
When you read content with references, CanopyCMS stores the UUIDs. To resolve them back to data:
// In your server component
const { data } = await canopy.read({
entryPath: 'content/posts',
slug: 'my-post',
})
// data.author is a UUID string (e.g., "abc123DEF456ghi789")
// You would need to separately load the author entry if needed
const author = await canopy.read({
entryPath: 'content/authors',
id: data.author,
})Use TypeFromEntrySchema to get TypeScript types from your schema:
import { defineEntrySchema, TypeFromEntrySchema } from 'canopycms'
const postSchema = defineEntrySchema([
{ name: 'title', type: 'string', required: true },
{ name: 'tags', type: 'string', list: true },
])
// Inferred type: { title: string; tags: string[] }
type Post = TypeFromEntrySchema<typeof postSchema>The getCanopy() function provides automatic authentication and branch handling in Next.js server components:
// app/posts/[slug]/page.tsx
import { getCanopy } from '../lib/canopy'
export default async function PostPage({ params, searchParams }) {
const canopy = await getCanopy()
const { data } = await canopy.read({
entryPath: 'content/posts',
slug: params.slug,
branch: searchParams?.branch, // Optional: defaults to main
})
return <PostView post={data} />
}Key benefits:
- Automatic authentication: Current user extracted from request headers via auth plugin
- Bootstrap admin groups: Admin users automatically get
adminsgroup membership - Build mode support: Permissions bypassed during
next buildfor static generation - Type-safe: Full TypeScript support with inferred types from your schema
- Per-request caching: Context is cached using React's
cache()for the request lifecycle
The context object provides:
read(): Read content with automatic auth and branch resolutionbuildContentTree(): Build a typed content tree for navigation, sitemaps, etc. (see Content Tree Builder)user: Current authenticated user (with bootstrap admin groups applied)services: Underlying CanopyCMS services for advanced use cases
For cases where you need more control (e.g., reading as a specific user or in non-request contexts), you can use the lower-level createContentReader:
import { createContentReader } from 'canopycms/server'
import { ANONYMOUS_USER } from 'canopycms'
import config from '../canopycms.config'
const reader = createContentReader({ config: config.server })
const { data } = await reader.read({
entryPath: 'content/posts',
slug: 'my-post',
branch: 'main',
user: ANONYMOUS_USER, // Explicit user required
})Not Yet Implemented
editor: {
title: 'My CMS',
subtitle: 'Content Editor',
theme: {
colors: {
brand: '#4f46e5',
accent: '#0ea5e9',
neutral: '#0f172a',
},
},
}buildContentTree() walks your schema and filesystem to produce a typed tree of all your content -- useful for navigation sidebars, sitemaps, search indexes, breadcrumbs, and similar use cases. It replaces hundreds of lines of manual filesystem-walking code.
// app/layout.tsx (or any server component)
import { getCanopy } from './lib/canopy'
export default async function RootLayout({ children }) {
const canopy = await getCanopy()
const tree = await canopy.buildContentTree()
// tree is ContentTreeNode[] — a hierarchy of collections and entries
return (
<html>
<body>
<Sidebar tree={tree} />
{children}
</body>
</html>
)
}Each node in the tree has:
path-- URL path (e.g.,"/docs/getting-started")logicalPath-- CMS logical pathkind--"collection"or"entry"collection-- collection metadata (name, label) whenkind === "collection"entry-- entry metadata (slug, entryType, format, raw data) whenkind === "entry"fields-- custom fields extracted via yourextractcallbackchildren-- nested nodes (entries + subcollections, ordered by collection ordering)
Use the generic extract callback to pull typed fields from each node's raw data (frontmatter for md/mdx, parsed JSON for json entries):
interface NavItem {
title: string
draft: boolean
order: number
}
const tree = await canopy.buildContentTree<NavItem>({
extract: (data) => ({
title: (data.title as string) ?? '',
draft: (data.draft as boolean) ?? false,
order: (data.order as number) ?? 0,
}),
})
// tree nodes now have typed `fields: NavItem`
// e.g., tree[0].children?.[0].fields?.titleThe filter callback runs after extract, so you can filter based on extracted fields. Returning false excludes a node and all its descendants:
const tree = await canopy.buildContentTree<NavItem>({
extract: (data) => ({
title: (data.title as string) ?? '',
draft: (data.draft as boolean) ?? false,
order: (data.order as number) ?? 0,
}),
filter: (node) => node.fields?.draft !== true,
})By default, children at each level are sorted by the collection's order array first, then alphabetically. The sort option lets you replace this entirely with your own comparator. It runs after extract and filter, so fields is available on every node:
const tree = await canopy.buildContentTree<NavItem>({
extract: (data) => ({
title: (data.title as string) ?? '',
draft: (data.draft as boolean) ?? false,
order: (data.order as number) ?? 0,
}),
filter: (node) => node.fields?.draft !== true,
sort: (a, b) => (a.fields?.order ?? 0) - (b.fields?.order ?? 0),
})| Option | Type | Default | Description |
|---|---|---|---|
rootPath |
string |
Content root | Starting collection path (e.g., "content/docs" for a subtree) |
extract |
(data, node) => T |
- | Extract typed custom fields from raw entry/collection data |
filter |
(node: ContentTreeNode<T>) => boolean |
- | Return false to exclude a node and its descendants |
buildPath |
(logicalPath, kind) => string |
Strips content root | Custom URL path builder |
sort |
(a: ContentTreeNode<T>, b: ContentTreeNode<T>) => number |
Order array then alphabetical | Custom sort for children at each level (replaces default sort) |
maxDepth |
number |
Unlimited | Maximum depth to traverse |
// Types (for use in your components)
import type { ContentTreeNode, BuildContentTreeOptions } from 'canopycms'
// Via CanopyContext (recommended)
const canopy = await getCanopy()
const tree = await canopy.buildContentTree(options)
// Raw function (advanced — requires branchRoot, flatSchema, contentRootName)
import { buildContentTree } from 'canopycms/server'Every entry gets an automatic UUID that stays the same even when you rename or move files. Reference fields use these IDs to create type-safe relationships that never break. The editor shows human-readable labels while storing stable identifiers, optimizing both for user experience and data integrity.
- Create or select a branch: Each editor works in isolation
- Make changes: Edits are saved to the branch workspace
- Submit for review: Creates a GitHub PR with all changes
- Review and merge: Standard PR workflow on GitHub
- Deploy: Your CI/CD rebuilds the site after merge
Comments enable asynchronous review workflows at three levels:
- Field comments: Attached to specific form fields for targeted feedback
- Entry comments: General feedback on an entire content entry
- Branch comments: Discussion about the overall changeset
Comments are stored in .canopy-meta/comments.json per branch workspace and are NOT committed to git (they're review artifacts, excluded via git's info/exclude mechanism).
Access control uses three layers:
- Branch access: Per-branch ACLs control who can access each branch
- Path permissions: Glob patterns restrict who can edit specific content paths
- Reserved groups:
admins(full access) andreviewers(review branches, approve PRs)
Bootstrap admin groups: When using getCanopy(), users with IDs matching the bootstrapAdminIds configuration automatically receive the admins group membership, even before groups are set up in the repository. This makes initial setup easier.
Build mode bypass: During next build, all permission checks are bypassed to allow static generation of all content, regardless of auth configuration.
The editor shows a live preview of your actual site pages in an iframe. Changes update immediately via postMessage. Clicking elements in the preview focuses the corresponding form field.
CanopyCMS can serve your content as clean markdown for AI consumption (LLM tools, Claude Code, documentation chatbots, etc.). Content is converted from your schema-driven JSON/MD/MDX entries into well-structured markdown with a discovery manifest. No authentication is required -- the output is read-only.
All content is included by default (opt-out exclusion model). You can exclude specific collections, entry types, or entries matching a custom predicate.
Serve AI content dynamically from a Next.js catch-all route. Content is generated on first request and cached (in dev mode, regenerated on every request).
This is set up automatically by npx canopycms init (unless you pass --no-ai). The generated files are {appDir}/ai/config.ts and {appDir}/ai/[...path]/route.ts. To set it up manually, create app/ai/[...path]/route.ts:
import { createAIContentHandler } from 'canopycms/ai'
import config from '../../../canopycms.config'
import { entrySchemaRegistry } from '../../schemas'
export const GET = createAIContentHandler({
config: config.server,
entrySchemaRegistry,
})This serves:
GET /ai/manifest.json-- discovery manifest listing all collections, entries, and bundlesGET /ai/posts/my-post.md-- individual entry as markdownGET /ai/posts/all.md-- all entries in a collection concatenatedGET /ai/bundles/my-bundle.md-- custom filtered bundle
Generate AI content as static files during your build process:
npx canopycms generate-ai-content --output public/aiOptions:
--output <dir>-- output directory (default:public/ai)--config <path>-- path to an AI content config file--app-dir <path>-- app directory whereschemas.tslives (default:app; usesrc/appfor src-layout projects)
Call the generator directly from a build script:
import { generateAIContentFiles } from 'canopycms/build'
import config from './canopycms.config'
import { entrySchemaRegistry } from './app/schemas'
await generateAIContentFiles({
config: config.server,
entrySchemaRegistry,
outputDir: 'public/ai',
})Use defineAIContentConfig to customize what content is generated and how fields are converted:
import { defineAIContentConfig } from 'canopycms/ai'
const aiConfig = defineAIContentConfig({
// Opt-out exclusions
exclude: {
collections: ['drafts'], // Skip entire collections
entryTypes: ['internal-note'], // Skip entry types everywhere
where: (entry) => entry.data.hidden === true, // Custom predicate
},
// Custom bundles (filtered subsets as single files)
bundles: [
{
name: 'research-guides',
description: 'All research guide content',
filter: {
collections: ['docs'],
entryTypes: ['guide'],
},
},
],
// Per-field markdown overrides
fieldTransforms: {
dataset: {
dataFields: (value) =>
`## Data Fields\n| Name | Type |\n|---|---|\n${(value as Array<{ name: string; type: string }>).map((f) => `| ${f.name} | ${f.type} |`).join('\n')}`,
},
},
})Pass the config to either delivery mechanism:
// Route handler
export const GET = createAIContentHandler({
config: config.server,
entrySchemaRegistry,
aiConfig,
})
// Static build
await generateAIContentFiles({
config: config.server,
entrySchemaRegistry,
outputDir: 'public/ai',
aiConfig,
})The manifest at manifest.json describes all generated content for tool discovery:
{
"generated": "2026-03-23T12:00:00.000Z",
"entries": [],
"collections": [
{
"name": "posts",
"label": "Blog Posts",
"path": "posts",
"allFile": "posts/all.md",
"entryCount": 5,
"entries": [{ "slug": "my-post", "title": "My Post", "file": "posts/my-post.md" }]
}
],
"bundles": [
{
"name": "research-guides",
"description": "All research guide content",
"file": "bundles/research-guides.md",
"entryCount": 3
}
]
}This section describes how to use the CanopyCMS editor interface from a content editor's perspective.
- Navigate to your editor URL (e.g.,
/edit) - Sign in with your authentication provider (Clerk, etc.)
- Select or create a branch to work on
Creating a branch:
- Click the branch selector in the header
- Click "New Branch"
- Enter a descriptive name (e.g.,
update-homepage-hero) - Your branch is created and you can start editing
Switching branches:
- Click the branch selector
- Choose from available branches
- The editor loads content from the selected branch
Selecting an entry:
- Use the sidebar to browse collections
- Click an entry to open it in the editor
- Create new entries with the "+" button (disabled for entry types with
maxItems: 1when one already exists)
Making changes:
- Edit fields using the form on the left
- See changes reflected in the live preview on the right
- Click "Save" to persist changes to your branch (changes are NOT committed yet)
Discarding changes:
- Use "Discard" to revert unsaved changes to the last saved state
When your changes are ready:
- Click "Submit for Review" in the header
- This commits your changes and creates a GitHub PR
- The PR can be reviewed using standard GitHub workflows
- Once merged, your changes are deployed with the next site build
Adding field comments:
- Hover over a field label
- Click the comment icon
- Type your comment and submit
Viewing comments:
- Comments appear as badges on fields
- Click a comment badge to see the thread and add replies
Resolving comments:
- Mark comments as resolved once addressed
Admins can configure access control:
- Go to Settings (gear icon)
- Groups: Create groups and add users
- Permissions: Set path-based access rules
CanopyCMS is designed for minimal integration effort. Run npx canopycms init to generate all required files, or create them manually. Use --app-dir to customize the app directory path (default: app).
| Touchpoint | File | Purpose |
|---|---|---|
| Config | canopycms.config.ts |
Define settings and operating mode |
| Next.js wrap | next.config.ts |
Wrap with withCanopy() from canopycms-next |
| Schemas | {appDir}/schemas.ts |
Field schemas and registry (for .collection.json approach) |
| Context | {appDir}/lib/canopy.ts |
One-time async setup with auth plugin |
| API Route | {appDir}/api/canopycms/[...canopycms]/route.ts |
Single catch-all handler |
| Editor Page | {appDir}/edit/page.tsx |
Embed the editor component |
| Middleware | middleware.ts |
Protect editor routes with authentication (Clerk only) |
Optional touchpoints:
- Server components: Use
await getCanopy()to read draft content with automatic auth - AI content route:
{appDir}/ai/[...path]/route.ts-- serve content as AI-readable markdown; generated by default duringinit(see AI-Ready Content)
To switch between auth providers, set the CANOPY_AUTH_MODE environment variable (dev or clerk). The generated code handles both providers without regenerating files.
Everything else (branch management, content storage, permissions, comments, bootstrap admin groups, meta file loading) is handled automatically by CanopyCMS.
For CanopyCMS:
CANOPY_AUTH_MODE=dev # Auth provider: "dev" (default) or "clerk"
CANOPY_BOOTSTRAP_ADMIN_IDS=user_123,user_456 # Comma-separated user IDs that get auto-admin access
CANOPY_AUTH_CACHE_PATH=.canopy-prod-sim/.cache # Override auth cache location (prod/prod-sim only)For Clerk authentication:
CLERK_SECRET_KEY=sk_...
CLERK_PUBLISHABLE_KEY=pk_...
CLERK_JWT_KEY=... # Optional: for networkless JWT verification
CLERK_AUTHORIZED_PARTIES=... # Optional: comma-separated domainsFor GitHub integration (production mode):
GITHUB_BOT_TOKEN=ghp_... # Bot token for PR creation- DEVELOPING.md - Development guidelines for contributors (note: the CanopyCMS monorepo uses pnpm workspaces; see DEVELOPING.md for setup)
- ARCHITECTURE.md - Internal architecture (for contributors)