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
- Schema-driven content: Define entry schemas once with
defineEntrySchema, register them withcreateEntrySchemaRegistry, and reference them from.collection.jsonfiles alongside your content - 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
- Sanitizing URLs from CMS Content
- Content Tree Builder
- Listing Entries
- Features
- AI-Ready Content
- Using the Editor
- Adopter Touchpoints Summary
- Local Development Sync
- Migrating Existing Content
- 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(full local development with branching and git ops) orprod(production deployment). 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 --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; exports getCanopy, phase-selecting read/readByUrlPath, contentStaticParams, getHandler (and getCanopyForBuild as an advanced escape hatch) |
{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) |
middleware.ts |
Route protection for /edit and /api/canopycms (passthrough by default; commented Clerk example inside) |
next.config.ts |
Next.js config wrapped with withCanopy() for transpilation and dual-build support |
It also updates .gitignore to exclude CanopyCMS runtime directories (.canopy-dev/).
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.
The init command creates a next.config.ts that wraps your config with withCanopy() from canopycms-next/config. You do not need to set this up manually.
If you already have a next.config.ts, the init command will ask before overwriting. To add the wrapper to an existing config, merge it like this:
// next.config.ts
import { withCanopy } from 'canopycms-next/config'
export default withCanopy({
// ...your existing Next.js config
})withCanopy() handles three things:
- Transpilation — Canopy packages export raw TypeScript; the wrapper auto-detects which Canopy packages are installed and adds only those to
transpilePackages. You never need to maintain this list manually. - 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. - Dual-build page extensions — By default, adds
server.tsandserver.tsxto Next.jspageExtensions, enabling the dual-build convention (see below).
The React aliases are harmless when not strictly needed (e.g., when installing from npm), so withCanopy() is the recommended configuration for all adopters.
If you deploy both a static public site and a separate CMS server from the same Next.js app, use the staticBuild option and the .server.ts/.server.tsx file extension convention:
-
Name CMS-only files with
.server.tsor.server.tsxextensions (e.g.,route.server.ts,page.server.tsx). These files contain your API route handler and editor page -- things the static site does not need. -
Toggle the build using an environment variable:
// next.config.ts
import { withCanopy } from 'canopycms-next/config'
// CANOPY_BUILD=static -> static export of the public site (editor/API excluded)
// CANOPY_BUILD=cms -> standalone Node.js server for the CMS
// unset (next dev, or a plain `next build`) -> regular server build with the editor included
const buildFlavor = process.env.CANOPY_BUILD
export default withCanopy(
{
...(buildFlavor === 'static'
? { output: 'export' as const }
: buildFlavor === 'cms'
? { output: 'standalone' as const }
: {}),
},
{ staticBuild: buildFlavor === 'static' },
)When staticBuild is true (CANOPY_BUILD=static), CMS-only .server.ts/.server.tsx files are excluded, making them invisible to the static export build. Otherwise — including plain next dev — withCanopy() adds those extensions to pageExtensions so Next.js processes them, which is why the editor works in local development without any env var.
Pair this with deployedAs: process.env.CANOPY_BUILD === 'static' ? 'static' : 'server' in canopycms.config.ts (generated by init when you choose dual-build) so the static export also skips auth and git operations at build time.
- Build each target separately in your CI:
# Static public site (no CMS code included)
CANOPY_BUILD=static next build
# CMS server (includes editor + API routes)
CANOPY_BUILD=cms next buildIf you are not doing dual-build deployment (most setups), you can ignore this option entirely -- the default behavior works for both development and single-build production.
Note:
withCanopy()addsserver.tsandserver.tsxto Next.jspageExtensionsby default. If you already have files ending in.server.tsor.server.tsxinside your app directory for non-CMS purposes, they will be treated as pages/routes by Next.js. Rename them or use a different naming convention to avoid conflicts.
Edit {appDir}/schemas.ts with your content types. See Schema Registry and References for details.
The init command generates a middleware.ts that matches /edit and /api/canopycms routes. By default it is a passthrough (suitable for dev auth mode). For Clerk auth, replace the file contents with the commented example inside, or use this:
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: ['/edit(.*)', '/api/canopycms(.*)'],
}npm run dev
# Visit http://localhost:3000/editThe init command adds .canopy-dev/ to your .gitignore. Branch metadata is automatically excluded via git's info/exclude inside branch workspaces. In production mode, permissions and groups live on a separate git branch (canopycms-settings-{deploymentName}).
Schemas live in TypeScript (declared via defineEntrySchema) and are registered with createEntrySchemaRegistry. Your content lives in .collection.json files that reference schemas from the registry by name.
Three components:
- Schema Registry — a TypeScript object mapping entry-type names to their field schemas, created with
createEntrySchemaRegistry. - Meta Files —
.collection.jsonfiles in your content directories that reference schemas from the registry via theirentry.schemaproperty. - Automatic Loading — CanopyCMS scans your content directory for meta files and resolves schema references when the editor starts and at build time.
Create a schemas file (e.g., app/schemas.ts):
import { defineEntrySchema, type EntryTypesFromRegistry } from 'canopycms'
import { createEntrySchemaRegistry } from 'canopycms/server'
// 1. Declare your entry 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' },
])
// 2. Register them. KEY EACH SCHEMA BY ENTRY-TYPE NAME — the same string
// that appears in your `.collection.json` files' `entry.schema` property,
// in filenames (`post.<slug>.<id>.mdx`), and in `meta.entryType` from the
// tree builder. Keying this way lets EntryTypesFromRegistry derive your
// typed entry-type map automatically (see step 3).
export const entrySchemaRegistry = createEntrySchemaRegistry({
post: postSchema,
author: authorSchema,
home: homeSchema,
})
// 3. Derive a typed entry-type map. Pass `EntryTypes` as the second generic
// to `canopy.buildContentTree<NavFields, EntryTypes>(...)` to get narrowed
// access to `meta.indexEntry.data` after switching on `meta.entryType`.
export type EntryTypes = EntryTypesFromRegistry<typeof entrySchemaRegistry>
// 4. Per-schema aliases derive cleanly from EntryTypes — single source of truth.
export type PostContent = EntryTypes['post']
export type AuthorContent = EntryTypes['author']
export type HomeContent = EntryTypes['home']The string in .collection.json's entry.schema field is a lookup key into the registry. Picking the entry-type name as that key:
- Removes one level of indirection (
entry.nameandentry.schemaare the same string in the common case). - Makes error messages clearer (
Available schemas: post, author, homerather thanpostSchema, authorSchema, homeSchema). - Lets
EntryTypesFromRegistry<typeof entrySchemaRegistry>derive the typed entry-type map automatically. Without it you'd declare a parallelMyEntriesinterface usingTypeFromEntrySchema<typeof postSchema>per entry — the previously-documented fallback.
If you have multiple entry types that share one schema ({ partner-v1: ..., partner-v2: ... } both pointing at partnerSchema), name-keying still works — it just means two registry entries hold the same schema reference. Workable; ugly. If that's your situation, see the migration section below for the manual MyEntries fallback.
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": "post"
}
]
}For a singleton-like entry (content/pages/.collection.json):
{
"name": "pages",
"label": "Pages",
"entries": [
{
"name": "home",
"label": "Homepage",
"format": "json",
"schema": "home",
"maxItems": 1
}
]
}For nested collections (content/docs/.collection.json):
{
"name": "docs",
"label": "Documentation",
"entries": [
{
"name": "doc",
"format": "mdx",
"schema": "doc"
}
]
}Then create nested collections in subfolders (e.g., content/docs/guides/.collection.json):
{
"name": "guides",
"label": "Guides",
"entries": [
{
"name": "guide",
"format": "mdx",
"schema": "guide"
}
]
}Pass your schema registry to createNextCanopyContext in app/lib/canopy.ts. The npx canopycms init command generates this file automatically:
import { createNextCanopyContext, type GenerateContentStaticParamsOptions } from 'canopycms-next'
import { createClerkAuthPlugin } from 'canopycms-auth-clerk'
import { createDevAuthPlugin } from 'canopycms-auth-dev'
import config from '../../canopycms.config'
import { entrySchemaRegistry } from '../schemas'
const canopyContextPromise = createNextCanopyContext({
config: config.server,
authPlugin:
process.env.CANOPY_AUTH_MODE === 'clerk'
? createClerkAuthPlugin({ useOrganizationsAsGroups: true })
: createDevAuthPlugin(),
entrySchemaRegistry, // Enable .collection.json file support
})
// For server component pages (request-scoped, auth-aware)
export const getCanopy = async () => {
const context = await canopyContextPromise
return context.getCanopy()
}
// Phase-selecting reads: filesystem-direct at build, branch-aware (ACL-enforced) at request time.
// Recommended for resolving a page by URL/path in a [...slug] / [slug] route.
export const readByUrlPath = async <T = unknown>(urlPath: string) => {
const context = await canopyContextPromise
return context.readByUrlPath<T>(urlPath)
}
// Enumeration-only static params (no admin context exposed). Use in generateStaticParams.
export const contentStaticParams = async (options?: GenerateContentStaticParamsOptions) => {
const context = await canopyContextPromise
return context.generateContentStaticParams(options)
}
// Advanced escape hatch: bypasses all ACLs (synthetic admin) and throws if used at request time on a
// production server. Prefer readByUrlPath/read/contentStaticParams above.
export const getCanopyForBuild = async () => {
const context = await canopyContextPromise
return context.getCanopyForBuild()
}
// For API routes
export const getHandler = async () => {
const context = await canopyContextPromise
return context.handler
}For production deployments that need networkless JWT verification (e.g., AWS Lambda without internet access), you can replace the auth setup with CachingAuthPlugin and createClerkJwtVerifier. See the ARCHITECTURE.md deployment section for details.
.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)
}
],
"order": ["<contentId>", ...] // Optional: explicit item ordering; omitted/empty = alphabetical
}Root .collection.json (content/.collection.json):
{
"entries": [ // Optional: entry types at root level
{
"name": "home",
"format": "json",
"schema": "home",
"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
If your registry currently uses schema-variable names as keys (the convention previously generated by npx canopycms init), you have two paths.
Path A — Migrate to the entry-type-name convention (recommended). Mechanical changes only; runtime behavior is identical afterward.
-
Rename the registry keys in
schemas.ts:// Before export const entrySchemaRegistry = createEntrySchemaRegistry({ postSchema, authorSchema, homeSchema, }) // After export const entrySchemaRegistry = createEntrySchemaRegistry({ post: postSchema, author: authorSchema, home: homeSchema, })
-
Update every
.collection.jsonfile to match. Theentry.schemastrings reference the registry keys you just renamed:{ "entries": [ { "name": "post", "format": "mdx", - "schema": "postSchema" + "schema": "post" } ] }A find-and-replace across
content/**/.collection.jsoncovers it:"schema": "postSchema"→"schema": "post", repeated per schema. Then verify withgrep -r "Schema\"" content/. -
Add the typed entry-type map and switch per-schema aliases to derive from it:
import { type EntryTypesFromRegistry } from 'canopycms' export type EntryTypes = EntryTypesFromRegistry<typeof entrySchemaRegistry> // These names stay the same at every call site — they just source from EntryTypes now. export type PostContent = EntryTypes['post'] export type AuthorContent = EntryTypes['author'] export type HomeContent = EntryTypes['home']
-
Pass
EntryTypesas the second generic tobuildContentTreewherever you call it:await canopy.buildContentTree<NavFields, EntryTypes>({ extract: (data, meta) => { if (meta.kind === 'collection' && meta.indexEntry?.entryType === 'partner') { // meta.indexEntry.data is now typed as PartnerContent — no `unknown` casting. } return {} }, })
-
Run
pnpm typecheck. If a.collection.jsonstill references an old schema name, you'll get a clear error pointing at the file (Schema reference "postSchema" ... not found in registry. Available schemas: post, author, home). Fix and rerun.
No content files, frontmatter, or .canopy-meta/ cache files need migration. In dev mode, editing .collection.json automatically invalidates the schema cache; the next read picks up the new strings.
Behavior change for both paths: createEntrySchemaRegistry now runs the same field-shape checks that previously ran via validateCanopyConfig (select fields must have options, reference fields must have collections or entryTypes, no inline groups inside object/block fields, no field-name collisions after group flattening). Schemas that passed registry creation before but quietly held one of these mistakes will throw at registry creation now. The error messages cite the specific field; fix the schema and rerun.
Config is now strict: defineCanopyConfig rejects unknown top-level keys instead of silently ignoring them. A leftover inline schema: from the old config-based approach — or any typo'd/unsupported key — now throws Unrecognized key(s) in object. Remove any keys not listed in the Configuration Reference.
Path B — Keep your existing keyless shorthand. Your code keeps working exactly as today. You don't get the auto-derived EntryTypes map, so if you want typed access to meta.indexEntry.data, declare a parallel interface manually:
import { type TypeFromEntrySchema } from 'canopycms'
interface MyEntries {
partner: TypeFromEntrySchema<typeof partnerSchema>
doc: TypeFromEntrySchema<typeof docSchema>
}
await canopy.buildContentTree<NavFields, MyEntries>({ ... })This is the right choice if you have multiple entry types sharing one schema and prefer one registry entry per schema rather than per entry type, or if you're not ready to touch .collection.json files yet. The migration is always available later.
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 "post" in collection "posts" not found in registry.
Available schemas: author, home, doc
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
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' |
No | 'dev' |
Operating mode (see below) |
contentRoot |
string |
No | 'content' |
Root directory for content files relative to project root |
defaultBaseBranch |
string |
No | 'main' |
Git branch used as the fork point for CMS content branches (typically main) |
defaultActiveBranch |
string |
No | (see below) | Which workspace the dev server serves content from and which branch the editor opens by default. In dev mode, auto-detected from the current git branch. In prod mode, falls back to defaultBaseBranch |
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 |
dev |
DevConfig |
No | - | Dev-mode-only behavior. dev.contentSync: 'off' | 'warn' (default 'warn') controls how the dev server detects/reports working-tree edits vs. the served branch clone (see Local Development Sync). Ignored when mode !== 'dev'. |
validateEntry |
ValidateEntryHook |
No | - | Save-time validation hook, run server-side before the entry file is written. Return level: 'error' issues to reject the save, or 'warning' issues to surface alongside it. See Save-Time Validation. |
Note: Schemas are declared in TypeScript with defineEntrySchema, registered with createEntrySchemaRegistry, and referenced from .collection.json files alongside your content. See the Schema Registry and References section for details.
Schema validation keeps field shapes clean, but it can't know that a markdown body must, say, compile as MDX for your production build to succeed. The optional validateEntry hook lets the site refuse (or flag) saves that would break it:
// canopycms.config.ts
import { defineCanopyConfig, type EntryValidationIssue } from 'canopycms'
import { compile } from '@mdx-js/mdx'
export default defineCanopyConfig({
// ...
validateEntry: async ({ format, body }): Promise<EntryValidationIssue[]> => {
if ((format === 'mdx' || format === 'md') && body) {
try {
await compile(body)
} catch (err) {
return [
{
level: 'error', // 'error' rejects the save; 'warning' saves but notifies
fieldPath: 'body',
message: `MDX failed to compile: ${err instanceof Error ? err.message : String(err)}`,
},
]
}
}
return []
},
})The hook receives { entryPath, branch, entryType?, format, data, body } for every editor content save (entryType is set when the editor specifies one, e.g. in collections with multiple entry types). error issues reject the save with the message shown to the editor; warning issues let the save through and appear as a notification. The hook gates content writes only — renames and deletes do not invoke it. Pair it with the preview error channel (see Live Preview) so authors see compile failures while typing, not just at save time.
dev: Full-featured local development with branching and git operations. Uses a local bare remote at.canopy-dev/remote.gitand branch workspaces at.canopy-dev/content-branches/.defaultActiveBranchis auto-detected from the current git branch (e.g., if you are onfeat-bar, the dev server and editor default to that branch). The dev server silently follows branch switches — no restart needed. Add.canopy-dev/to.gitignore.prod: Production deployment with branch workspaces on persistent storage (e.g., AWS Lambda + EFS).defaultActiveBranchfalls back todefaultBaseBranch(usuallymain) but can be explicitly configured (e.g., to a staging branch). Permissions and groups are tracked in git on an orphan settings branch.
When working in dev mode, your content lives in two places: the working tree of your repo and the branch workspaces inside .canopy-dev/content-branches/ that the CMS editor reads from. If you edit files in the working tree directly (or pull from GitHub) while the dev server serves a branch clone, the two can drift — the classic "builds fine, but the dev editor shows blank/stale content" trap.
Automatic divergence detection. The dev.contentSync config option controls how the dev server detects and reports working-tree edits that have drifted from the served branch clone (dev mode only; ignored when mode !== 'dev'):
// canopycms.config.ts
export default defineCanopyConfig({
// ...
dev: {
contentSync: 'warn', // 'off' | 'warn' (default)
},
})| Value | Behavior |
|---|---|
'warn' |
Default. On startup and on content/** changes, logs a warning naming the files that diverge from the branch clone |
'off' |
No watcher, no warnings |
Why there is no
'auto'mode: auto-pushing the working tree into the branch clone could silently clobber uncommitted editor "Save" state, with no Canopy-level recovery path for the editor. Reconcile explicitly instead withcanopycms sync push, which is interactive and conflict-aware.
To keep the two in sync, use the canopycms sync command.
Push (working tree → branch workspace) -- copies your current working-tree content into a branch workspace and commits it, so the CMS editor sees your latest changes (e.g., after pulling from GitHub or editing files directly). By default, targets the workspace matching your current git branch (auto-creating it if needed):
npx canopycms sync pushPull (branch workspace → working tree) -- copies content from a CMS branch workspace back into your working tree so you can review, commit, and push the changes yourself:
npx canopycms sync pullBoth push and pull support --branch to target a specific workspace. If multiple branch workspaces exist and no --branch is given, the CLI will prompt you to choose one:
npx canopycms sync pull --branch update-homepageBoth directions (3-way merge) -- merges your working-tree changes with any editor changes using a 3-way git merge, then pulls the merged result back into your working tree:
npx canopycms sync bothThis is useful when both you and the editor have made changes to the same branch and you want to reconcile them in one step.
Abort -- if a merge fails due to conflicts, you can cancel it and restore the branch workspace to its pre-merge state:
npx canopycms sync abortAll project-bound CLI commands (
sync,migrate,generate-ai-content,worker run-once) resolve the project root by walking up from the current directory to the nearestcanopycms.config.ts, like git does — running them from a subdirectory works.
Adopting CanopyCMS on a site with existing content? canopycms migrate converts a plain content tree into CanopyCMS conventions — entry files become {type}.{slug}.{id}.{ext}, content-bearing directories get ID suffixes and a .collection.json, and the root gets one when entry files live directly in it:
npx canopycms migrate --entry-type doc --format md --schema docSchema --dry-run
npx canopycms migrate --entry-type doc --format md --schema docSchema--dry-runprints the full rename/create plan without touching anything; omitted flags are prompted for.- Only files of the chosen format are migrated. Assets, other formats, and directories without matching content are left untouched.
- Re-running is a no-op: already-conforming names are skipped.
- Entry order is left unset (alphabetical). Source-specific ordering conventions (e.g. Nextra
_meta.json) are out of scope — apply those with a follow-up script if needed.
After migrating, make sure the schema key you chose (e.g. docSchema) exists in your entry schema registry.
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
Declare your schemas once in schemas.ts and register them by entry-type name:
// app/schemas.ts
import { defineEntrySchema } from 'canopycms'
import { createEntrySchemaRegistry } from 'canopycms/server'
export const postSchema = defineEntrySchema([
/* fields */
])
export const homeSchema = defineEntrySchema([
/* fields */
])
export const guideSchema = defineEntrySchema([
/* fields */
])
export const tutorialSchema = defineEntrySchema([
/* fields */
])
export const endpointSchema = defineEntrySchema([
/* fields */
])
export const settingsSchema = defineEntrySchema([
/* fields */
])
export const entrySchemaRegistry = createEntrySchemaRegistry({
post: postSchema,
home: homeSchema,
guide: guideSchema,
tutorial: tutorialSchema,
endpoint: endpointSchema,
settings: settingsSchema,
})Then place a .collection.json next to each collection's content. The directory tree decides where each collection lives — there is no path field.
Repeatable entries — content/posts/.collection.json:
{
"name": "posts",
"label": "Blog Posts",
"entries": [{ "name": "post", "format": "json", "schema": "post" }]
}Singleton-like entry — content/pages/.collection.json:
{
"name": "pages",
"label": "Pages",
"entries": [
{
"name": "home",
"label": "Homepage",
"format": "json",
"schema": "home",
"maxItems": 1
}
]
}Multiple entry types in one collection — content/docs/.collection.json:
{
"name": "docs",
"label": "Documentation",
"entries": [
{ "name": "guide", "label": "Guide", "format": "mdx", "schema": "guide" },
{ "name": "tutorial", "label": "Tutorial", "format": "mdx", "schema": "tutorial" }
]
}Nested collections — add a .collection.json in a subdirectory of content/docs/, e.g. content/docs/api/.collection.json:
{
"name": "api",
"label": "API Reference",
"entries": [{ "name": "endpoint", "format": "mdx", "schema": "endpoint" }]
}Root-level entries (e.g., site-wide settings) — content/.collection.json:
{
"entries": [
{
"name": "settings",
"label": "Site Settings",
"format": "json",
"schema": "settings",
"maxItems": 1
}
]
}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[], entryTypes?: string[], displayField?: string, resolvedSchema?: Schema |
object |
Nested object | fields: FieldConfig[] |
block |
Block-based "flexible content" / page blocks | templates: BlockTemplate[] (define each with defineBlockTemplate, see Page Blocks) |
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
isTitle: true, // Optional: use this field as the display title in the editor sidebar
}Field groups let you visually organize related fields in the editor without forcing you to restructure your content files. Two helpers are available:
defineInlineFieldGroup — groups fields under a labeled, bordered section in the editor. The fields are stored flat in your content file alongside other top-level fields.
defineNestedFieldGroup — groups fields under a labeled section and stores them as a nested object in your content file (equivalent to type: 'object' with ergonomic sugar).
import { defineInlineFieldGroup, defineNestedFieldGroup, defineEntrySchema } from 'canopycms'
// Inline group: fields stored flat (metaTitle, metaDescription at top level)
const seoGroup = defineInlineFieldGroup({
name: 'seo',
label: 'SEO',
description: 'Search engine metadata', // optional
fields: [
{ name: 'metaTitle', type: 'string', label: 'Meta Title' },
{ name: 'metaDescription', type: 'string', label: 'Meta Description' },
],
})
// Nested group: fields stored under a key (seo.metaTitle, seo.metaDescription)
const seoGroupNested = defineNestedFieldGroup({
name: 'seo',
label: 'SEO',
fields: [
{ name: 'metaTitle', type: 'string', label: 'Meta Title' },
{ name: 'metaDescription', type: 'string', label: 'Meta Description' },
],
})
const docSchema = defineEntrySchema([
{ name: 'title', type: 'string', required: true },
seoGroup, // or seoGroupNested
{ name: 'body', type: 'markdown' },
])
// TypeFromEntrySchema with inline group → { title: string; metaTitle: string; metaDescription: string; body: string }
// TypeFromEntrySchema with nested group → { title: string; seo: { metaTitle: string; metaDescription: string }; body: string }Groups are reusable — define them once and include them in multiple schemas. Both helpers accept an optional description that appears as hint text in the editor.
A block field holds an ordered, repeatable list of heterogeneous section blocks discriminated by a template key — the "flexible content" / page-builder pattern. Each block in the list picks one of the field's templates, so a page entry becomes an array of typed sections that editors can add, remove, and reorder.
Use defineBlockTemplate() (from canopycms) to define a reusable section template once and embed it in multiple entry schemas' block fields, instead of duplicating the template inline in every schema:
import { defineBlockTemplate, defineEntrySchema } from 'canopycms'
const heroBlock = defineBlockTemplate({
name: 'hero',
label: 'Hero',
fields: [
{ name: 'heading', type: 'string' },
{ name: 'subheading', type: 'string', required: false },
],
})
const ctaBlock = defineBlockTemplate({
name: 'cta',
label: 'Call to Action',
fields: [
{ name: 'label', type: 'string' },
{ name: 'href', type: 'string' },
],
})
// Reuse the same templates across multiple page schemas:
const pageSchema = defineEntrySchema([
{ name: 'title', type: 'string', required: true },
{ name: 'sections', type: 'block', templates: [heroBlock, ctaBlock] },
])
type Page = TypeFromEntrySchema<typeof pageSchema>
// Page['sections'] narrows to a discriminated union:
// Array<
// | { template: 'hero'; value: { heading: string; subheading?: string } }
// | { template: 'cta'; value: { label: string; href: string } }
// >defineBlockTemplate() is an identity/type-inference helper (like defineEntrySchema and the field-group helpers): it returns the template unchanged but preserves the literal types so TypeFromEntrySchema derives the correct discriminated union. Switch on block.template to render each section (see Typed Block Discriminated Unions).
Example with reference field:
const schema = defineEntrySchema([
{ name: 'title', type: 'string', label: 'Title', required: true, isTitle: 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
resolvedSchema: authorSchema, // Optional: enables typed inference (see Type Inference section)
},
{
name: 'relatedPosts',
type: 'reference',
label: 'Related Posts',
collections: ['posts'],
displayField: 'title',
list: true, // Allow multiple references
},
{
name: 'partners',
type: 'reference',
label: 'Partners',
entryTypes: ['partner'], // Find entries by type across all collections
displayField: 'name',
list: true,
resolvedSchema: partnerSchema,
},
])Example with all field types:
const schema = defineEntrySchema([
{ name: 'title', type: 'string', label: 'Title', required: true, isTitle: 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.
Reference fields accept collections, entryTypes, or both to scope which entries can be referenced. At least one must be specified:
collections— Scope by collection path(s), including all subcollections within that treeentryTypes— Scope by entry type name(s), regardless of which collection the entries live in- Both — Combine for precise scoping (e.g., only
partnerentries within thedata-catalogtree)
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
},
{
name: 'partners',
type: 'reference',
label: 'Partners',
entryTypes: ['partner'], // Find all 'partner' entries across any collection
displayField: 'name',
list: true,
resolvedSchema: partnerSchema,
},
{
name: 'catalogPartner',
type: 'reference',
label: 'Catalog Partner',
collections: ['data-catalog'], // Search within data-catalog and all its subcollections
entryTypes: ['partner'], // But only entries of type 'partner'
displayField: 'name',
},
])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 and/or entry types
- 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
- Co-located data: Use
entryTypesto reference entries that live alongside their related content in subcollections, without needing a dedicated collection
When editing a reference field:
- Click the dropdown to see all available entries matching the configured collections and/or entry types
- 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 type inference covers all field types: string and markdown fields become string, number becomes number, boolean becomes boolean, object fields become nested objects, list: true wraps the value in an array, and required: false adds | undefined.
Block fields produce a proper discriminated union based on their templates. This means you can switch on block.template and TypeScript will narrow block.value to the correct shape for that template:
const pageSchema = defineEntrySchema([
{
name: 'blocks',
type: 'block',
templates: [
{
name: 'hero',
label: 'Hero Section',
fields: [
{ name: 'headline', type: 'string' },
{ name: 'body', type: 'markdown' },
],
},
{
name: 'cta',
label: 'Call to Action',
fields: [
{ name: 'title', type: 'string' },
{ name: 'ctaText', type: 'string' },
],
},
],
},
])
type Page = TypeFromEntrySchema<typeof pageSchema>
// Page['blocks'] is:
// Array<
// | { template: 'hero'; value: { headline: string; body: string } }
// | { template: 'cta'; value: { title: string; ctaText: string } }
// >
for (const block of page.blocks) {
switch (block.template) {
case 'hero':
// block.value is narrowed to { headline: string; body: string }
return <HeroSection headline={block.value.headline} body={block.value.body} />
case 'cta':
// block.value is narrowed to { title: string; ctaText: string }
return <CtaSection title={block.value.title} ctaText={block.value.ctaText} />
}
}By default, reference fields infer as string | null (the UUID). If you want the inferred type to reflect the resolved entry's shape instead, pass resolvedSchema pointing to the target schema:
const authorSchema = defineEntrySchema([
{ name: 'name', type: 'string', label: 'Name' },
{ name: 'bio', type: 'string', label: 'Bio' },
])
const postSchema = defineEntrySchema([
{ name: 'title', type: 'string', label: 'Title' },
{
name: 'author',
type: 'reference',
label: 'Author',
collections: ['authors'],
displayField: 'name',
resolvedSchema: authorSchema, // Infer the resolved type from this schema
},
])
type Post = TypeFromEntrySchema<typeof postSchema>
// Without resolvedSchema: Post['author'] would be string | null
// With resolvedSchema: Post['author'] is { name: string; bio: string } | nullThe resolvedSchema option is used only for type inference -- it does not affect how content is read, written, or validated at runtime, and is automatically stripped from API responses. It accepts any schema created with defineEntrySchema, so you can share the same schema objects between your entry type definitions and your reference fields.
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 resolutionreadByUrlPath(): Read content by URL path, resolving the collection/entry split automatically (see below)buildContentTree(): Build a typed content tree for navigation, sitemaps, etc. (see Content Tree Builder)listEntries(): Get a flat array of all entries forgenerateStaticParams, search indexes, sitemaps (see Listing Entries)user: Current authenticated user (with bootstrap admin groups applied)services: Underlying CanopyCMS services for advanced use cases
readByUrlPath() maps a URL path directly to a content entry, handling the collection/slug split and index entry resolution automatically. This is the simplest way to load content when your routes mirror your content structure:
// app/[...slug]/page.tsx
import { notFound } from 'next/navigation'
import { getCanopy } from '../lib/canopy'
export default async function Page({ params }) {
const canopy = await getCanopy()
const urlPath = '/' + (params.slug?.join('/') ?? '')
const result = await canopy.readByUrlPath<{ title: string; body: string }>(urlPath)
if (!result) return notFound()
return <Article title={result.data.title} body={result.data.body} />
}Resolution order:
/docs/getting-started-- triescontent/docs+ slug"getting-started"(direct entry match)- If that fails, tries
content/docs/getting-started+ slug"index"(index entry fallback) /docs/guides-- resolves to the index entry of theguidescollection (if one exists)/-- resolves to the root index entry at the content root (if one exists)
Returns null when no content matches the path. Throws on permission errors.
Index entries (entries with slug "index") represent the default content for a collection URL. All three content APIs -- readByUrlPath, listEntries, and buildContentTree -- treat index entries consistently:
readByUrlPath('/guides')resolves to the index entry in theguidescollectionreadByUrlPath('/')resolves to the index entry at the content rootlistEntries()returnsurlPath: '/guides'(not'/guides/index') for index entries, andurlPath: '/'for a root index entrybuildContentTree()generatespath: '/guides'(not'/guides/index') for index entries by default
This means entry.urlPath from listEntries() is round-trip safe: readByUrlPath(entry.urlPath) always resolves back to the same entry.
For static-export sites you need a generateStaticParams that enumerates every content URL directly from your CanopyCMS content, so you do not have to hand-roll the path-segment mapping. This is exposed as a bound helper on the result of createNextCanopyContext — wire it once in your lib/canopy.ts and call it from each page. Because it is bound to the build context internally, your page modules never import the admin getCanopyForBuild.
The scaffolded lib/canopy.ts already exports it as contentStaticParams:
// app/lib/canopy.ts
import { createNextCanopyContext, type GenerateContentStaticParamsOptions } from 'canopycms-next'
const canopyContextPromise = createNextCanopyContext({
config: config.server,
authPlugin,
entrySchemaRegistry,
})
export const contentStaticParams = async (options?: GenerateContentStaticParamsOptions) => {
const context = await canopyContextPromise
return context.generateContentStaticParams(options)
}Catch-all route (app/[...slug]/page.tsx) — emits { slug: segments[] } for each entry:
// app/[...slug]/page.tsx
import { contentStaticParams } from '../lib/canopy'
export const generateStaticParams = () => contentStaticParams()Collection-scoped single-segment route (app/posts/[slug]/page.tsx) — pass shape: 'single' (emits { slug }) and rootPath to scope to one collection:
// app/posts/[slug]/page.tsx
import { contentStaticParams } from '../../lib/canopy'
export const generateStaticParams = () =>
contentStaticParams({ rootPath: 'content/posts', shape: 'single' })Catch-all nested under a URL prefix (app/docs/[[...slug]]/page.tsx) — pass basePath so the emitted segments are relative to that prefix and match the route:
// app/docs/[[...slug]]/page.tsx
import { contentStaticParams } from '../../lib/canopy'
export const generateStaticParams = () =>
contentStaticParams({ rootPath: 'content/docs', basePath: '/docs' })Options:
| Option | Type | Default | Description |
|---|---|---|---|
shape |
'catch-all' | 'single' |
'catch-all' |
'catch-all' emits the URL segments array; 'single' emits the entry slug |
paramName |
string |
'slug' |
Route param name (matches your [...name] / [name] folder) |
rootPath |
string |
Content root | Scope to a subtree (e.g., 'content/posts'), useful with shape: 'single' |
basePath |
string |
- | For a catch-all nested under a URL prefix (e.g. app/docs/[[...slug]]): set to the route base ('/docs') so entries are scoped to that prefix and segments are made relative to it |
filter |
(entry) => boolean |
- | Exclude entries; e.g. drop the root index from a non-optional catch-all: (e) => e.segments.length > 0 |
A root index (
/) produces emptysegments— keep it only for an optional catch-all[[...slug]], otherwise exclude it withfilter.Advanced (framework-agnostic): if you need to call the enumeration with a build context you already hold, the free helper
collectStaticParams(buildCtx, options)fromcanopycms-nexttakes the build context directly. The boundcontentStaticParamsabove is preferred for ordinary page code.Sitemap and SEO-metadata static-export helpers are coming separately.
For ordinary page work you should not need a build-specific context. The recommended page surface is:
- Content — the phase-selecting
readByUrlPath/readhelpers (below), which read the working tree during static generation and the branch-aware, ACL-enforced runtime context at request time. - Paths — the bound
contentStaticParamshelper forgenerateStaticParams(see Static Export with generateStaticParams).
Both are exported from your scaffolded lib/canopy.ts, so your page modules never import an admin context.
// app/posts/[slug]/page.tsx
import { contentStaticParams, read } from '../../lib/canopy'
// Build-only: enumeration of paths, no admin context in the page module
export const generateStaticParams = () =>
contentStaticParams({ rootPath: 'content/posts', shape: 'single' })
export async function generateMetadata({ params }) {
// Phase-selecting read: working tree at build, ACL-enforced runtime at request time
const { data } = await read({ entryPath: 'content/posts', slug: params.slug })
return { title: data.title }
}
export default async function PostPage({ params }) {
const { data } = await read({ entryPath: 'content/posts', slug: params.slug })
return <PostView post={data} />
}getCanopyForBuild() is an advanced escape hatch that returns a context not tied to request headers. The scaffolded lib/canopy.ts exports it but no longer leads with it — reach for it only when the phase-selecting helpers above are not enough (e.g. a standalone build script, or scanning the whole content set with listEntries/buildContentTree outside a page):
// Standalone build script (not a page module)
import { getCanopyForBuild } from '../lib/canopy'
const canopy = await getCanopyForBuild()
const entries = await canopy.listEntries()When to use which:
| Function | Auth | Request scope needed | Use for |
|---|---|---|---|
read() / readByUrlPath() (from lib/canopy.ts) |
Phase-selecting (admin at build, user at request) | No (auto) | Page modules that render in both phases — recommended surface |
contentStaticParams() |
Build-only enumeration | No | generateStaticParams |
getCanopy() |
Current user | Yes | Server components, route handlers |
getCanopyForBuild() |
Full admin (bypasses all auth/permissions) | No | Advanced: build scripts, whole-collection scans outside a page |
Security note:
getCanopyForBuild()runs as a synthetic admin user with unrestricted read access, bypassing all branch and path ACLs. Only use it in build-time code paths that are not exposed to end users at request time. On a productionserverdeployment (mode: 'prod'anddeployedAs: 'server'), its operations throw if invoked at request time (i.e. outside the build phase) — a guard rail so the ACL-bypassing reader cannot be accidentally used to serve live requests. The guard intentionally does not fire in dev: Next legitimately invokesgenerateStaticParams/generateMetadatathrough the build context duringnext devwith the same not-build-phase signature as misuse, so a dev guard would false-positive on idiomatic code; in prod that ambiguity is gone (generateStaticParamsis build-only). UsegetCanopy()(or the phase-selecting helpers above) for request-time reads.
The build context also exposes a build-safe readByUrlPath() that returns null for non-entry paths (e.g. /favicon.ico, /robots.txt) instead of throwing, so a single [...slug] page can resolve real entries and cleanly notFound() everything else.
For a [...slug]/[slug] page that must work in both phases, createNextCanopyContext returns top-level readByUrlPath and read helpers that automatically pick the right context: the admin build context during static generation (reads the working tree) and the branch-aware, ACL-enforced runtime context at request time (branch-clone preview in dev). This is the recommended way to resolve a page by URL — page code never has to hand-pick the admin build context.
Export them from your lib/canopy.ts alongside getCanopy:
// app/lib/canopy.ts
const canopyContextPromise = createNextCanopyContext({
config: config.server,
authPlugin,
entrySchemaRegistry,
})
export const readByUrlPath = async <T = unknown>(urlPath: string) => {
const context = await canopyContextPromise
return context.readByUrlPath<T>(urlPath)
}// app/[...slug]/page.tsx
import { notFound } from 'next/navigation'
import { readByUrlPath } from '../lib/canopy'
export default async function Page({ params }) {
const urlPath = '/' + (params.slug?.join('/') ?? '')
const result = await readByUrlPath<{ title: string; body: string }>(urlPath)
if (!result) return notFound()
return <Article title={result.data.title} body={result.data.body} />
}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
})When rendering links from CMS-managed content, user-provided URLs may contain dangerous schemes like javascript: or data:. CanopyCMS exports a sanitizeHref utility that parses untrusted URLs and only allows http: and https: protocols, returning a safe fallback for anything else.
import { sanitizeHref } from 'canopycms'Basic usage:
// In your component that renders CMS content
<a href={sanitizeHref(entry.data.link)}>{entry.data.linkText}</a>With a custom fallback:
// Returns '/fallback-page' instead of '#' for invalid URLs
<a href={sanitizeHref(entry.data.link, '/fallback-page')}>Click here</a>Behavior:
| Input | Output |
|---|---|
"https://example.com/page" |
"https://example.com/page" |
"http://example.com" |
"http://example.com" |
"javascript:alert(1)" |
"#" (blocked scheme) |
"data:text/html,<h1>bad</h1>" |
"#" (blocked scheme) |
"not a url" |
"#" (invalid URL) |
"" |
"#" (invalid URL) |
Use sanitizeHref anywhere you render an href attribute with a value that comes from CMS content -- call-to-action links, navigation URLs, author website fields, etc. It constructs a fresh string from the parsed URL rather than passing the original input through, which also satisfies static analysis tools (e.g., CodeQL taint tracking).
When your code catches errors thrown by CanopyCMS reads (e.g., to render a notFound() vs. a 403), the same typed error helpers CanopyCMS uses internally are available to adopters from the canopycms/utils/error subpath:
import {
getErrorMessage,
isNodeError,
isNotFoundError,
isPermissionError,
isFileExistsError,
} from 'canopycms/utils/error'getErrorMessage(err)— safely extract a string message from anunknowncaught value (avoidsany)isNodeError(err)— type guard narrowing toNodeJS.ErrnoException(gives you.code,.path, etc.)isNotFoundError(err)/isPermissionError(err)/isFileExistsError(err)— classify common filesystem failures (ENOENT,EACCES/EPERM,EEXIST)
import { notFound } from 'next/navigation'
import { isNotFoundError, isPermissionError } from 'canopycms/utils/error'
try {
const { data } = await canopy.read({ entryPath: 'content/posts', slug })
return <PostView post={data} />
} catch (err) {
if (isNotFoundError(err)) return notFound()
if (isPermissionError(err)) return <Forbidden />
throw err
}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, lowercased by default (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 extract callback receives a second meta argument with structural context:
meta.kind--"collection"or"entry"meta.logicalPath-- the node's logical pathmeta.entryType,meta.format-- present whenkind === "entry"meta.indexEntry-- present whenkind === "collection"and the directory contains an entry withslug === "index". Carries that entry'sentryType,format, and rawdata. This represents the collection's identity under the directory-as-page pattern (e.g., a partner's metadata for/data-catalog/<partner>/, a section landing for/docs/<section>/).
Narrow on meta.indexEntry.entryType before reading type-specific fields:
const tree = await canopy.buildContentTree({
extract: (data, meta) => {
if (meta.kind === 'collection' && meta.indexEntry?.entryType === 'partner') {
return { isFictional: Boolean(meta.indexEntry.data.isFictional) }
}
return {}
},
})Note: meta.indexEntry is undefined for collections at the maxDepth cap (entries aren't loaded there).
buildContentTree<T, TEntryTypes> accepts an optional second generic — an adopter-supplied map from entry-type names to their data shapes. When provided, narrowing on meta.indexEntry.entryType types meta.indexEntry.data as the matching shape (a discriminated union), so you can drop as casts and unknown checks. The default for TEntryTypes is a loose Record<string, unknown>-style shape, so existing callers work unchanged. Reuse the schemas you already defined with defineEntrySchema via TypeFromEntrySchema — no redeclaration. The exported EntryTypeMap type alias documents the expected shape (Record<string, object>); adopters don't have to extend it, any matching interface works.
import { defineEntrySchema, type TypeFromEntrySchema, buildContentTree } from 'canopycms'
const partnerSchema = defineEntrySchema([
{ name: 'name', type: 'string', isTitle: true },
{ name: 'isFictional', type: 'boolean' },
])
const docSchema = defineEntrySchema([{ name: 'title', type: 'string' }])
interface MyEntries {
partner: TypeFromEntrySchema<typeof partnerSchema>
doc: TypeFromEntrySchema<typeof docSchema>
}
const canopy = await getCanopyForBuild()
const tree = await canopy.buildContentTree<NavFields, MyEntries>({
extract: (data, meta) => {
if (meta.kind === 'collection' && meta.indexEntry?.entryType === 'partner') {
// meta.indexEntry.data is typed as the partner shape — no casting needed
return { isFictional: Boolean(meta.indexEntry.data.isFictional) }
}
return { isFictional: false }
},
})The 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, meta: ContentTreeExtractMeta) => T |
- | Extract typed custom fields from raw entry/collection data. meta.indexEntry exposes a collection's directory-as-page index entry when present. |
filter |
(node: ContentTreeNode<T>) => boolean |
- | Return false to exclude a node and its descendants |
buildPath |
(logicalPath, kind) => string |
Strips content root, lowercases, collapses index entries | Custom URL path builder (default collapses index entries to parent path and lowercases) |
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'listEntries() returns a flat array of every content entry in your site. It is designed for search indexing, sitemaps, and any other case where you need to iterate over all content without the tree hierarchy. (For generateStaticParams, prefer the bound contentStaticParams helper — see Static Export with generateStaticParams — which enumerates routable paths without handing an admin context to your page module.)
listEntries() is available on both the request-scoped context (getCanopy()) and the advanced build context (getCanopyForBuild()); use the latter for build scripts or whole-collection scans that run outside a request:
// build script or non-request context
import { getCanopyForBuild } from '../lib/canopy'
const canopy = await getCanopyForBuild()
const entries = await canopy.listEntries()
// urlPath has index collapsing applied — preferred for URL generation
const slugs = entries.map((entry) => entry.urlPath.split('/').filter(Boolean))Each entry includes urlPath -- a URL-ready string with index entries collapsed to their parent path (e.g., '/guides' instead of '/guides/index', '/' for root index entries). This is round-trip safe with readByUrlPath(): calling readByUrlPath(entry.urlPath) resolves to the same entry. The raw pathSegments array is also available for consumers that need the unmodified filesystem structure.
| Field | Type | Description |
|---|---|---|
pathSegments |
string[] |
URL path segments (e.g., ['researchers', 'guides', 'glossary']) |
urlPath |
string |
URL-ready path with index entries collapsed (e.g., '/guides' instead of '/guides/index'; '/' for root index) |
slug |
string |
Entry slug within its collection |
entryPath |
string |
Full CMS logical path |
entryId |
string |
12-char Base58 content ID from the filename |
collectionId |
string? |
Collection content ID (if present) |
collectionPath |
string |
Logical path of the parent collection |
entryType |
string |
Entry type name |
format |
string |
Content format (json, md, or mdx) |
data |
T |
Entry data (frontmatter + body for md/mdx, JSON fields for json) |
For md/mdx entries, data.body contains the raw markdown content.
Use the extract callback to control what ends up in data. This is useful for dropping large fields (like body) from memory when you only need metadata:
interface PostMeta {
title: string
publishDate: string
}
const entries = await canopy.listEntries<PostMeta>({
extract: (raw) => ({
title: (raw.title as string) ?? '',
publishDate: (raw.publishDate as string) ?? '',
}),
})
// entries[0].data.title is typed as stringconst entries = await canopy.listEntries<PostMeta>({
extract: (raw) => ({
title: (raw.title as string) ?? '',
publishDate: (raw.publishDate as string) ?? '',
}),
filter: (entry) => entry.entryType === 'post',
sort: (a, b) => b.data.publishDate.localeCompare(a.data.publishDate),
})Use rootPath to only load entries under a specific collection path, skipping everything else:
const guideEntries = await canopy.listEntries({
rootPath: 'content/docs/guides',
})| Option | Type | Default | Description |
|---|---|---|---|
extract |
(raw, meta) => T |
- | Transform raw data; controls what data contains |
filter |
(entry: ListEntriesItem<T>) => boolean |
- | Return false to exclude an entry |
rootPath |
string |
Content root | Scope to a subtree (e.g., "content/docs") |
sort |
(a: ListEntriesItem<T>, b: ListEntriesItem<T>) => number |
- | Custom sort comparator |
// Types (for use in your components)
import type { ListEntriesItem, ListEntriesOptions } from 'canopycms'
// Via CanopyContext (recommended)
const canopy = await getCanopy()
const entries = await canopy.listEntries(options)
// Raw function (advanced -- requires branchRoot, flatSchema, contentRootName)
import { listEntries } 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. In page modules, drive generateStaticParams with the bound contentStaticParams helper and resolve content with the phase-selecting read/readByUrlPath (both from lib/canopy.ts) so you avoid request-scope errors without importing an admin context.
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.
Security model. Preview pages only accept messages when they are actually framed, and only from their direct parent window with a matching origin — same-origin by default. A standalone page (including one opened via window.open from a hostile site) never accepts draft data. If your editor is deployed on a different origin than the site, pass it explicitly:
const { data, fieldProps } = useCanopyPreview<DocContent>({
initialData,
editorOrigin: 'https://editor.example.com', // only needed for cross-origin editor deployments
})We also recommend serving your site with Cross-Origin-Opener-Policy: same-origin where your hosting allows — it severs window.opener handles entirely (the bridge is safe without it, but defense in depth is cheap).
Reporting draft errors. If your page compiles the draft body (e.g. MDX) and keeps the last good render on failure, the author sees a stale-but-fine preview while the draft is broken. Use reportError to tell the editor, which surfaces an alert next to the preview:
const { data, reportError } = useCanopyPreview<DocContent>({ initialData })
useEffect(() => {
compileMdx(data.body)
.then(() => reportError(null)) // clears a previously reported error
.catch((err) => reportError(`MDX failed to compile: ${err.message}`, 'body'))
}, [data.body, reportError])Pair this with the validateEntry hook to also reject such saves server-side.
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 (keyed by entry type, then field name)
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')}`,
},
},
// Per-component MDX transforms (keyed by PascalCase component name)
// Converts JSX components to clean markdown for AI output.
// Return undefined to keep the original JSX unchanged.
componentTransforms: {
Callout: (props, children) => `> **${props.type ?? 'Note'}:** ${children}`,
Spacer: () => '',
ChecklistItem: (props, children) =>
`- [ ] ${props.label ? `**${props.label}:** ` : ''}${children}`,
MatrixRow: (props) => `- **${props.label}** (${props.category}): columns ${props.matches}`,
},
// Per-entry-type body transforms for general markdown cleanup.
// Applied after componentTransforms; receives the full body string.
bodyTransforms: {
guideline: (body) => body.replace(/\s*\|\|[^\n]+/g, ''),
},
})Transform processing pipeline: For MD/MDX entries, transforms are applied in this order:
stripMdxImports-- import statements are removed automaticallycomponentTransforms-- JSX components are matched by PascalCase name and replaced with the transform output (or kept as-is if the transform returnsundefined)bodyTransforms-- the full body string is passed through the entry-type-specific transform for final cleanup
componentTransforms are keyed by component name and apply globally across all entry types (since MDX components are project-wide). bodyTransforms are keyed by entry type name and are useful for stripping entry-type-specific syntax that does not belong in AI output.
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 |
Auto-generated by init; wraps config with withCanopy() (supports staticBuild for dual-build sites) |
| 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 |
Auto-generated by init; passthrough for dev auth, replace contents with Clerk middleware for production |
Optional touchpoints:
- Server components: Use
await getCanopy()to read draft content with automatic auth. For[...slug]/[slug]pages that render in both build and request phases, prefer the phase-selectingreadByUrlPath/readfromlib/canopy.ts(see Phase-Selecting readByUrlPath / read).getCanopyForBuild()remains as an advanced escape hatch for build scripts and whole-collection scans - Static export: Use the bound
contentStaticParamshelper fromlib/canopy.tsto drivegenerateStaticParams(supportsshape,rootPath, andbasePathfor nested catch-all routes); see Static Export with generateStaticParams - 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=/mnt/efs/workspace/.cache # Override auth cache location (prod mode 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)