npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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: prod uses $CANOPYCMS_WORKSPACE_ROOT/content-branches (default: /mnt/efs/workspace/content-branches), dev uses .canopy-dev/content-branches/<branch>.
  • For prod mode, you must set defaultRemoteUrl. For dev, defaultRemoteUrl is optional - if omitted, a local remote is auto-created at .canopy-dev/remote.git.
  • Optionally configure defaultRemoteName (default: origin) and defaultBaseBranch (default: main).
  • Git author identity is required for prod mode: set gitBotAuthorName and gitBotAuthorEmail so 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.json and records the workspaceRoot for each branch.
  • BranchWorkspaceManager + loadBranchState keep 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)

  1. 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.

  1. 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 = handler

The 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.

  1. 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.

  1. Wire auth Provide an authPlugin in createCanopyHandler (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_id2 to 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
  1. 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.

  1. Theme it Wrap editor surfaces with CanopyCMSProvider to load Mantine styles and customize brand/primary/neutral/accent colors and color scheme. Pass themeOptions into Editor if desired.

TODO show an example

  1. 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 from canopycms/client and 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-foo or /posts/hello?branch=feature-foo) so SSR preview pages read from the same branch workspace the editor is editing. The Editor component appends the branch param automatically to previewBaseByCollection; your page loaders should read searchParams.branch and pass it into createContentReader.
  • For public static builds, omit/ignore the branch param; this pattern is only for the editor/preview environment.
  • Likewise, include branch in 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. The Editor will 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 fields
  • highlightEnabled: Boolean indicating if field highlighting is active in the editor
  • fieldProps: Helper function to add data-canopy-path attributes 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.author is a boolean
  • Array of references: isLoading.relatedPosts is an array of boolean[] 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 defaultRemoteUrl is configured, CanopyCMS automatically creates a local remote at .canopy-dev/remote.git and seeds it with your current baseBranch (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 defaultRemoteUrl to use a real remote or custom local path.
    • Use npx canopycms sync to push/pull content between your working tree and the CMS.
  • prod: EFS-backed roots under $CANOPYCMS_WORKSPACE_ROOT (default: /mnt/efs/workspace). Requires defaultRemoteUrl.

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:

  1. When you create your first branch, CanopyCMS automatically creates a bare git repository at .canopy-dev/remote.git
  2. Your current baseBranch (default: main) is pushed to this local remote
  3. Branch workspaces are cloned from this local remote into .canopy-dev/content-branches/<branch-name>/
  4. 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 server

This 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-app directory 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/home works as expected)

How it works:

  1. When sourceRoot is set, CanopyCMS resolves it relative to the git repository root (where process.cwd() returns)
  2. The local remote is created at <sourceRoot>/.canopy-dev/remote.git
  3. Only the sourceRoot directory is pushed to the remote using git subtree (using the baseBranch)
  4. 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.