canopycms
v0.0.15
Published
CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.
Downloads
1,502
Readme
CanopyCMS
CanopyCMS is a schema-driven, branch-aware CMS for websites that store their content in GitHub repositories.
What it does for you
- Lets you keep your site code and content in one repo.
- Lets your users edit your content without ever touch Git directly.
- Keeps your users changes apart from each other on separate branches.
- Enforces schema constraints on your content.
- Limits which users can see/edit which branch and which content.
- See edits immediately in a live preview of your site.
- Use block-based page building, nested objects, Mermaid, MDX, and more.
- Upload and manage assets in various stores (S3, LFS, etc)
- Bring-your-own-auth (with pre-built adapter for Clerk)
- Lets you deploy the editor within your public facing site or keep it in a separate site.
- Minimal deployment requirements: a filesystem that common to your web request handlers.
- Form pages are autogenerated from schemas.
Branch roots
- Workspaces resolve per mode:
produses$CANOPYCMS_WORKSPACE_ROOT/content-branches(default:/mnt/efs/workspace/content-branches),devuses.canopy-dev/content-branches/<branch>. - For
prodmode, you must setdefaultRemoteUrl. Fordev,defaultRemoteUrlis optional - if omitted, a local remote is auto-created at.canopy-dev/remote.git. - Optionally configure
defaultRemoteName(default:origin) anddefaultBaseBranch(default:main). - Git author identity is required for
prodmode: setgitBotAuthorNameandgitBotAuthorEmailso bot commits can be created reliably. - Branch names are sanitized and traversal is blocked before creating directories.
- Metadata lives at
<workspace>/.canopy-meta/branch.json; the registry lives at<branchesRoot>/branches.jsonand records the workspaceRoot for each branch. BranchWorkspaceManager+loadBranchStatekeep metadata and registry entries in sync so APIs read/write against the correct workspace root.
Content formats
CanopyCMS supports content as
- JSON
- Markdown (
.md) and MDX (.mdx) with frontmatter - Blocks and nested objects via schema definitions
How it works (behind the scenes)
When a user makes an edit in CanopyCMS, they do so on a branch they choose (or are defaulted into). That branch represents an underlying git branch that they don't see. Behind the scenes, CanopyCMS manages a set of git clones, each tuned to a different branch supporting the branches your users see in the editing interface. When a user saves a change, that change is written to disk. A user can change multiple files on a branch, e.g. to work on changes across files that work together. When they click a button to publish their branch, the changes are committed in git, and a pull request is made. Reviewers can comment on the submission within the editor and users can make changes and resubmit. Reviewers finally accept the change on GitHub by merging the pull request. CanopyCMS then marks the change as complete and archives the branch. Sync jobs refresh clones when upstream changes happen and surface conflicts without dropping a branch's changes. Authorization information for users and groups is stored on disk and managed by CanopyCMS.
How to integrate (Next.js)
- Define your schema/config (
canopycms.config.ts)
import { defineCanopyConfig } from 'canopycms'
export default defineCanopyConfig({
mode: 'dev', // or 'prod'
gitBotAuthorName: 'Canopy Bot',
gitBotAuthorEmail: '[email protected]',
editor: {
title: 'CanopyCMS Editor', // optional UI defaults
subtitle: 'Edit content',
theme: {
colors: { brand: '#4f46e5' },
},
// previewBase: { 'content/posts': '/blog' }, // optional overrides
},
// For prod mode, defaultRemoteUrl is required.
// For dev, it's optional - if omitted, uses auto-initialized local remote at .canopy-dev/remote.git
// defaultRemoteUrl: 'https://github.com/your/repo.git',
defaultBranchAccess: 'allow',
// Optional: contentRoot defaults to "content"
// contentRoot: 'content',
schema: {
collections: [
{
name: 'posts',
path: 'posts', // resolves to content/posts by default
entries: [
{
name: 'post',
format: 'mdx',
default: true,
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'body', type: 'mdx', required: true },
],
},
],
collections: [
{
name: 'highlights',
path: 'featured',
entries: [
{
name: 'highlight',
format: 'mdx',
fields: [
{ name: 'title', type: 'string' },
{ name: 'body', type: 'mdx' },
],
},
],
},
],
},
],
entries: [
{
name: 'home',
format: 'json',
maxItems: 1, // acts as a singleton - only one instance allowed
fields: [
{
name: 'hero',
type: 'object',
fields: [{ name: 'headline', type: 'string' }],
},
],
},
],
},
})The schema object has two top-level keys: collections (nested collections with their own entry types) and entries (entry types at the root level). Collections can contain other collections via collections and define their allowed content via entries. Use maxItems: 1 on an entry type to restrict it to a single instance (like a singleton). contentRoot (default content) is prefixed when resolving filesystem paths and ids, so a path of posts becomes content/posts. Use the collection’s resolved path (id) when calling APIs or building editor URLs.
TODO show how schemas can be defined across multiple files. Show all the configuration options for schemas.
- Add API routes with the Next adapter
// app/api/canopycms/[...canopycms]/route.ts
import config from '../../../canopycms.config' // adjust path as needed
import { createCanopyHandler } from 'canopycms/next'
import { createClerkAuthPlugin } from 'canopycms-auth-clerk'
const handler = createCanopyHandler({
config,
authPlugin: createClerkAuthPlugin({
secretKey: process.env.CLERK_SECRET_KEY,
useOrganizationsAsGroups: true, // Map Clerk organizations to CMS groups
}),
})
export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handlerThe authPlugin is required and handles authentication for all API requests. See canopycms-auth-clerk for Clerk integration or create your own plugin implementing the AuthPlugin interface.
The [collection] segment should receive the collection path (the id). If your ids include /, encode them (encodeURIComponent) when building URLs to keep them as a single path segment.
Host styling is framework-agnostic: your public app can use Tailwind (the included example does) or anything else; Mantine is only required inside the CanopyCMS editor UI.
- Load content in your pages with the Next helper
// app/page.tsx (server component)
import { createContentReader } from 'canopycms'
import config from '../canopycms.config'
const reader = createContentReader({ config })
export default async function Page({ searchParams }: { searchParams?: { branch?: string } }) {
const { data } = await reader.read<{
/* your data shape */
}>({
entryPath: 'content/home',
branch: searchParams?.branch,
})
// render using data; preview hooks can infer the entry id from the current URL
}read returns { data, path } and throws if the content is missing. In preview pages, useCanopyPreview can infer the entry id from window.location so you can usually ignore path. Pass a branch when you want branch-specific data; otherwise it defaults to your configured base branch. The helper enforces the same branch/path access rules as the API handlers.
- Wire auth
Provide an
authPluginincreateCanopyHandler(e.g., Clerk) so branch/path permissions can be enforced.
Permission Model:
- Reserved Groups:
Admins(full access to all operations),Reviewers(can review branches, request changes, approve PRs) - Bootstrap Admins: Set
CANOPY_BOOTSTRAP_ADMIN_IDS=user_id1,user_id2to grant admin access to specific users before the group system is configured - Path-based permissions: Define which groups can access which content paths
- Branch permissions: Branch creators can edit their branches; Admins/Reviewers can see all branches
- Embed the editor in an editor-only build/app
import { CanopyEditorPage } from 'canopycms/client'
import config from '../canopycms.config'
export default CanopyEditorPage(config)The editor loads entries on the client from /api/canopycms/[branch]/entries, so server prefetch is optional. Use previewBaseByCollection to control preview URLs per collection.
- Theme it
Wrap editor surfaces with
CanopyCMSProviderto load Mantine styles and customizebrand/primary/neutral/accentcolors and color scheme. PassthemeOptionsintoEditorif desired.
TODO show an example
- Split builds
Keep your public build free of editor bundles by importing only from
canopycms(server helpers + data loaders). Host the editor in a separate app or build target that imports fromcanopycms/clientand mounts the API routes above.
TODO show real examples of what to do
Preview branch awareness
- When building preview URLs, include the current branch as a query param (e.g.,
/?branch=feature-fooor/posts/hello?branch=feature-foo) so SSR preview pages read from the same branch workspace the editor is editing. TheEditorcomponent appends the branch param automatically topreviewBaseByCollection; your page loaders should readsearchParams.branchand pass it intocreateContentReader. - For public static builds, omit/ignore the branch param; this pattern is only for the editor/preview environment.
- Likewise, include
branchin your editor route (e.g.,/edit?branch=feature-foo) and have your editor page pass it to<Editor>so reloads/links preserve the selected branch. TheEditorwill also reflect branch switches back into the query string.
Live preview with useCanopyPreview
The useCanopyPreview hook provides live updates to your preview components as content is edited in the CMS. It automatically receives draft changes from the editor iframe and returns updated data in real-time.
Basic usage:
'use client'
import { useCanopyPreview } from 'canopycms/client'
import type { PostContent } from './schemas'
export function PostView({ data }: { data: PostContent }) {
const {
data: liveData,
isLoading,
highlightEnabled,
fieldProps,
} = useCanopyPreview<PostContent>({
initialData: data,
})
return (
<article>
<h1 {...fieldProps('title')}>{liveData.title}</h1>
<p {...fieldProps('body')}>{liveData.body}</p>
</article>
)
}Return values:
data: The current content data (initial data on first render, then live updates from editor)isLoading: Object mirroring your data structure with boolean loading states for reference fieldshighlightEnabled: Boolean indicating if field highlighting is active in the editorfieldProps: Helper function to adddata-canopy-pathattributes for editor integration
Reference fields and loading states:
When using reference fields (foreign key relationships to other content), the editor resolves these references asynchronously. Use the isLoading object to show loading states for reference fields:
'use client'
import { useCanopyPreview } from 'canopycms/client'
export function PostView({ data }: { data: PostContent }) {
const { data: liveData, isLoading } = useCanopyPreview<PostContent>({
initialData: data,
})
return (
<article>
<h1>{liveData.title}</h1>
<AuthorCard author={liveData.author} isLoading={isLoading.author} />
</article>
)
}
// Component receives clean types - no framework coupling
interface AuthorCardProps {
author: AuthorContent | null
isLoading?: boolean // Optional - only needed if you want loading UI
}
function AuthorCard({ author, isLoading }: AuthorCardProps) {
if (isLoading) {
return <p>Loading author...</p>
}
if (!author) {
return null // Render nothing when no author
}
return <p>By {author.name}</p>
}Loading state structure:
The isLoading object mirrors your data structure:
- Single reference field:
isLoading.authoris aboolean - Array of references:
isLoading.relatedPostsis an array ofboolean[]values - Non-reference fields: Always
false(no loading state needed)
Benefits:
- ✅ Zero framework types in your components - just your content types + optional
isLoading: boolean - ✅ Works for nested reference fields at any depth
- ✅ Optional - only check loading state if you want to show loading UI
- ✅ Familiar pattern - mirrors React Query's
{ data, isLoading }API
Modes (pick per environment)
dev(default): Full-featured local development with branching and git ops. Uses.canopy-dev/content-branches/for per-branch clones.- Auto-initialization: If no
defaultRemoteUrlis configured, CanopyCMS automatically creates a local remote at.canopy-dev/remote.gitand seeds it with your currentbaseBranch(e.g.,main). This allows fully local testing of branching and submission workflows without requiring an external GitHub remote. - Manual remote: You can still provide an explicit
defaultRemoteUrlto use a real remote or custom local path. - Use
npx canopycms syncto push/pull content between your working tree and the CMS.
- Auto-initialization: If no
prod: EFS-backed roots under$CANOPYCMS_WORKSPACE_ROOT(default:/mnt/efs/workspace). RequiresdefaultRemoteUrl.
Branch metadata lives in .canopy-meta/branch.json; registry in branches.json at the branches root. Content APIs resolve the workspace root from branch state + mode instead of relying on process.cwd().
Dev Mode Setup
Basic setup (auto-initialization):
// canopycms.config.ts
export default defineCanopyConfig({
mode: 'dev',
gitBotAuthorName: 'Canopy Bot',
gitBotAuthorEmail: '[email protected]',
// No defaultRemoteUrl needed - auto-creates .canopy-dev/remote.git
})How it works:
- When you create your first branch, CanopyCMS automatically creates a bare git repository at
.canopy-dev/remote.git - Your current
baseBranch(default:main) is pushed to this local remote - Branch workspaces are cloned from this local remote into
.canopy-dev/content-branches/<branch-name>/ - All git operations (push, fetch, etc.) work against the local remote
Requirements:
- Your project must be a git repository with at least one commit
- The
baseBranch(e.g.,main) must exist locally
Resetting dev state:
If you change the defaultBaseBranch in your config or want to start fresh:
rm -rf .canopy-dev/
npm run dev # Restart the serverThis will reinitialize the local remote with the new base branch.
Using in a monorepo:
If your CanopyCMS config is in a subdirectory of a larger monorepo, set sourceRoot to tell CanopyCMS which directory to use as the source for the local remote:
// packages/my-app/canopycms.config.ts
export default defineCanopyConfig({
mode: 'dev',
sourceRoot: 'packages/my-app', // Path relative to git repository root
gitBotAuthorName: 'Canopy Bot',
gitBotAuthorEmail: '[email protected]',
schema: [...]
})This ensures that:
- Only the
packages/my-appdirectory is pushed to the local remote (not the entire monorepo) - Branch clones contain only your app's directory structure
- Content paths resolve correctly (e.g.,
content/homeworks as expected)
How it works:
- When
sourceRootis set, CanopyCMS resolves it relative to the git repository root (whereprocess.cwd()returns) - The local remote is created at
<sourceRoot>/.canopy-dev/remote.git - Only the
sourceRootdirectory is pushed to the remote using git subtree (using the baseBranch) - Branch workspaces are cloned from this remote and contain only the source directory
When to use sourceRoot:
- Your config is in a monorepo subdirectory (e.g.,
packages/my-app/) - You're developing/testing CanopyCMS examples within the CanopyCMS repo itself
- Not needed if your config is at the root of a standalone repository (omit
sourceRoot- defaults to git root)
Using a real remote: You can still provide an explicit remote URL if you want to test against a real repository:
export default defineCanopyConfig({
mode: 'dev',
defaultRemoteUrl: 'https://github.com/your/repo.git',
// ... or use a local bare repo at a custom path
})Deployment (placeholder)
- Public build: ships only your site/pages; reads main/default branch content.
- Editor build: ships the editor UI + CanopyCMS API routes; handles branch create/switch/save/submit and asset uploads.
- Modes map to environments: dev for local development, prod on EFS. More deployment guidance will be documented as the branch-rooted APIs and submission flow land.
Assets
- Local filesystem adapter (
LocalAssetStore) is available now. S3 and LFS adapters are planned; the API handlers accept a provided adapter so you can swap storage without changing the editor.
