@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)
- Localization (i18n) — optional per-locale content, slugs, and navigation (admin-only setup)
- 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.local
Note: 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:3000
Here'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=1234567
Find 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() reads the slug-map to resolve a slug to its markdown file and parses it. It filters out drafts by default; pass includeDrafts (e.g. in preview mode) to include them.
import path from 'path'
import { getPageContent } from '@celar/git-cms'
const cwd = process.cwd()
const slugMapPath = path.join(cwd, 'content', 'slug-map.json')
// Public page — drafts excluded automatically. `slug` is the route segments array
// (e.g. ['blog', 'post']); pass undefined for the home page.
const content = getPageContent(slugMapPath, cwd, slug)
// Preview — pass includeDrafts to show draft content
const content = getPageContent(slugMapPath, cwd, slug, { includeDrafts: true })Signature:
getPageContent(slugMapPath, projectRoot, slugParts?, options?)whereoptionsis{ includeDrafts?: boolean; locale?: string }. See Localization for thelocaleoption.
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 navParentLocalization (i18n)
Localization is optional and off by default. With no content/locales.json, content stays flat and everything behaves exactly as documented above. Once enabled, each page can be translated into multiple locales, with localized titles, slugs, descriptions, and navigation.
How it works
- Folder = locale. The first path segment under a content root is the locale code:
content/pages/en-US/about.md,content/pages/fr-CA/about.md. - The whole page is the unit of translation — not individual fields. Each locale gets its own markdown file. A page that has no file for a given locale simply does not exist in that locale (no synthetic fallback).
- Translation key links variants of the same logical page: it is the file path relative to the locale folder (
about,blog/post). Filenames stay stable across locales; only theslugfrontmatter is localized.
Locale codes are ISO-639 language + optional ISO-3166-1 alpha-2 region — e.g. en-US, fr-CA, nl.
Enabling it (in the admin)
Localization is configured entirely from the admin UI — no code changes in your app:
- Log in as admin and open Locales in the header (admin-only, like Users/Releases).
- Add your locales (code + label) and pick one as the default.
- The first locale you add triggers a one-time migration: existing flat files in each content dir move into a
<defaultLocale>/subfolder (e.g.content/pages/home.md→content/pages/en-US/home.md), getlocale+translationKeyfrontmatter stamped, and the slug-map is rewritten into its structured shape. This runs on both thedraftsandmainbranches.
After that, the admin header shows a locale selector (active browsing/editing locale), and the editor shows a per-page translation switcher to jump to or create a variant in another locale (with a "Copy from default" action to clone the default's blocks as a starting point).
The admin auto-resolves
content/locales.json(under yourcontentBaseif set) server-side — you do not need to pass any extra prop toAdminPage.
What gets stored
content/locales.json — the locale config (mirrored to main so the published site can read it):
{
"default": "en-US",
"locales": [
{ "code": "en-US", "label": "English (US)", "urlPrefix": "" },
{ "code": "fr-CA", "label": "Français (Canada)", "urlPrefix": "fr" }
]
}URL prefix. urlPrefix is the path segment a locale routes under, and is configurable per locale in the Locales admin:
- Omitted → falls back to the locale
code(sofr-CA→/fr-CA/…). - A custom value (e.g.
"fr") → routes under that segment (/fr/a-propos). - An empty string
""→ the locale is served at the site root with no prefix (/about). Typically used for the default locale.
The editor's Page URL preview (under the Slug field) reflects the configured prefix, so you can see a page's final URL while editing.
Each localized page file carries two extra frontmatter fields (both derivable from the path, persisted for robustness):
---
title: À propos
slug: /a-propos
locale: fr-CA
translationKey: about
---content/slug-map.json becomes a structured, locale-keyed map (the legacy flat { slug → file } shape is still read for non-localized apps):
{
"en-US": { "/about": { "file": "content/pages/en-US/about.md", "key": "about" } },
"fr-CA": { "/a-propos": { "file": "content/pages/fr-CA/about.md", "key": "about" } }
}Reading localized content in your app
Your app owns routing. The recommended layout uses a locale prefix plus the localized slug — app/[locale]/[[...slug]]/page.tsx, where the [locale] segment is the locale's URL prefix (which may differ from its code, or be empty for a root-served locale). The CMS supplies the helpers to resolve the active locale and load the right file. They pair naturally with any i18n routing library (next-intl, next-translate, etc.), or you can use resolveLocale on its own.
// app/[locale]/[[...slug]]/page.tsx
import path from 'path'
import { notFound } from 'next/navigation'
import { getPageContent } from '@celar/git-cms'
import { getLocales, resolveLocale, getAvailableLocales, localePath } from '@celar/git-cms/locales'
interface PageProps {
params: Promise<{ locale: string; slug?: string[] }>
}
export default async function Page({ params }: PageProps) {
const { locale: prefix, slug } = await params
const cwd = process.cwd()
const slugMapPath = path.join(cwd, 'content', 'slug-map.json')
const localesPath = path.join(cwd, 'content', 'locales.json')
// Map the URL prefix segment back to a locale code (falls back to default).
const config = getLocales(localesPath)
const locale = resolveLocale({ pathname: `/${prefix}` }, config)
// Load the file for this locale + slug. Returns null when no variant exists.
const content = getPageContent(slugMapPath, cwd, slug, { locale })
if (!content) notFound()
// Locale switcher: every locale this page exists in, with its localized URL.
const alternates = getAvailableLocales(slugMapPath, localesPath, cwd, slug, locale)
return (
<main>
{content.blocks.map(renderBlock)}
{/* each alternate's URL: localePath(a.urlPrefix, a.slug) → "/fr/a-propos" or "/about" */}
{/* <a href={localePath(a.urlPrefix, a.slug)}>{a.label}</a> */}
</main>
)
}The helpers:
// Read the locales config (server-side). Returns { default: '', locales: [] } when absent.
getLocales(localesPath: string): LocalesConfig
// Resolve the active locale code. Pure / framework-agnostic.
// Precedence: url path prefix > cookie > Accept-Language > config.default.
// The url's first segment is matched against each locale's effective URL prefix.
resolveLocale(
input: { pathname?: string; cookieValue?: string; acceptLanguage?: string },
config: LocalesConfig,
): string
// Resolve a page for a locale. Returns null when no variant exists (no fallback).
getPageContent(
slugMapPath: string,
projectRoot: string,
slugParts: string[] | undefined,
options?: { locale?: string; includeDrafts?: boolean },
): PageContent | null
// Locale switcher data: every locale this page exists in, with its localized slug,
// label, and resolved URL prefix. Build a path with localePath(urlPrefix, slug).
getAvailableLocales(
slugMapPath: string,
localesPath: string,
projectRoot: string,
slugParts: string[] | undefined,
currentLocale: string,
): Array<{ code: string; label: string; slug: string; urlPrefix: string; isCurrent: boolean }>
// A locale's effective URL prefix: its urlPrefix, or the code when unset ("" = root).
localeUrlPrefix(locale: LocaleDefinition): string
// Join a prefix + slug into a URL path. "" prefix → slug unchanged; else "/<prefix><slug>".
localePath(prefix: string, slug: string): stringWhen you're not in a route that provides a locale prefix (e.g. resolving the locale in middleware or a root redirect), feed resolveLocale the request's cookie and Accept-Language header instead of a pathname.
Localized navigation
buildNav takes a locale option and scopes the scan to that locale's subtree, so nav titles, ordering, parent relationships, and hrefs are all localized automatically. Call it once per locale:
import { buildNav } from '@celar/git-cms/nav'
const nav = buildNav(['content/pages'], { locale })With locale omitted on flat content, behavior is unchanged.
Example: integrating with next-intl
The CMS helpers are framework-agnostic, so they slot into any i18n library. Here
is a complete, working integration with next-intl on
Next.js 16. The CMS owns the content (per-locale markdown, localized slugs,
nav); next-intl owns locale routing (prefix detection/redirects) and any UI
chrome strings (labels, buttons).
1. Install and enable the plugin
npm install next-intl// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin() // defaults to ./i18n/request.ts
export default withNextIntl({ /* your Next.js config */ })2. Derive the routing config from the CMS's locales.json
Reuse the file the CMS admin already writes — one source of truth, so adding a locale in the admin needs no code change.
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing'
import localesConfig from '@/content/locales.json'
export const routing = defineRouting({
locales: localesConfig.locales.map((l) => l.code), // e.g. ['en-US', 'fr-CA']
defaultLocale: localesConfig.default,
// Mirrors the CMS "prefix + localized slug" model: /en-US/about, /fr-CA/a-propos
localePrefix: 'always',
})// i18n/request.ts (loads UI-chrome messages, not page content)
import { getRequestConfig } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing } from './routing'
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
return { locale, messages: (await import(`../messages/${locale}.json`)).default }
})3. Proxy (formerly Middleware) for locale detection
Next.js 16:
middleware.tswas renamed toproxy.ts(same functionality). next-intl's factory still returns a standard request handler.
// proxy.ts
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
export default createMiddleware(routing)
export const config = {
// Run everywhere except the CMS admin, API, Next internals and static files.
// Excluding `/admin` keeps the admin un-localized.
matcher: ['/((?!admin|api|_next|_vercel|.*\\..*).*)'],
}4. Keep /admin out of [locale] with route groups
The admin must not be localized, so give the public site and the admin their own
root layouts
via route groups — no top-level app/layout.tsx:
app/
├── (site)/
│ └── [locale]/
│ ├── layout.tsx # <html lang>, NextIntlClientProvider, localized nav
│ └── [[...slug]]/page.tsx # localized content + locale switcher
└── (admin)/
└── admin/ # the CMS admin — its own <html>/<body> root layout
├── [[...cms]]/page.tsx
└── api/[...cms]/route.ts
5. Localized layout — provider, lang, and locale-scoped nav
// app/(site)/[locale]/layout.tsx
import path from 'path'
import { notFound } from 'next/navigation'
import { hasLocale, NextIntlClientProvider } from 'next-intl'
import { setRequestLocale } from 'next-intl/server'
import { buildNav } from '@celar/git-cms/nav'
import type { NavItem } from '@celar/git-cms'
import { routing } from '@/i18n/routing'
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
// buildNav hrefs are bare localized slugs ("/about"); prefix them with the
// active locale so links resolve to the right route ("/en-US/about").
function prefixNav(items: NavItem[], locale: string): NavItem[] {
return items.map((item) => ({
...item,
href: `/${locale}${item.href === '/' ? '' : item.href}`,
children: item.children ? prefixNav(item.children, locale) : undefined,
}))
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
if (!hasLocale(routing.locales, locale)) notFound()
setRequestLocale(locale)
// Scope nav to this locale's content subtree — titles, order, slugs all localized.
const nav = buildNav([path.join(process.cwd(), 'content', 'pages')], { locale })
const items = prefixNav(nav.items, locale)
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>
{/* render `items` in your <nav>, link home to `/${locale}` */}
{children}
</NextIntlClientProvider>
</body>
</html>
)
}6. Localized page — resolve content and build the switcher
params.locale is already validated by the proxy; pass it straight to
getPageContent. getAvailableLocales gives every locale this page exists in
(with its localized slug) for the language switcher.
// app/(site)/[locale]/[[...slug]]/page.tsx
import path from 'path'
import { notFound } from 'next/navigation'
import { setRequestLocale, getTranslations } from 'next-intl/server'
import { getPageContent } from '@celar/git-cms'
import { getLocales, resolveLocale, getAvailableLocales } from '@celar/git-cms/locales'
interface PageProps {
params: Promise<{ locale: string; slug?: string[] }>
}
export default async function Page({ params }: PageProps) {
const { locale: localeParam, slug } = await params
setRequestLocale(localeParam)
const cwd = process.cwd()
const slugMapPath = path.join(cwd, 'content', 'slug-map.json')
const localesPath = path.join(cwd, 'content', 'locales.json')
const locale = resolveLocale({ pathname: `/${localeParam}` }, getLocales(localesPath))
const content = getPageContent(slugMapPath, cwd, slug, { locale })
if (!content) notFound()
// [{ code, label, slug, isCurrent }] — link each as `/${code}${slug}`
const alternates = getAvailableLocales(slugMapPath, localesPath, cwd, slug, locale)
const t = await getTranslations('LocaleSwitcher')
return (
<main>
{content.blocks.map(renderBlock)}
<nav aria-label={t('label')}>
{alternates.map((a) => (
<a key={a.code} href={`/${a.code}${a.slug === '/' ? '' : a.slug}`} aria-current={a.isCurrent || undefined}>
{a.label}
</a>
))}
</nav>
</main>
)
}That's the whole integration: next-intl handles the locale prefix and
redirects, while getPageContent({ locale }), buildNav({ locale }) and
getAvailableLocales(...) supply the localized content, navigation and switcher.
Field 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/locales |
getLocales, resolveLocale, getAvailableLocales, localeUrlPrefix, localePath + locale types (also re-exported from the main entry) |
@celar/git-cms/preview |
PreviewProvider (server), usePreviewContext (client) |
@celar/git-cms/styles |
Scoped admin CSS |
License
MIT