@celar/git-cms
v1.5.2
Published
Git-based CMS for Next.js - Store content as markdown in GitHub
Maintainers
Readme
@celar/git-cms
A Git-based CMS for Next.js. Content lives as markdown files in your GitHub repo — no database, no separate deployment.
What this package provides
- Admin UI at
/adminfor creating and editing content - GitHub OAuth authentication (via NextAuth v5)
- Email/password login as an alternative auth path
- Role-based access control (
admin,editor) - User management UI built into the admin (admin-only)
- Extensible adapter system for user storage — Vercel Edge Config ships out of the box
- REST API for reading/writing markdown files to GitHub
- Schema system to define your content structure
- Draft / publish workflow with per-page status
- Release manager — batch-publish multiple draft pages in one git commit (admin-only)
- Utilities to read and render content in your frontend
You wire it into your Next.js app by creating a few files and defining your content schemas.
In your Next.js project
1. Install
npm install @celar/git-cmsIf you plan to use the Vercel Edge Config adapter for user storage, also install:
npm install @vercel/edge-config2. Files to create
your-app/
├── app/
│ └── admin/
│ ├── [[...cms]]/
│ │ └── page.tsx # renders the admin UI
│ └── api/
│ └── [...cms]/
│ └── route.ts # all CMS routes (auth + content + users)
├── auth.ts # createAuth setup — shared across files
├── cms.config.ts # your content schemas
└── .env.localNote: The two separate auth and CMS route files from v1 are now consolidated into a single
[...cms]catch-all route. The dispatcher handles theauth/*,cms/*, andusers/*segments internally.
3. Auth setup
Create auth.ts at your project root. This is where you configure the UserStore adapter and create the NextAuth helpers. Import from this file wherever you need auth, handlers, signIn, or signOut.
With Vercel Edge Config (recommended):
auth.ts
import { createAuth } from '@celar/git-cms/auth'
import { VercelEdgeConfigAdapter } from '@celar/git-cms/adapters'
export const userStore = new VercelEdgeConfigAdapter({
connectionString: process.env.EDGE_CONFIG!,
token: process.env.VERCEL_API_TOKEN!,
projectId: process.env.VERCEL_PROJECT_ID!,
edgeConfigId: process.env.VERCEL_EDGE_CONFIG_ID!,
})
export const { handlers, auth, signIn, signOut } = createAuth(userStore)Without a user store (GitHub-only, no email login):
If you only need the GIT_CMS_ADMIN bootstrap admin and no user management, you can omit the adapter:
import { createAuth } from '@celar/git-cms/auth'
export const { handlers, auth, signIn, signOut } = createAuth()4. Admin page
app/admin/[[...cms]]/page.tsx
import AdminPage from '@celar/git-cms/core'
import { auth } from '../../../auth'
import { blockSchemas, pageSchemas } from '../../../cms.config'
export default function Page() {
return AdminPage({ blockSchemas, pageSchemas, auth })
}5. CMS API route
A single catch-all route handles GitHub OAuth, content operations, and user management.
app/admin/api/[...cms]/route.ts
import { createDispatcher } from '@celar/git-cms/core'
import { handlers, auth, userStore } from '../../../../auth'
export const { GET, POST, PATCH, DELETE } = createDispatcher({
handlers,
auth,
userStore, // omit if not using user management
})6. Define your schemas
cms.config.ts
import type { BlockSchema, PageSchema } from '@celar/git-cms'
export const blockSchemas: BlockSchema[] = [
{
type: 'hero',
label: 'Hero',
fields: [
{ name: 'heading', label: 'Heading', fieldType: 'text', required: true },
{ name: 'body', label: 'Body', fieldType: 'richtext' },
{ name: 'image', label: 'Image', fieldType: 'image' },
],
},
]
export const pageSchemas: PageSchema[] = [
{
type: 'page',
label: 'Page',
contentPath: 'content/pages', // folder in your GitHub repo
allowedBlocks: 'any',
},
]7. Environment variables
.env.local
# GitHub repo to store content in
GITHUB_OWNER=your-username
GITHUB_REPO=your-repo
# GitHub OAuth app
AUTH_GITHUB_ID=your-oauth-app-id
AUTH_GITHUB_SECRET=your-oauth-app-secret
AUTH_SECRET=random-secret-string # openssl rand -base64 32
# Bootstrap admin — GitHub user ID (numeric) of the first admin
# Find your ID at: https://api.github.com/users/<your-username>
GIT_CMS_ADMIN=1234567
# GitHub token for email/password users (required if you have any email users)
# These users have no GitHub OAuth session, so the CMS uses this token to
# read and write content on their behalf. A fine-grained PAT scoped to this
# repo with Contents: Read and Write is sufficient.
GIT_CMS_GITHUB_TOKEN=github_pat_...
# Vercel Edge Config adapter (only needed if using VercelEdgeConfigAdapter)
EDGE_CONFIG=ecfg_... # connection string from Vercel dashboard
VERCEL_API_TOKEN=... # token with Edge Config write permission
VERCEL_PROJECT_ID=prj_...
VERCEL_EDGE_CONFIG_ID=ecfg_...
# Optional: enable live preview
GIT_CMS_PREVIEW_KEY=random-secret-string
GIT_CMS_PUBLIC_URL=http://localhost:3000Here's exactly where to find each one in the Vercel dashboard:
EDGE_CONFIG — connection string
- Go to your Vercel dashboard → Storage tab (top nav)
- Click Create → Edge Config → give it a name → Create
- Once created, click the store → Tokens tab
- Copy the value under Connection String — it starts with https://edge-config.vercel.com/ecfg_...
VERCEL_EDGE_CONFIG_ID
Same page as above. The Edge Config ID is visible in the URL of the store page: vercel.com//stores/edge-config/ecfg_xxxxxxxxxxxx
Or copy it from the connection string — it's the ecfg_... part at the end.
VERCEL_PROJECT_ID
- Go to your project in the Vercel dashboard
- Settings → General
- Scroll down to Project ID — copy the prj_... value
VERCEL_API_TOKEN
- Click your account avatar (top right) → Account Settings
- Go to Tokens in the left sidebar
- Click Create Token
- Give it a name (e.g. git-cms-edge-config), set scope to your team, set an expiry
- Copy the token — you only see it once
▎ The token needs permission to write to Edge Config. A full-scope account token works, but if you want to limit ▎ it: Vercel doesn't offer per-resource token scopes on the free plan, so a team-scoped token is the minimum.
Connecting the Edge Config store to your project
One extra step people miss: the store must be linked to your project or the connection string won't work at runtime.
- Go to your project → Storage tab
- Click Connect Store → select your Edge Config store → Connect
After linking, Vercel automatically injects EDGE_CONFIG into your project's environment variables. You can verify this under Settings → Environment Variables.
User management
How access control works
When a user tries to sign in:
- GitHub path — after OAuth, the system checks if the GitHub user ID matches
GIT_CMS_ADMIN. If yes, admin access is granted immediately. Otherwise, the user must be registered in the UserStore with a role. - Email path — the email and password are checked against the UserStore. No account means no access.
Users not found in either check see an access denied screen.
The bootstrap admin
Set GIT_CMS_ADMIN to your GitHub user ID (the numeric ID, not your username — usernames can change). This grants you permanent admin access regardless of the UserStore and lets you add other users through the CMS UI.
GIT_CMS_ADMIN=1234567Find your GitHub user ID:
curl https://api.github.com/users/<your-username>
# look for the "id" fieldOnce you have added yourself (or another admin) to the UserStore through the UI, you can remove GIT_CMS_ADMIN from your environment — but keeping it as a fallback is fine.
Adding users
Log in as admin, navigate to Users in the CMS header, and click Add user. Two types are supported:
GitHub user — enter their GitHub username. The CMS resolves it to a stable GitHub user ID server-side and stores that (so renames don't break access). They log in via the usual "Sign in with GitHub" button.
Email user — enter a display name, email address, and password. The password is bcrypt-hashed server-side before being stored. They log in with email and password on the sign-in page.
Roles
| Role | Access |
|------|--------|
| admin | Full content access + user management (add, remove, change roles) |
| editor | Full content access, no user management |
Roles can be changed at any time from the Users view. Changes take effect on the user's next login.
Custom adapters
Any object that implements the UserStore interface works as an adapter:
import type { UserStore, CmsUser, CmsUserPublic, NewCmsUser, UserUpdate } from '@celar/git-cms'
class MyCustomAdapter implements UserStore {
async getUserByGithubId(githubId: string): Promise<CmsUser | null> { ... }
async getUserByEmail(email: string): Promise<CmsUser | null> { ... }
async getAllUsers(): Promise<CmsUserPublic[]> { ... }
async addUser(user: NewCmsUser): Promise<CmsUserPublic> { ... }
async updateUser(id: string, update: UserUpdate): Promise<CmsUserPublic> { ... }
async removeUser(id: string): Promise<void> { ... }
}Security contract for custom adapters:
getUserByEmailis the only method that may returnpasswordHash— it is needed internally by the Credentials provider for bcrypt comparison.- All other methods (
getAllUsers,addUser,updateUser) must never includepasswordHashin their return values. Use theCmsUserPublictype (which omitspasswordHash) for all responses. - Hash passwords with bcrypt (cost factor 12 or higher) before storing. Never store plaintext passwords.
GitHub token for email users
Email/password users have no GitHub OAuth session, so they cannot use their own credentials to read and write content. When the CMS receives a request from an email user, it falls back to GIT_CMS_GITHUB_TOKEN — a server-side token used on their behalf.
What to create
Use a fine-grained personal access token scoped to only this repo:
- Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens
- Click Generate new token
- Set Resource owner to the account or org that owns the content repo
- Under Repository access, select Only select repositories → choose your content repo
- Under Permissions → Repository permissions, set:
- Contents: Read and Write
- Everything else: No access
- Copy the token →
GIT_CMS_GITHUB_TOKEN
What this means for Git history
All writes by email users appear in the GitHub commit history under the PAT owner's account. The CMS still records the actual user's name in the file's frontmatter (metadata.author), so content attribution is preserved — only the Git committer identity is shared.
If accurate per-user Git history matters for your project, stick to GitHub OAuth accounts for editors.
Setup GitHub OAuth
- Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
- Fill in:
- Homepage URL:
http://localhost:3000 - Authorization callback URL:
http://localhost:3000/admin/api/auth/callback/github
- Homepage URL:
- Copy the Client ID →
AUTH_GITHUB_ID - Generate a Client Secret →
AUTH_GITHUB_SECRET - For production, create a second OAuth App with your live domain, or update the callback URL
Setup Vercel Edge Config
- In your Vercel dashboard, go to your project → Storage → Create Edge Config store
- Copy the Connection String →
EDGE_CONFIG - Copy the Edge Config ID (starts with
ecfg_) →VERCEL_EDGE_CONFIG_ID - Go to Account Settings → Tokens → create a token with at least Edge Config write access →
VERCEL_API_TOKEN - Copy your project ID from the project settings →
VERCEL_PROJECT_ID
The Edge Config store is independent from your deployments. Adding or removing a user through the CMS admin takes effect instantly without a redeploy.
Live Preview
When GIT_CMS_PREVIEW_KEY and GIT_CMS_PUBLIC_URL are set, the editor shows a Preview button that opens your public page in a new window or a side-by-side pane. The page re-renders live as you edit without saving.
How it works
- The editor opens your public page at
<publicUrl><slug>?preview=true&key=<previewKey> PreviewProvider(server component) detects the query params and activatesPreviewClientPreviewClientsendspreview:readyback to the editor viapostMessage- The editor responds with
preview:datacontaining the currentPageContent - Client components that call
usePreviewContext()receive live data and re-render
Setup in your public app
PreviewProvider must live in your page component, not the layout. Layouts do not receive searchParams in Next.js App Router, so the preview query params would never be detected.
app/[[...slug]]/page.tsx
import { cache } from 'react'
import { getPageContent, getSettings } from '@celar/git-cms'
import { PreviewProvider } from '@celar/git-cms/preview'
type SearchParams = Record<string, string | string[] | undefined>
interface PageProps {
params: Promise<{ slug?: string[] }>
searchParams: Promise<SearchParams>
}
const getPageData = cache(async (
slug: string[] | undefined,
searchParams: SearchParams | undefined
) => {
const cwd = process.cwd()
const slugMapPath = path.join(cwd, 'content', 'slug-map.json')
const settingsPath = path.join(cwd, 'content', 'settings.json')
const includeDrafts =
searchParams?.preview === 'true' &&
searchParams?.key === process.env.GIT_CMS_PREVIEW_KEY
const content = getPageContent(slugMapPath, cwd, slug, { includeDrafts })
const settings = getSettings(settingsPath)
return { content, settings, includeDrafts }
})
export default async function Page({ params, searchParams }: PageProps) {
const [p, sp] = await Promise.all([params, searchParams])
const { content } = await getPageData(p.slug, sp)
if (!content) notFound()
return (
<PreviewProvider searchParams={sp}>
<main>
{content.blocks.map(renderBlock)}
</main>
</PreviewProvider>
)
}Making blocks preview-aware — no block changes needed
Use PreviewBlocks to handle data substitution at the page level. Your block components stay untouched.
import { PreviewProvider, PreviewBlocks } from '@celar/git-cms/preview'
export default async function Page({ params, searchParams }: PageProps) {
const { slug = [] } = await params
const content = readContent(slug)
if (!content) notFound()
return (
<PreviewProvider searchParams={await searchParams}>
<main className="flex-1">
<PreviewBlocks serverBlocks={content.blocks}>
{(blocks) => blocks.map((block) => (
block.type !== 'hero' ? (
<div key={`${block.type}-${block.id}`} className="container mx-auto max-w-5xl px-4 mb-8">
{renderBlock(block)}
</div>
) : renderBlock(block)
))}
</PreviewBlocks>
</main>
</PreviewProvider>
)
}PreviewBlocks is a client component that reads PreviewContext and passes live blocks to its render-prop children when preview data is available, or falls through to serverBlocks for normal page loads.
Draft and publish
Every page has a status of draft or published. Status is stored in the file's YAML frontmatter:
---
title: My page
status: draft
---Pages without a status field are treated as published (backward compatible with existing content).
Editing workflow
The editor shows two save actions:
- Save Draft — saves the page with
status: draft. The page is excluded from public reads unless you explicitly request drafts. - Publish — saves the page with
status: published.
If you edit a published page, the status automatically flips to draft when you start typing. You must explicitly click Publish to make changes live.
A status badge (amber for draft, green for published) is shown in both the editor toolbar and the file list.
Reading published content only
getPageContent() filters out drafts by default. In preview mode, drafts are included:
import { getPageContent } from '@celar/git-cms'
// Public page — drafts excluded automatically
const content = getPageContent(slug)
// Preview — pass includeDrafts to show draft content
const content = getPageContent(slug, { includeDrafts: true })Release manager
The release manager lets you group multiple draft pages into a named release and publish them all in a single git commit. Releases are admin-only.
How it works
- While editing a page, open the Page settings panel and assign the page to a release (or create a new one).
- Navigate to Releases in the admin header to see all releases and the pages in each one.
- Click Publish on a release to publish all its pages in one atomic commit and delete the release.
Releases are stored as JSON files in {contentBase}/content/releases/ in your GitHub repo. Publishing uses the GitHub Git Trees API to write all changed files and the commit in one round trip.
Release operations
From the Releases view you can:
- Create a named release with an optional description
- Add / remove pages — pages can be moved between releases or removed without being published
- Rename a release
- Publish — all pages in the release are set to
publishedand committed atomically - Delete — removes the release without publishing any of its pages
Access control
Only admin users can access the Releases view and the release API endpoints.
Content is stored as markdown with YAML frontmatter in your GitHub repo. Read it server-side using the markdown utility:
import { parseMarkdown } from '@celar/git-cms/markdown'
import fs from 'fs'
const raw = fs.readFileSync('content/pages/home.md', 'utf-8')
const page = parseMarkdown(raw)
// page.title, page.slug, page.blocks[]Navigation
Pages opt in to navigation via frontmatter:
---
title: About
slug: /about
navEnabled: true
navTitle: About Us
navOrder: 2
navParent: /company # optional: nests under another page's slug
---Build a nav tree server-side:
import { buildNav } from '@celar/git-cms/nav'
const nav = buildNav(['content/pages'])
// nav.items[] sorted by navOrder, nested by navParentField types
| Type | Description |
|------|-------------|
| text | Single-line text |
| textarea | Multi-line text |
| richtext | Tiptap rich text editor |
| number | Numeric input |
| boolean | Toggle |
| image | Single image |
| imagelist | Multiple images |
| dropdown | Select — requires options: [{ label, value }] |
| pagepicker | Pick a page by slug — requires contentPath |
Package exports
| Import | Use |
|--------|-----|
| @celar/git-cms | Types, markdown utilities, settings utilities |
| @celar/git-cms/core | AdminPage, createDispatcher |
| @celar/git-cms/auth | createAuth — call with a UserStore to get handlers, auth, signIn, signOut |
| @celar/git-cms/adapters | VercelEdgeConfigAdapter and the UserStore interface |
| @celar/git-cms/markdown | parseMarkdown, serializeToMarkdown |
| @celar/git-cms/nav | buildNav |
| @celar/git-cms/preview | PreviewProvider (server), usePreviewContext (client) |
| @celar/git-cms/styles | Scoped admin CSS |
License
MIT
