# Agent Instructions Source: https://docs.mdcms.ai/agent-instructions Copy-pasteable prompts for AI coding agents to set up and configure MDCMS **Prefer installable skills?** If your agent supports [skills.sh](https://skills.sh) (Claude Code, Cursor, Gemini CLI, Codex, Copilot, and 40+ others), run `npx skills add mdcms-ai/mdcms/skills` to install the [MDCMS Skills Pack](/agent-skills) — it walks your agent through the full setup without copy-pasting prompts. Copy any of these prompts into [Claude Code](https://claude.ai/claude-code), [Codex](https://openai.com/index/codex/), or your preferred AI coding agent to automate MDCMS setup tasks hands-free. ## Full Project Setup Set up MDCMS from scratch — install dependencies, start services, initialize the project, and embed Studio. Set up MDCMS in this project. Follow these steps exactly: 1. **Install the MDCMS packages:** ```bash theme={null} bun add @mdcms/sdk @mdcms/studio bun add -D @mdcms/cli ``` 2. **Start the MDCMS server** (requires Docker): ```bash theme={null} git clone git@github.com:mdcms-ai/mdcms.git /tmp/mdcms-server cd /tmp/mdcms-server bun install docker compose -f docker-compose.dev.yml up -d ``` Server runs at [http://localhost:4000](http://localhost:4000). Demo credentials: [demo@mdcms.local](mailto:demo@mdcms.local) / Demo12345! 3. **Initialize the project:** ```bash theme={null} bunx mdcms init ``` Follow the interactive wizard: enter server URL ([http://localhost:4000](http://localhost:4000)), choose a project name, authenticate via browser, select content directories, and generate the config. 4. **Embed Studio** in a Next.js App Router catch-all route: Create `app/admin/[[...path]]/page.tsx`: ```tsx theme={null} import { Studio } from "@mdcms/studio"; export default function AdminPage() { return ( ); } ``` 5. **Create a content type** in `mdcms.config.ts`: ```typescript theme={null} import { defineConfig, defineType } from "@mdcms/shared"; import { z } from "zod"; const Page = defineType("Page", { directory: "pages", fields: { title: z.string().min(1).max(200), slug: z.string().regex(/^[a-z0-9-]+$/), description: z.string().optional(), }, }); export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", types: [Page], }); ``` 6. **Sync the schema:** ```bash theme={null} bunx mdcms schema sync ``` 7. **Start the dev server** and visit /admin to see the Studio. Verify each step works before proceeding to the next. ## Embed Studio in Next.js Add the MDCMS Studio visual editor to an existing Next.js application. Add the MDCMS Studio visual CMS editor to this Next.js project. 1. **Install:** ```bash theme={null} bun add @mdcms/studio ``` 2. **Add environment variables** to `.env.local`: ``` NEXT_PUBLIC_MDCMS_SERVER_URL=http://localhost:4000 ``` 3. **Create the Studio route** at `app/admin/[[...path]]/page.tsx`: ```tsx theme={null} import { Studio } from "@mdcms/studio"; export default function AdminPage() { return ( ); } ``` 4. **Start the dev server** and navigate to `/admin`. You should see the MDCMS Studio dashboard. The Studio component fetches a runtime bundle from the MDCMS server and renders the full CMS UI. It handles authentication, content editing, publishing, and schema browsing. If the server is unreachable, Studio shows an error with retry option. Make sure the MDCMS server is running at the configured URL. ## Configure the SDK Set up the `@mdcms/sdk` client for fetching content in your application. Set up the MDCMS SDK to fetch content from the CMS in this project. 1. **Install:** ```bash theme={null} bun add @mdcms/sdk ``` 2. **Add environment variables** to `.env.local`: ``` MDCMS_SERVER_URL=http://localhost:4000 MDCMS_API_KEY=your_api_key_here MDCMS_PROJECT=marketing-site MDCMS_ENVIRONMENT=production ``` 3. **Create a shared client** at `lib/mdcms.ts`: ```typescript theme={null} import { createClient } from "@mdcms/sdk"; export const cms = createClient({ serverUrl: process.env.MDCMS_SERVER_URL!, apiKey: process.env.MDCMS_API_KEY!, project: process.env.MDCMS_PROJECT!, environment: process.env.MDCMS_ENVIRONMENT!, }); ``` 4. **Fetch content** in a Next.js page (App Router): ```typescript theme={null} import { cms } from "@/lib/mdcms"; import { notFound } from "next/navigation"; import { MdcmsApiError } from "@mdcms/sdk"; export async function generateStaticParams() { const posts = await cms.list("BlogPost", { published: true }); return posts.data.map((post) => ({ slug: post.frontmatter.slug as string, })); } export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; try { const post = await cms.get("BlogPost", { slug, resolve: ["author"], }); return (

{post.frontmatter.title as string}

{post.body}
); } catch (error) { if (error instanceof MdcmsApiError && error.statusCode === 404) notFound(); throw error; } } ``` The SDK methods are: * `cms.get(type, { id } | { slug }, options?)` — fetch a single document * `cms.list(type, options?)` — fetch a paginated list of documents Options include: `locale`, `resolve` (array of reference fields to expand), `draft` (boolean), `published`, `limit`, `offset`, `sort`, `order`.
## Schema Definition and Sync Define content types and sync them to the server. Set up MDCMS content type definitions and sync them to the server. 1. **Install CLI** (if not already): ```bash theme={null} bun add -D @mdcms/cli ``` 2. **Create or update `mdcms.config.ts`** at the project root: ```typescript theme={null} import { defineConfig, defineType, fieldTypes } from "@mdcms/shared"; import { z } from "zod"; const Author = defineType("Author", { directory: "authors", fields: { name: z.string().min(1), bio: z.string().max(500).optional(), avatar: z.string().url().optional(), }, }); const BlogPost = defineType("BlogPost", { directory: "blog", localized: true, fields: { title: z.string().min(1).max(200), slug: z.string().regex(/^[a-z0-9-]+$/), author: fieldTypes.reference("Author"), publishedAt: z.coerce.date(), tags: z.array(z.string()).default([]), excerpt: z.string().max(300).optional(), featured: z.boolean().default(false), }, }); export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], locales: { default: "en", supported: ["en", "fr", "de"], }, types: [Author, BlogPost], }); ``` 3. **Sync to server:** ```bash theme={null} bunx mdcms schema sync ``` 4. **Verify** in Studio at `/admin/schema` — you should see both content types with their field definitions. Adapt the type definitions to match your content model. Each type needs: * `directory` — folder name for local content files * `fields` — Zod schemas defining the frontmatter structure * `localized` (optional) — set to `true` for multilingual content Available field types: `z.string()`, `z.number()`, `z.boolean()`, `z.coerce.date()`, `z.enum([...])`, `z.array(...)`, `z.object({...})`, `fieldTypes.reference("TypeName")`. ## Content Migration Run schema changes and apply content migrations. Run a schema migration for MDCMS. Follow these steps: 1. **Make schema changes** in `mdcms.config.ts` — modify field types, add/remove fields, rename types as needed. 2. **Check current status:** ```bash theme={null} bunx mdcms status ``` This shows any drift between local schema and server. 3. **For non-breaking changes** (adding optional fields, adding new types): Just sync the schema — no migration needed: ```bash theme={null} bunx mdcms schema sync ``` 4. **For breaking changes** (renaming types, changing field types, removing fields): Generate a migration: ```bash theme={null} bunx mdcms migrate ``` Review the generated migration file in `migrations/`. Apply the migration: ```bash theme={null} bunx mdcms migrate --apply ``` This transforms existing documents and auto-publishes new versions. 5. **Verify** the migration succeeded: ```bash theme={null} bunx mdcms status ``` Should report "in sync" with exit code 0. IMPORTANT: Back up your database before running migrations in production. Test migrations locally with realistic data first. ## CI/CD Pipeline Setup Configure automated schema sync and content deployment. Set up GitHub Actions CI/CD for MDCMS schema sync and content deployment. 1. **Add repository secrets** in GitHub Settings > Secrets: * `MDCMS_API_KEY` — API key with `schema:read`, `schema:write`, `content:write`, `content:write:draft` scopes * `MDCMS_SERVER_URL` — your MDCMS server URL 2. **Create `.github/workflows/mdcms-schema-sync.yml`:** ```yaml theme={null} name: Sync MDCMS Schema on: push: branches: [main] paths: ["mdcms.config.ts"] jobs: sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - run: bunx mdcms schema sync env: MDCMS_API_KEY: ${{ secrets.MDCMS_API_KEY }} MDCMS_PROJECT: marketing-site MDCMS_ENVIRONMENT: production MDCMS_SERVER_URL: ${{ secrets.MDCMS_SERVER_URL }} ``` 3. **Create `.github/workflows/mdcms-content-push.yml`:** ```yaml theme={null} name: Push MDCMS Content on: push: branches: [main] paths: ["content/**"] jobs: push: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - run: bun install - run: bunx mdcms push --force env: MDCMS_API_KEY: ${{ secrets.MDCMS_API_KEY }} MDCMS_PROJECT: marketing-site MDCMS_ENVIRONMENT: production MDCMS_SERVER_URL: ${{ secrets.MDCMS_SERVER_URL }} ``` 4. **Add a drift check** to your existing CI: ```yaml theme={null} - run: bunx mdcms status env: MDCMS_API_KEY: ${{ secrets.MDCMS_API_KEY }} MDCMS_PROJECT: marketing-site MDCMS_ENVIRONMENT: production MDCMS_SERVER_URL: ${{ secrets.MDCMS_SERVER_URL }} ``` Exit code 1 means drift detected — use this to block deploys. Adapt project name, environment, and content paths to match your setup. # Agent Skills Source: https://docs.mdcms.ai/agent-skills Install the MDCMS Skills Pack so your AI coding agent can set up MDCMS end-to-end The **MDCMS Skills Pack** is an installable bundle of AI-agent skills that walks a coding agent through adding MDCMS to any React-based project — from standing up a self-hosted backend to day-to-day `pull`/`push` automation. If you use Claude Code, Cursor, Gemini CLI, Codex, Copilot, OpenCode, or any of the 40+ agents supported by [skills.sh](https://skills.sh), this is the fastest way to get an agent to do the setup for you. ## Install ```bash theme={null} # Install all skills into the current project npx skills add mdcms-ai/mdcms/skills # Install globally so every project sees them npx skills add mdcms-ai/mdcms/skills -g # Non-interactive (CI / scripted install) npx skills add mdcms-ai/mdcms/skills -y ``` The install URL ends in `/skills` so skills.sh only sees the public MDCMS pack (internal repo skills are skipped). Installing the whole pack is recommended — the master skill delegates to the focused skills by slug. See the [skills.sh CLI reference](https://github.com/vercel-labs/skills) for `--copy`, `--skill`, `--agent`, and update/remove commands. ## What's in the pack | Skill | Purpose | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `mdcms-setup` | **Master orchestrator.** Detects repo state and routes to the right focused skill. Start here. | | `mdcms-self-host-setup` | Stand up the MDCMS backend via Docker Compose. Env, first boot, admin bootstrap. | | `mdcms-brownfield-init` | Import an existing Markdown/MDX repo into MDCMS via `mdcms init --non-interactive`. | | `mdcms-greenfield-init` | Bootstrap MDCMS in an empty repo with a scaffolded starter. | | `mdcms-schema-refine` | Add/edit content types, fields, references; `mdcms schema sync`. | | `mdcms-studio-embed` | Mount `` at a catch-all route inside the host app. | | `mdcms-sdk-integration` | Fetch content with `@mdcms/sdk`; drafts vs published; SSR. | | `mdcms-mdx-components` | Register custom MDX components so Studio preview and host SSR match. | | `mdcms-content-editing` | Edit, author, translate, and bulk-modify documents. Local vs Studio; validation; new docs. | | `mdcms-content-sync-workflow` | Day-to-day `pull`/`push`/login; key rotation; CI automation. | ## How to use it Once installed, start any conversation with your agent with an intent like "set up MDCMS in this project" or "add MDCMS here." The master skill (`mdcms-setup`) activates, detects whether you have a running server, existing content, and a host app, then delegates to each focused skill at the right phase. The full flow diagram and per-skill details live in the [pack's README on GitHub](https://github.com/mdcms-ai/mdcms/blob/main/skills/README.md). ## When to use skills vs. copy-paste prompts * **Skills pack** (this page) — recommended when you use one of the agents supported by skills.sh. Skills stay installed, are versioned, and delegate across setup phases. * **[Copy-paste prompts](/agent-instructions)** — use when you want a one-shot paste into an agent window, don't want to install anything, or your agent isn't supported by skills.sh. Both paths target the same CLI contract (`mdcms init --non-interactive` and friends), so you can switch between them. # Authentication Source: https://docs.mdcms.ai/api-reference/authentication API key, session, and CLI authentication endpoints MDCMS supports three authentication methods: API keys for server-to-server access, session cookies for browser-based Studio usage, and a CLI device flow for command-line tools. ## API Key Authentication API keys are the recommended method for server-to-server integrations, CI/CD pipelines, and SDK usage. Pass the key in the `Authorization` header: ``` Authorization: Bearer mdcms_key_live_abc123def456 ``` API keys are scoped to specific capabilities and can be restricted to particular project/environment pairs. See [Key Concepts -- API Keys](/guide/concepts#api-keys) for details on scopes and configuration. ## Session Authentication Session-based auth is used by the Studio and browser clients. It requires a CSRF token for all mutation requests. *** ### Login Authenticate with email and password. Returns a session and sets an HTTP-only session cookie. User's email address. User's password. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/auth/login" \ -H "Content-Type: application/json" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -d '{ "email": "editor@example.com", "password": "s3cureP@ssw0rd" }' ``` **Response:** ```json theme={null} { "data": { "session": { "id": "sess_abc123def456", "userId": "550e8400-e29b-41d4-a716-446655440000", "email": "editor@example.com", "issuedAt": "2026-01-15T09:00:00.000Z", "expiresAt": "2026-01-16T09:00:00.000Z" } } } ``` The response also sets an `mdcms_session` HTTP-only cookie and a `mdcms_csrf` cookie containing the CSRF token. Include the CSRF token in the `X-MDCMS-CSRF-Token` header for all subsequent mutation requests. *** ### Logout End the current session. Requires session cookie and CSRF token. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/auth/logout" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -b "mdcms_session=sess_abc123def456" ``` **Response:** ```json theme={null} { "data": { "success": true } } ``` *** ## SSO Authentication ### Initiate OIDC Initiate an OpenID Connect authentication flow with an external identity provider. The identity provider slug (e.g., `google`, `github`, `okta`). **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/auth/sso/google" \ -H "Content-Type: application/json" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" ``` **Response:** ```json theme={null} { "data": { "redirectUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=...&scope=openid+email+profile&state=..." } } ``` Redirect the user to the `redirectUrl`. After successful authentication, the provider redirects back to the MDCMS callback URL, which establishes a session. *** ### SAML Assertion Consumer SAML assertion consumer service endpoint. Receives and processes SAML assertions from the identity provider after authentication. This endpoint is called by the SAML identity provider, not by client applications directly. On successful assertion validation, a session is established and the user is redirected to the Studio. **Response:** HTTP 302 redirect to the Studio dashboard with session cookies set. *** ### SAML Metadata Returns the SAML service provider metadata XML document. Use this URL when configuring MDCMS as a service provider in your identity provider's admin console. **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/auth/saml/metadata" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" ``` **Response:** XML document with `Content-Type: application/xml`. ```xml theme={null} ``` *** ## CLI Device Flow The CLI uses a device authorization flow to authenticate without exposing credentials on the command line. This flow is initiated by `mdcms login`. ### Start Challenge Begin a CLI device authorization flow. Returns a challenge ID and a URL for the user to authorize in their browser. Project slug to authorize against. Environment to authorize against. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/auth/cli/start" \ -H "Content-Type: application/json" \ -d '{ "project": "marketing-site", "environment": "production" }' ``` **Response:** ```json theme={null} { "data": { "challengeId": "ch_abc123def456", "authorizeUrl": "https://cms.example.com/auth/cli/authorize?challenge=ch_abc123def456", "expiresAt": "2026-01-15T09:10:00.000Z" } } ``` The challenge expires after 10 minutes. If the user does not authorize within that window, the CLI must restart the flow. ### Authorize Challenge Authorize a pending CLI device challenge. Called from the browser after the user logs in and approves the CLI session. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/auth/cli/authorize" \ -H "Content-Type: application/json" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -b "mdcms_session=sess_abc123def456" \ -d '{ "challengeId": "ch_abc123def456" }' ``` **Response:** ```json theme={null} { "data": { "success": true, "code": "authz_code_xyz789" } } ``` ### Exchange Code Exchange the authorization code for an API key. Called by the CLI after the user authorizes the challenge. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/auth/cli/exchange" \ -H "Content-Type: application/json" \ -d '{ "challengeId": "ch_abc123def456", "code": "authz_code_xyz789" }' ``` **Response:** ```json theme={null} { "data": { "apiKey": "mdcms_key_cli_abc123def456ghi789", "expiresAt": "2026-02-15T09:00:00.000Z" } } ``` The CLI stores this API key locally and uses it for subsequent requests. *** ## Current Principal ### Get Current User Returns the current authenticated principal's identity and capabilities within the active project and environment. **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/me" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "principalType": "apiKey", "principalId": "key_550e8400-e29b-41d4-a716-446655440000", "label": "Production Read-Only", "capabilities": { "schema": { "read": true, "write": false }, "content": { "read": true, "readDraft": false, "write": false, "publish": false, "delete": false }, "users": { "manage": false }, "settings": { "manage": false } } } } ``` For session-based authentication, the response includes the user's email and role instead of a key label: ```json theme={null} { "data": { "principalType": "user", "principalId": "550e8400-e29b-41d4-a716-446655440000", "email": "admin@example.com", "role": "admin", "capabilities": { "schema": { "read": true, "write": true }, "content": { "read": true, "readDraft": true, "write": true, "publish": true, "delete": true }, "users": { "manage": true }, "settings": { "manage": true } } } } ``` *** ## Environments ### List Environments Returns all environments configured for the current project. **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/environments" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": [ { "name": "production", "extends": null, "isDefault": true, "createdAt": "2026-01-01T00:00:00.000Z" }, { "name": "staging", "extends": "production", "isDefault": false, "createdAt": "2026-01-01T00:00:00.000Z" }, { "name": "development", "extends": "staging", "isDefault": false, "createdAt": "2026-01-05T00:00:00.000Z" } ] } ``` The `X-MDCMS-Environment` header is still required for this endpoint, even though it returns all environments. The header is used for authentication context. # Content API Source: https://docs.mdcms.ai/api-reference/content Document CRUD, publishing, versioning, and restoration endpoints The Content API provides full lifecycle management for documents: creation, querying, editing, publishing, versioning, deletion, and restoration. All content endpoints require the standard [authentication headers](/api-reference/overview#required-headers). ## List Documents Retrieve a paginated list of documents, filtered by type and optional criteria. **Scope required:** `content:read`. Add `content:read:draft` to include draft documents (when `draft=true`). ### Query Parameters Content type to filter by (e.g., `BlogPost`). BCP 47 locale tag. Filters results to a specific locale. Exact path match (e.g., `content/blog/hello-world`). Filter by the `slug` frontmatter field. Requires the type to have a `slug` field. When `true`, only return documents with at least one published version. When `false`, only return never-published documents. When `true`, include soft-deleted documents. Defaults to `false`. When `true`, only return documents with draft changes ahead of the published version. When `true`, return draft content instead of the published version. Requires `content:read:draft` scope. Reference field name to resolve. Can be repeated for multiple fields (e.g., `resolve=author&resolve=category`). Resolution is one level deep. Number of results per page. Default `20`, maximum `100`. Number of results to skip. Default `0`. Sort field. Accepted values: `createdAt`, `updatedAt`, `path`. Default `createdAt`. Sort direction: `asc` or `desc`. Default `desc`. ### Example ```bash cURL theme={null} curl "https://cms.example.com/api/v1/content?type=BlogPost&locale=en&published=true&sort=updatedAt&order=desc&limit=10" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` ```typescript SDK theme={null} import { createClient } from "@mdcms/sdk"; const client = createClient({ serverUrl: "https://cms.example.com", apiKey: process.env.MDCMS_API_KEY!, project: "marketing-site", environment: "production", }); const posts = await client.list("BlogPost", { locale: "en", published: true, sort: "updatedAt", order: "desc", limit: 10, }); // posts.data — ContentDocumentResponse[] // posts.pagination — { total, limit, offset, hasMore } ``` **Response:** ```json theme={null} { "data": [ { "documentId": "550e8400-e29b-41d4-a716-446655440000", "translationGroupId": "660e8400-e29b-41d4-a716-446655440000", "project": "marketing-site", "environment": "production", "path": "content/blog/hello-world", "type": "BlogPost", "locale": "en", "format": "mdx", "isDeleted": false, "hasUnpublishedChanges": false, "version": 3, "publishedVersion": 3, "draftRevision": 7, "frontmatter": { "title": "Hello World", "slug": "hello-world", "author": "770e8400-e29b-41d4-a716-446655440001", "publishedAt": "2026-01-15T00:00:00.000Z", "tags": ["announcements"] }, "body": "Welcome to our blog. This is the first post.", "resolveErrors": {}, "createdBy": "880e8400-e29b-41d4-a716-446655440002", "createdAt": "2026-01-10T12:00:00.000Z", "updatedBy": "880e8400-e29b-41d4-a716-446655440002", "updatedAt": "2026-01-15T09:30:00.000Z" } ], "pagination": { "total": 42, "limit": 10, "offset": 0, "hasMore": true } } ``` *** ## Get Document Retrieve a single document by its ID. **Scope required:** `content:read`. Add `content:read:draft` when using `draft=true`. ### Path Parameters The document's UUID. ### Query Parameters Locale override. If the document is localized, returns the variant for this locale. Reference field name to resolve. Can be repeated. When `true`, return the draft version instead of the published version. ### Example ```bash cURL theme={null} curl "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000?resolve=author&draft=false" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` ```typescript SDK theme={null} const post = await client.get("BlogPost", { id: "550e8400-e29b-41d4-a716-446655440000", resolve: ["author"], draft: false, }); ``` **Response:** ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "translationGroupId": "660e8400-e29b-41d4-a716-446655440000", "project": "marketing-site", "environment": "production", "path": "content/blog/hello-world", "type": "BlogPost", "locale": "en", "format": "mdx", "isDeleted": false, "hasUnpublishedChanges": false, "version": 3, "publishedVersion": 3, "draftRevision": 7, "frontmatter": { "title": "Hello World", "slug": "hello-world", "author": { "documentId": "770e8400-e29b-41d4-a716-446655440001", "type": "Author", "path": "content/authors/jane-doe", "frontmatter": { "name": "Jane Doe", "bio": "Tech writer and editor.", "website": "https://janedoe.com" } }, "publishedAt": "2026-01-15T00:00:00.000Z", "tags": ["announcements"] }, "body": "Welcome to our blog. This is the first post.", "resolveErrors": {}, "createdBy": "880e8400-e29b-41d4-a716-446655440002", "createdAt": "2026-01-10T12:00:00.000Z", "updatedBy": "880e8400-e29b-41d4-a716-446655440002", "updatedAt": "2026-01-15T09:30:00.000Z" } } ``` *** ## Create Preview Token Mint a short-lived signed token for a host-app draft preview route. **Scope required:** `content:read:draft` **Required headers:** `X-MDCMS-CSRF-Token` (session auth) The token is bound to the persisted draft document, project, environment, locale, path, draft revision, and optional preview URL. It is not an API key. Host routes verify it with `@mdcms/sdk` before fetching `draft: true` content. ### Path Parameters The document's UUID. ### Request Body Host preview URL Studio intends to load. When provided, it is included in the token claims so host routes can bind preview tokens to a route. ### Example ```bash cURL theme={null} curl -X POST "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000/preview-token" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_preview_abc123" \ -H "Content-Type: application/json" \ -d '{"previewUrl":"/preview/blog/hello-world?preview=true"}' ``` **Response:** ```json theme={null} { "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expiresAt": "2026-06-02T10:05:00.000Z" } } ``` ### Errors | Code | Status | Description | | --------------------------- | ------ | -------------------------------------------------- | | `PREVIEW_TOKEN_UNAVAILABLE` | 503 | The server has no preview token secret configured. | | `NOT_FOUND` | 404 | The document does not exist or is deleted. | | `FORBIDDEN` | 403 | The caller lacks draft read access to the path. | | `INVALID_INPUT` | 400 | The request body is not valid. | *** ## Create Document Create a new document of the specified content type. **Scope required:** `content:write` **Required headers:** `X-MDCMS-Schema-Hash`, `X-MDCMS-CSRF-Token` (session auth) ### Request Body Content type name (e.g., `BlogPost`). Filesystem path for the document (e.g., `content/blog/my-new-post`). BCP 47 locale tag. Required for localized types. Defaults to `__mdcms_default__` for non-localized types. Document format: `md` or `mdx`. Defaults to `md`. Structured data matching the content type schema. Markdown or MDX content body. Defaults to empty string. ### Example ```bash cURL theme={null} curl -X POST "https://cms.example.com/api/v1/content" \ -H "Content-Type: application/json" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-Schema-Hash: sha256_abc123" \ -H "Authorization: Bearer mdcms_key_live_abc123" \ -d '{ "type": "BlogPost", "path": "content/blog/my-new-post", "locale": "en", "format": "mdx", "frontmatter": { "title": "My New Post", "slug": "my-new-post", "author": "770e8400-e29b-41d4-a716-446655440001", "publishedAt": "2026-01-20T00:00:00.000Z", "tags": ["tutorial"] }, "body": "This is the content of my new post." }' ``` ```typescript SDK theme={null} // The SDK does not currently expose write operations. // Use the REST API directly for content creation. ``` **Response:** ```json theme={null} { "data": { "documentId": "990e8400-e29b-41d4-a716-446655440003", "translationGroupId": "aa0e8400-e29b-41d4-a716-446655440004", "project": "marketing-site", "environment": "production", "path": "content/blog/my-new-post", "type": "BlogPost", "locale": "en", "format": "mdx", "isDeleted": false, "hasUnpublishedChanges": true, "version": 0, "publishedVersion": null, "draftRevision": 1, "frontmatter": { "title": "My New Post", "slug": "my-new-post", "author": "770e8400-e29b-41d4-a716-446655440001", "publishedAt": "2026-01-20T00:00:00.000Z", "tags": ["tutorial"] }, "body": "This is the content of my new post.", "resolveErrors": {}, "createdBy": "880e8400-e29b-41d4-a716-446655440002", "createdAt": "2026-01-20T10:00:00.000Z", "updatedBy": "880e8400-e29b-41d4-a716-446655440002", "updatedAt": "2026-01-20T10:00:00.000Z" } } ``` **Error cases:** | Error Code | Cause | | ----------------------- | ------------------------------------------------------------------------------------ | | `SCHEMA_HASH_MISMATCH` | The `X-MDCMS-Schema-Hash` does not match the server. Run `mdcms schema sync`. | | `CONTENT_PATH_CONFLICT` | A document already exists at the given path and locale. | | `INVALID_INPUT` | Frontmatter does not pass schema validation. Check `details` for field-level errors. | *** ## Update Document Update an existing document's frontmatter and/or body. **Scope required:** `content:write` **Required headers:** `X-MDCMS-Schema-Hash`, `X-MDCMS-CSRF-Token` (session auth) ### Path Parameters The document's UUID. ### Request Body Updated frontmatter. Partial updates are not supported -- provide the complete frontmatter object. Updated Markdown or MDX body. The `draftRevision` value from the last fetch. Used for optimistic concurrency control. If the server's current revision is higher, the update is rejected with a `CONFLICT` error. ### Example ```bash cURL theme={null} curl -X PUT "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000" \ -H "Content-Type: application/json" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-Schema-Hash: sha256_abc123" \ -H "Authorization: Bearer mdcms_key_live_abc123" \ -d '{ "frontmatter": { "title": "Hello World (Updated)", "slug": "hello-world", "author": "770e8400-e29b-41d4-a716-446655440001", "publishedAt": "2026-01-15T00:00:00.000Z", "tags": ["announcements", "updated"] }, "body": "Welcome to our blog. This post has been updated.", "draftRevision": 7 }' ``` ```typescript SDK theme={null} // The SDK does not currently expose write operations. // Use the REST API directly for content updates. ``` **Response:** ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "translationGroupId": "660e8400-e29b-41d4-a716-446655440000", "project": "marketing-site", "environment": "production", "path": "content/blog/hello-world", "type": "BlogPost", "locale": "en", "format": "mdx", "isDeleted": false, "hasUnpublishedChanges": true, "version": 3, "publishedVersion": 3, "draftRevision": 8, "frontmatter": { "title": "Hello World (Updated)", "slug": "hello-world", "author": "770e8400-e29b-41d4-a716-446655440001", "publishedAt": "2026-01-15T00:00:00.000Z", "tags": ["announcements", "updated"] }, "body": "Welcome to our blog. This post has been updated.", "resolveErrors": {}, "createdBy": "880e8400-e29b-41d4-a716-446655440002", "createdAt": "2026-01-10T12:00:00.000Z", "updatedBy": "880e8400-e29b-41d4-a716-446655440002", "updatedAt": "2026-01-20T11:00:00.000Z" } } ``` **Error cases:** | Error Code | Cause | | ---------------------- | ------------------------------------------------------------------------------------------------------- | | `CONFLICT` | The `draftRevision` is stale. Another edit was saved after your last fetch. Re-fetch, merge, and retry. | | `SCHEMA_HASH_MISMATCH` | Schema is out of date. Run `mdcms schema sync`. | | `INVALID_INPUT` | Frontmatter does not pass schema validation. | *** ## Publish Document Publish the current draft, creating an immutable version snapshot. **Scope required:** `content:publish` ### Path Parameters The document's UUID. ### Request Body Optional summary of changes in this version (e.g., "Fixed typos and updated pricing section"). ### Example ```bash cURL theme={null} curl -X POST "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000/publish" \ -H "Content-Type: application/json" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" \ -d '{ "changeSummary": "Updated introduction and fixed typos" }' ``` ```typescript SDK theme={null} // The SDK does not currently expose write operations. // Use the REST API directly for publishing. ``` **Response:** ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "version": 4, "publishedVersion": 4, "hasUnpublishedChanges": false, "publishedAt": "2026-01-20T12:00:00.000Z", "publishedBy": "880e8400-e29b-41d4-a716-446655440002", "changeSummary": "Updated introduction and fixed typos" } } ``` *** ## Unpublish Document Remove the published version, reverting the document to draft-only state. The version history is preserved. **Scope required:** `content:publish` ### Path Parameters The document's UUID. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000/unpublish" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "version": 4, "publishedVersion": null, "hasUnpublishedChanges": true } } ``` *** ## List Versions Retrieve the version history of a document. Each entry represents a published snapshot. **Scope required:** `content:read` ### Path Parameters The document's UUID. ### Query Parameters Number of versions per page. Default `20`, maximum `100`. Number of versions to skip. Default `0`. **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000/versions?limit=5" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": [ { "version": 4, "publishedAt": "2026-01-20T12:00:00.000Z", "publishedBy": "880e8400-e29b-41d4-a716-446655440002", "changeSummary": "Updated introduction and fixed typos" }, { "version": 3, "publishedAt": "2026-01-18T15:00:00.000Z", "publishedBy": "880e8400-e29b-41d4-a716-446655440002", "changeSummary": "Added tags section" }, { "version": 2, "publishedAt": "2026-01-16T09:00:00.000Z", "publishedBy": "880e8400-e29b-41d4-a716-446655440002", "changeSummary": null }, { "version": 1, "publishedAt": "2026-01-15T10:00:00.000Z", "publishedBy": "880e8400-e29b-41d4-a716-446655440002", "changeSummary": "Initial publication" } ], "pagination": { "total": 4, "limit": 5, "offset": 0, "hasMore": false } } ``` *** ## Get Version Retrieve the full content snapshot of a specific published version. **Scope required:** `content:read` ### Path Parameters The document's UUID. The version number to retrieve. **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000/versions/2" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "version": 2, "publishedAt": "2026-01-16T09:00:00.000Z", "publishedBy": "880e8400-e29b-41d4-a716-446655440002", "changeSummary": null, "frontmatter": { "title": "Hello World", "slug": "hello-world", "author": "770e8400-e29b-41d4-a716-446655440001", "publishedAt": "2026-01-15T00:00:00.000Z", "tags": ["announcements"] }, "body": "Welcome to our blog. This is the first post." } } ``` *** ## Restore Version Restore the document's draft to the state captured in a specific version. This does not delete any version history -- it creates a new draft revision with the content from the specified version. **Scope required:** `content:write` ### Path Parameters The document's UUID. The version number to restore from. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000/versions/2/restore" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "version": 4, "publishedVersion": 4, "draftRevision": 9, "hasUnpublishedChanges": true, "restoredFromVersion": 2 } } ``` *** ## Delete Document Soft-delete a document. The document remains in the database but is excluded from normal queries. It can be restored later. **Scope required:** `content:delete` ### Path Parameters The document's UUID. **Example request:** ```bash theme={null} curl -X DELETE "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "isDeleted": true, "deletedAt": "2026-01-21T10:00:00.000Z", "deletedBy": "880e8400-e29b-41d4-a716-446655440002" } } ``` *** ## Restore Deleted Document Restore a soft-deleted document, making it visible in normal queries again. **Scope required:** `content:write` ### Path Parameters The document's UUID. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000/restore" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "isDeleted": false, "restoredAt": "2026-01-22T08:00:00.000Z", "restoredBy": "880e8400-e29b-41d4-a716-446655440002" } } ``` *** ## Duplicate Document Create a copy of a document with a new ID and path. The duplicate starts as a draft with no published version, regardless of the source document's state. **Scope required:** `content:write` ### Path Parameters The source document's UUID. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000/duplicate" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "documentId": "bb0e8400-e29b-41d4-a716-446655440005", "translationGroupId": "cc0e8400-e29b-41d4-a716-446655440006", "project": "marketing-site", "environment": "production", "path": "content/blog/hello-world-copy", "type": "BlogPost", "locale": "en", "format": "mdx", "isDeleted": false, "hasUnpublishedChanges": true, "version": 0, "publishedVersion": null, "draftRevision": 1, "frontmatter": { "title": "Hello World (Copy)", "slug": "hello-world-copy", "author": "770e8400-e29b-41d4-a716-446655440001", "publishedAt": "2026-01-15T00:00:00.000Z", "tags": ["announcements"] }, "body": "Welcome to our blog. This is the first post.", "resolveErrors": {}, "createdBy": "880e8400-e29b-41d4-a716-446655440002", "createdAt": "2026-01-22T09:00:00.000Z", "updatedBy": "880e8400-e29b-41d4-a716-446655440002", "updatedAt": "2026-01-22T09:00:00.000Z" } } ``` *** ## Overview Stats Get aggregate counts of documents per content type. Useful for building editorial dashboards. **Scope required:** `content:read` ### Query Parameters Filter to specific types. Can be repeated (e.g., `type=BlogPost&type=Author`). When omitted, returns counts for all types. **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/content/overview?type=BlogPost&type=Author" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "counts": [ { "type": "BlogPost", "total": 42, "published": 38, "draft": 4, "deleted": 2 }, { "type": "Author", "total": 8, "published": 8, "draft": 0, "deleted": 0 } ] } } ``` *** ## Get Variants Returns all locale variants for a document, identified by their shared translation group. **Scope required:** `content:read` ### Path Parameters The document's UUID. Any locale variant can be used -- the server looks up the translation group automatically. **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/content/550e8400-e29b-41d4-a716-446655440000/variants" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": [ { "documentId": "550e8400-e29b-41d4-a716-446655440000", "locale": "en", "path": "content/blog/hello-world", "version": 4, "publishedVersion": 4, "hasUnpublishedChanges": false, "updatedAt": "2026-01-20T12:00:00.000Z" }, { "documentId": "dd0e8400-e29b-41d4-a716-446655440007", "locale": "fr", "path": "content/blog/bonjour-le-monde", "version": 2, "publishedVersion": 2, "hasUnpublishedChanges": false, "updatedAt": "2026-01-19T14:00:00.000Z" }, { "documentId": "ee0e8400-e29b-41d4-a716-446655440008", "locale": "ja", "path": "content/blog/konnichiwa-sekai", "version": 1, "publishedVersion": null, "hasUnpublishedChanges": true, "updatedAt": "2026-01-21T08:00:00.000Z" } ] } ``` Non-localized types return a single-element array with the `__mdcms_default__` locale. # Error Handling Source: https://docs.mdcms.ai/api-reference/errors Error response format, codes, and resolve errors When a request fails, the MDCMS API returns a structured error response with enough detail to diagnose and resolve the issue programmatically. ## Error Response Format All errors follow a consistent envelope: ```json theme={null} { "error": { "status": "error", "code": "SCHEMA_HASH_MISMATCH", "message": "The provided schema hash does not match the current server schema. Run `mdcms schema sync` to update.", "statusCode": 409, "details": { "expectedHash": "sha256_abc123", "providedHash": "sha256_def456" }, "requestId": "req_7f3a2b1c4d5e6f", "timestamp": "2026-01-15T09:30:00.000Z" } } ``` | Field | Type | Description | | ------------ | ------- | ----------------------------------------------------------------------- | | `status` | string | Always `"error"`. | | `code` | string | Machine-readable error code. Use this for programmatic error handling. | | `message` | string | Human-readable description of what went wrong. | | `statusCode` | integer | HTTP status code (duplicated from the response status for convenience). | | `details` | object | Additional context specific to the error type. May be empty `{}`. | | `requestId` | string | Unique identifier for this request. Include this when reporting issues. | | `timestamp` | string | ISO 8601 timestamp of when the error occurred. | Every error response includes a `requestId`. When contacting support or filing bug reports, include the `requestId` to help trace the request through server logs. ## Error Codes | Code | HTTP Status | Description | Common Cause | | ----------------------- | ----------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `UNAUTHORIZED` | 401 | Missing or invalid authentication credentials. | Expired API key, missing `Authorization` header, invalid session cookie. | | `FORBIDDEN` | 403 | Authenticated but insufficient permissions. | API key lacks the required scope, or user role does not include the needed capability. | | `NOT_FOUND` | 404 | The requested resource does not exist. | Invalid document ID, deleted document, or wrong project/environment. | | `INVALID_INPUT` | 400 | Request body failed validation. | Missing required fields, wrong data types, or values that violate schema constraints. | | `INVALID_QUERY_PARAM` | 400 | A query parameter has an invalid value. | Non-numeric `limit`, unsupported `sort` field, or unknown filter parameter. | | `INVALID_CONTENT_SCOPE` | 400 | The content scope in the request is invalid. | Requesting a type that does not exist, or a locale that is not configured for the project. | | `SCHEMA_NOT_FOUND` | 404 | The requested schema type does not exist in the registry. | Querying for a type name that has not been synced. | | `SCHEMA_HASH_REQUIRED` | 400 | Content write operation attempted without schema hash header. | Missing `X-MDCMS-Schema-Hash` header on a create or update request. | | `SCHEMA_HASH_MISMATCH` | 409 | The schema hash provided does not match the server's current schema. | Local schema is out of date. Run `mdcms schema sync` to update. | | `SCHEMA_NOT_SYNCED` | 409 | No schema has been synced to the server yet. | The project has no schema registry entries. Run `mdcms schema sync` first. | | `CONTENT_PATH_CONFLICT` | 409 | A document already exists at the given path and locale. | Attempting to create a document with a `path` that is already taken. | | `CONFLICT` | 409 | Optimistic concurrency conflict on document update. | The `draftRevision` in the request body is stale. Re-fetch the document, merge changes, and retry with the current revision. | | `RATE_LIMITED` | 429 | Too many requests. | Exceeding the rate limit for the API key or session. Back off and retry after the `Retry-After` header value. | | `INTERNAL_ERROR` | 500 | An unexpected server error occurred. | Server bug or infrastructure issue. Report with the `requestId`. | ### Handling Specific Errors **Schema hash mismatch (409):** ```typescript theme={null} try { await createDocument(payload); } catch (error) { if (error.code === "SCHEMA_HASH_MISMATCH") { // Re-sync schema and retry await syncSchema(); await createDocument(payload); } } ``` **Optimistic concurrency conflict (409):** ```typescript theme={null} try { await updateDocument(documentId, { frontmatter: updatedData, draftRevision: localRevision, }); } catch (error) { if (error.code === "CONFLICT") { // Fetch latest version and merge const latest = await getDocument(documentId); const merged = mergeChanges(localChanges, latest); await updateDocument(documentId, { frontmatter: merged, draftRevision: latest.draftRevision, }); } } ``` **Rate limiting (429):** ```typescript theme={null} try { await listDocuments(params); } catch (error) { if (error.code === "RATE_LIMITED") { const retryAfter = parseInt(error.details.retryAfter, 10); await sleep(retryAfter * 1000); await listDocuments(params); } } ``` ## Resolve Errors When you use the `resolve` parameter to expand [references](/guide/concepts#references) in a content response, some references may fail to resolve. Instead of failing the entire request, the API returns the document with `null` values for unresolvable references and includes error details in the `resolveErrors` field. ### Resolve Error Format ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "type": "BlogPost", "frontmatter": { "title": "Hello World", "author": null, "relatedPosts": [ { "documentId": "...", "type": "BlogPost", "frontmatter": { "title": "Other Post" } }, null ] }, "resolveErrors": { "frontmatter.author": { "code": "REFERENCE_DELETED", "referenceId": "660e8400-e29b-41d4-a716-446655440001", "message": "The referenced Author document has been deleted." }, "frontmatter.relatedPosts[1]": { "code": "REFERENCE_NOT_FOUND", "referenceId": "770e8400-e29b-41d4-a716-446655440002", "message": "The referenced BlogPost document was not found." } } } } ``` ### Resolve Error Codes | Code | Description | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `REFERENCE_NOT_FOUND` | The referenced document does not exist in the target project and environment. It may have been deleted permanently or never existed. | | `REFERENCE_DELETED` | The referenced document exists but has been soft-deleted. It can be restored via the [restore endpoint](/api-reference/content#restore-deleted). | | `REFERENCE_TYPE_MISMATCH` | The referenced document exists but its type does not match the expected target type declared in the schema. This typically indicates a data integrity issue. | | `REFERENCE_FORBIDDEN` | The current API key or user does not have `content:read` permission for the referenced document. The reference exists but cannot be resolved with the current credentials. | Resolve errors are informational and do not cause the request to fail. Always check the `resolveErrors` field when using resolved references, especially in production applications where referenced content may change independently. # Media & Webhooks Source: https://docs.mdcms.ai/api-reference/media-and-webhooks File upload, media management, and webhook configuration endpoints ## Media API The Media API uploads, lists, reads, and deletes project-scoped media assets. Media files are stored in S3-compatible object storage, and each request is routed to an explicit project and environment target. All `/api/v1/media*` endpoints require `X-MDCMS-Project` and `X-MDCMS-Environment` headers. ### List Media List media metadata for the routed project and environment. **Scope required:** `media:read` Case-insensitive filename search. Blank strings are ignored. Filter by derived category: `image`, `video`, `audio`, `document`, `archive`, or `other`. Filter by the uploading user ID. Include assets uploaded on or after this `YYYY-MM-DD` date. Include assets uploaded through this `YYYY-MM-DD` date. Sort by `uploadedAt`, `filename`, or `sizeBytes`. Defaults to `uploadedAt`. Sort direction: `asc` or `desc`. Defaults to `desc` for `uploadedAt` and `sizeBytes`, and `asc` for `filename`. Page size from `1` to `100`. Defaults to `30`. Zero-based page offset. Defaults to `0`. Duplicate query parameters, unknown query parameters, invalid dates, and inverted date ranges return `INVALID_QUERY_PARAM`. **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/media?category=image&q=hero&sort=uploadedAt&order=desc&limit=30&offset=0" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "project": "marketing-site", "filename": "hero.png", "mimeType": "image/png", "sizeBytes": 245760, "url": "https://cdn.example.com/marketing-site/media/550e8400-e29b-41d4-a716-446655440000/hero.png", "uploadedBy": "880e8400-e29b-41d4-a716-446655440002", "uploadedAt": "2026-01-20T10:00:00.000Z" } ], "pagination": { "limit": 30, "offset": 0, "total": 1, "hasMore": false } } ``` ### Upload Media Upload a file. The request must use `multipart/form-data` encoding. **Scope required:** `media:upload` **Content-Type:** `multipart/form-data` The file to upload. Image uploads are limited by the routed project's media image upload setting when configured. Non-image uploads are not subject to that image limit. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/media/upload" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" \ -F "file=@/path/to/image.png" ``` **Response:** ```json theme={null} { "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "project": "marketing-site", "filename": "image.png", "mimeType": "image/png", "sizeBytes": 245760, "url": "https://cdn.example.com/marketing-site/media/550e8400-e29b-41d4-a716-446655440000/image.png", "uploadedBy": "880e8400-e29b-41d4-a716-446655440002", "uploadedAt": "2026-01-20T10:00:00.000Z" } } ``` ### Get Media Read metadata for one media asset in the routed project and environment. **Scope required:** `media:read` ### Path Parameters The media asset ID. **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/media/550e8400-e29b-41d4-a716-446655440000" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "project": "marketing-site", "filename": "image.png", "mimeType": "image/png", "sizeBytes": 245760, "url": "https://cdn.example.com/marketing-site/media/550e8400-e29b-41d4-a716-446655440000/image.png", "uploadedBy": "880e8400-e29b-41d4-a716-446655440002", "uploadedAt": "2026-01-20T10:00:00.000Z" } } ``` ### Delete Media Delete a media file. This removes the file from storage and invalidates all URLs. **Scope required:** `media:delete` ### Path Parameters The media asset ID. **Example request:** ```bash theme={null} curl -X DELETE "https://cms.example.com/api/v1/media/550e8400-e29b-41d4-a716-446655440000" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "deleted": true, "id": "550e8400-e29b-41d4-a716-446655440000" } } ``` Media deletion is permanent and cannot be undone. Ensure no documents reference the file before deleting. The API does not scan or rewrite content references. *** ## Webhooks API Webhooks let you receive real-time HTTP notifications when content changes occur in your project. Configure webhook endpoints to trigger builds, invalidate caches, or sync data with external systems. The Webhooks API is a post-MVP feature. The endpoints are defined and will be available in an upcoming release. ### List Webhooks Returns all webhook configurations for the current project. **Scope required:** `webhooks:read` **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/webhooks" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "project": "marketing-site", "environment": "production", "url": "https://api.example.com/webhooks/mdcms", "events": ["content.published", "content.unpublished"], "active": true, "createdBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA", "updatedBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA", "createdAt": "2026-01-10T12:00:00.000Z", "updatedAt": "2026-01-10T12:00:00.000Z" }, { "id": "660e8400-e29b-41d4-a716-446655440001", "project": "marketing-site", "environment": "production", "url": "https://deploy.example.com/trigger", "events": ["content.published"], "active": true, "createdBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA", "updatedBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA", "createdAt": "2026-01-12T08:00:00.000Z", "updatedAt": "2026-01-12T08:00:00.000Z" } ] } ``` ### Create Webhook Register a new webhook endpoint. **Scope required:** `webhooks:write` The HTTPS URL to receive webhook payloads. Must use HTTPS. List of event types to subscribe to. See [Event Types](#event-types) below. Shared signing secret for HMAC signature verification. Must be 32 to 4096 characters. Studio generates this value automatically for new webhooks; API clients must provide a cryptographically random value. Whether the webhook is active. Defaults to `true`. **Example request:** ```bash theme={null} curl -X POST "https://cms.example.com/api/v1/webhooks" \ -H "Content-Type: application/json" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" \ -d '{ "url": "https://api.example.com/webhooks/mdcms", "events": ["content.published", "content.unpublished", "content.deleted"], "secret": "whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "active": true }' ``` **Response:** ```json theme={null} { "data": { "id": "770e8400-e29b-41d4-a716-446655440002", "project": "marketing-site", "environment": "production", "url": "https://api.example.com/webhooks/mdcms", "events": ["content.published", "content.unpublished", "content.deleted"], "active": true, "createdBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA", "updatedBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA", "createdAt": "2026-01-20T10:00:00.000Z", "updatedAt": "2026-01-20T10:00:00.000Z" } } ``` ### Update Webhook Update an existing webhook configuration. **Scope required:** `webhooks:write` ### Path Parameters The webhook ID. Updated HTTPS URL. Updated event subscriptions. Updated shared signing secret. Omit this field to preserve the existing write-only secret. Enable or disable the webhook. **Example request:** ```bash theme={null} curl -X PUT "https://cms.example.com/api/v1/webhooks/770e8400-e29b-41d4-a716-446655440002" \ -H "Content-Type: application/json" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" \ -d '{ "events": ["content.published", "content.unpublished", "content.deleted", "content.created"], "active": true }' ``` **Response:** ```json theme={null} { "data": { "id": "770e8400-e29b-41d4-a716-446655440002", "project": "marketing-site", "environment": "production", "url": "https://api.example.com/webhooks/mdcms", "events": [ "content.published", "content.unpublished", "content.deleted", "content.created" ], "active": true, "createdBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA", "updatedBy": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA", "createdAt": "2026-01-20T10:00:00.000Z", "updatedAt": "2026-01-20T11:00:00.000Z" } } ``` ### Delete Webhook Remove a webhook configuration. Pending deliveries are cancelled. **Scope required:** `webhooks:write` ### Path Parameters The webhook ID. **Example request:** ```bash theme={null} curl -X DELETE "https://cms.example.com/api/v1/webhooks/770e8400-e29b-41d4-a716-446655440002" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "deleted": true, "id": "770e8400-e29b-41d4-a716-446655440002" } } ``` *** ### Event Types | Event | Trigger | | --------------------- | ----------------------------------------------------------- | | `content.created` | A new document is created. | | `content.updated` | A document's draft is updated (frontmatter or body change). | | `content.published` | A document is published, creating a new version. | | `content.unpublished` | A published document is unpublished. | | `content.deleted` | A document is soft-deleted. | | `content.restored` | A soft-deleted document is restored. | | `media.uploaded` | A media file is uploaded. | ### Webhook Payload All webhook deliveries use the following envelope: ```json theme={null} { "event": "content.published", "timestamp": "2026-01-20T12:00:00.000Z", "project": "marketing-site", "environment": "production", "document": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "translationGroupId": "660e8400-e29b-41d4-a716-446655440001", "type": "BlogPost", "path": "content/blog/hello-world", "locale": "en", "format": "mdx", "version": 4 }, "user": { "id": "user_01HZX9N8Y6M2J4K7V3P6Q8R9SA", "email": "editor@example.com" } } ``` ### Signature Verification Every webhook delivery includes an `X-MDCMS-Signature` header for payload verification: ``` X-MDCMS-Signature: t=1705747200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` The signature is an HMAC-SHA256 digest computed over the concatenation of the timestamp and the raw request body, using the webhook's `secret` as the key. The raw body must be the exact bytes MDCMS sent; parsing JSON and serializing it again can change whitespace or key order and invalidate the signature. **Verification algorithm:** 1. Extract the `t` (timestamp) and `v1` (signature) values from the header. 2. Construct the signed payload: `{timestamp}.{raw_body}`. 3. Compute `HMAC-SHA256(secret, signed_payload)`. 4. Compare the computed digest with the `v1` value (use constant-time comparison). 5. Optionally, reject payloads where the timestamp is more than 5 minutes old to prevent replay attacks. **Verification helper (Node.js):** ```typescript theme={null} import { createHmac, timingSafeEqual } from "crypto"; function verifyWebhookSignature( rawBody: string, signatureHeader: string, secret: string, toleranceSeconds = 300, ): boolean { const parts = Object.fromEntries( signatureHeader.split(",").map((p) => { const [key, value] = p.split("="); return [key, value]; }), ); const timestamp = parts.t; const signature = parts.v1; if (!timestamp || !signature) { return false; } const timestampSeconds = Number.parseInt(timestamp, 10); if (!Number.isSafeInteger(timestampSeconds) || timestampSeconds < 0) { return false; } const age = Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds); if (age > toleranceSeconds) { return false; } const signedPayload = `${timestamp}.${rawBody}`; const expected = createHmac("sha256", secret) .update(signedPayload) .digest("hex"); const actualBuffer = Buffer.from(signature, "hex"); const expectedBuffer = Buffer.from(expected, "hex"); if (actualBuffer.length !== expectedBuffer.length) { return false; } return timingSafeEqual(actualBuffer, expectedBuffer); } ``` **Express receiver example:** ```typescript theme={null} import express from "express"; const app = express(); const signingSecret = process.env.MDCMS_WEBHOOK_SIGNING_SECRET!; app.post( "/webhooks/mdcms", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); const signature = req.header("x-mdcms-signature"); if (!verifyWebhookSignature(rawBody, signature ?? "", signingSecret)) { res.status(401).send("Invalid webhook signature"); return; } const payload = JSON.parse(rawBody); // Process the verified payload asynchronously if work may take longer than // a few seconds. queueWebhookJob(payload); res.sendStatus(200); }, ); ``` ### Delivery Behavior * **Async delivery** -- Webhooks are dispatched asynchronously and do not block the originating API request. * **Retries** -- Failed deliveries (non-2xx responses or network errors) use three total attempts: the initial attempt, then retries after 1 second and 2 seconds. * **HTTPS only** -- Webhook URLs must use HTTPS. HTTP URLs are rejected at configuration time. * **Timeout** -- Each delivery attempt has a 10-second timeout. If the endpoint does not respond within 10 seconds, the attempt is marked as failed. Return a `200` status code as quickly as possible from your webhook endpoint. Perform any heavy processing asynchronously to avoid timeouts. # API Overview Source: https://docs.mdcms.ai/api-reference/overview Base URL, headers, response format, and pagination The MDCMS REST API provides programmatic access to all content management operations. This page covers the fundamentals you need to make your first request. ## Base URL All API endpoints are prefixed with: ``` /api/v1 ``` For a server running at `https://cms.example.com`, the full base URL is `https://cms.example.com/api/v1`. ## Required Headers Every request to the MDCMS API must include the following headers: The project slug (e.g., `marketing-site`). Identifies which project the request targets. The environment name (e.g., `production`, `staging`). Determines which content space the request operates against. Authentication credential. Either a Bearer API key (`Bearer mdcms_key_`) or a session cookie from a browser login. CSRF protection token. Required for all mutation requests (POST, PUT, DELETE) when authenticating via session cookie. Not required for API key authentication. Schema hash returned by `mdcms schema sync`. Required for content write operations (create, update) to ensure the client's schema is in sync with the server. Locale override (BCP 47 tag, e.g., `en`, `fr`, `ja`). When set, the server uses this locale instead of the default for the project. Must be `application/json` for all requests with a JSON body. ## Response Envelopes All API responses follow one of three envelope formats. ### Single Resource Returned by endpoints that fetch or mutate a single entity: ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "type": "BlogPost", "path": "content/blog/hello-world", "locale": "en", "format": "mdx", "frontmatter": { "title": "Hello World", "slug": "hello-world", "publishedAt": "2026-01-15T00:00:00.000Z" }, "body": "Welcome to our blog.", "version": 3, "publishedVersion": 2, "draftRevision": 5, "createdAt": "2026-01-10T12:00:00.000Z", "updatedAt": "2026-01-15T09:30:00.000Z" } } ``` ### Paginated List Returned by list endpoints: ```json theme={null} { "data": [ { "documentId": "550e8400-e29b-41d4-a716-446655440000", "type": "BlogPost", "path": "content/blog/hello-world", "frontmatter": { "title": "Hello World", "slug": "hello-world" }, "version": 3, "createdAt": "2026-01-10T12:00:00.000Z", "updatedAt": "2026-01-15T09:30:00.000Z" } ], "pagination": { "total": 42, "limit": 20, "offset": 0, "hasMore": true } } ``` ### Error Returned when a request fails: ```json theme={null} { "error": { "status": "error", "code": "NOT_FOUND", "message": "Document not found", "statusCode": 404, "details": {}, "requestId": "req_abc123def456", "timestamp": "2026-01-15T09:30:00.000Z" } } ``` See the [Error Handling](/api-reference/errors) page for the full list of error codes. ## Pagination List endpoints accept pagination and sorting parameters: | Parameter | Type | Default | Description | | --------- | ------- | ----------- | -------------------------------------------------------------------- | | `limit` | integer | `20` | Number of results per page. Maximum `100`. | | `offset` | integer | `0` | Number of results to skip. | | `sort` | string | `createdAt` | Field to sort by. Accepted values: `createdAt`, `updatedAt`, `path`. | | `order` | string | `desc` | Sort direction. Accepted values: `asc`, `desc`. | Example request: ```bash theme={null} curl "https://cms.example.com/api/v1/content?type=BlogPost&limit=10&offset=20&sort=updatedAt&order=desc" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` The response includes a `pagination` object so you can iterate through pages: ```json theme={null} { "pagination": { "total": 42, "limit": 10, "offset": 20, "hasMore": true } } ``` When `hasMore` is `true`, increment `offset` by `limit` to fetch the next page. Use the [`@mdcms/sdk`](/api-reference/sdk) package for type-safe access to the API instead of raw HTTP requests. The SDK handles headers, pagination, error parsing, and TypeScript types automatically. # Schema API Source: https://docs.mdcms.ai/api-reference/schema Schema registry endpoints for listing, reading, and syncing content types The Schema API provides access to the content type registry. It lets you introspect the types defined in your project and sync schema changes from the CLI. ## List Types Returns all content types registered in the current project and environment, including resolved field metadata. **Scope required:** `schema:read` **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/schema" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "types": [ { "name": "Author", "directory": "content/authors", "localized": false, "fields": { "name": { "kind": "string", "required": true, "nullable": false, "default": null, "reference": null, "checks": [{ "type": "min", "value": 1 }] }, "bio": { "kind": "string", "required": false, "nullable": false, "default": null, "reference": null, "checks": [] }, "website": { "kind": "string", "required": false, "nullable": false, "default": null, "reference": null, "checks": [{ "type": "url" }] } } }, { "name": "BlogPost", "directory": "content/blog", "localized": true, "fields": { "title": { "kind": "string", "required": true, "nullable": false, "default": null, "reference": null, "checks": [ { "type": "min", "value": 1 }, { "type": "max", "value": 200 } ] }, "slug": { "kind": "string", "required": true, "nullable": false, "default": null, "reference": null, "checks": [{ "type": "regex", "value": "^[a-z0-9-]+$" }] }, "author": { "kind": "reference", "required": true, "nullable": false, "default": null, "reference": { "targetType": "Author" }, "checks": [] }, "publishedAt": { "kind": "date", "required": true, "nullable": false, "default": null, "reference": null, "checks": [] }, "tags": { "kind": "array", "required": false, "nullable": false, "default": [], "reference": null, "checks": [], "items": { "kind": "string", "checks": [] } } } } ], "schemaHash": "sha256_abc123def456789" } } ``` ### Field Metadata Each field in the response includes the following properties: | Property | Type | Description | | ----------- | -------------- | ----------------------------------------------------------------------------------------------------- | | `kind` | string | The field's data type: `string`, `number`, `boolean`, `date`, `array`, `object`, `reference`, `enum`. | | `required` | boolean | Whether the field must be present in frontmatter. | | `nullable` | boolean | Whether the field accepts `null` as a value. | | `default` | any | Default value applied when the field is omitted. `null` means no default. | | `reference` | object \| null | For reference fields, contains `targetType`. `null` for non-reference fields. | | `checks` | array | Validation constraints derived from the Zod schema (e.g., `min`, `max`, `regex`, `url`, `email`). | *** ## Get Type Returns the full schema definition for a single content type. **Scope required:** `schema:read` ### Path Parameters The content type name (e.g., `BlogPost`). **Example request:** ```bash theme={null} curl "https://cms.example.com/api/v1/schema/BlogPost" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "Authorization: Bearer mdcms_key_live_abc123" ``` **Response:** ```json theme={null} { "data": { "name": "BlogPost", "directory": "content/blog", "localized": true, "fields": { "title": { "kind": "string", "required": true, "nullable": false, "default": null, "reference": null, "checks": [ { "type": "min", "value": 1 }, { "type": "max", "value": 200 } ] }, "slug": { "kind": "string", "required": true, "nullable": false, "default": null, "reference": null, "checks": [{ "type": "regex", "value": "^[a-z0-9-]+$" }] }, "author": { "kind": "reference", "required": true, "nullable": false, "default": null, "reference": { "targetType": "Author" }, "checks": [] }, "publishedAt": { "kind": "date", "required": true, "nullable": false, "default": null, "reference": null, "checks": [] }, "tags": { "kind": "array", "required": false, "nullable": false, "default": [], "reference": null, "checks": [], "items": { "kind": "string", "checks": [] } } }, "schemaHash": "sha256_abc123def456789" } } ``` **Error cases:** | Error Code | Cause | | ------------------ | -------------------------------------------------------------- | | `SCHEMA_NOT_FOUND` | The requested type name does not exist in the schema registry. | *** ## Sync Schema Push the current schema definition to the server. This is the endpoint called by `mdcms schema sync`. The operation is idempotent -- syncing the same schema twice produces no changes. **Scope required:** `schema:write` ### Request Body The raw configuration object from `mdcms.config.ts`, serialized as JSON. This is stored for audit and debugging purposes. The fully resolved schema with all field metadata, environment overlays applied, and references validated. This is what the server uses for content validation. A deterministic hash of the resolved schema. Used by content write endpoints to verify client-server schema consistency. **Example request:** ```bash theme={null} curl -X PUT "https://cms.example.com/api/v1/schema/sync" \ -H "Content-Type: application/json" \ -H "X-MDCMS-Project: marketing-site" \ -H "X-MDCMS-Environment: production" \ -H "X-MDCMS-CSRF-Token: csrf_token_value" \ -H "Authorization: Bearer mdcms_key_live_abc123" \ -d '{ "rawConfigSnapshot": { "project": "marketing-site", "types": ["Author", "BlogPost"] }, "resolvedSchema": { "types": [ { "name": "Author", "directory": "content/authors", "localized": false, "fields": { "name": { "kind": "string", "required": true, "nullable": false, "default": null, "reference": null, "checks": [{ "type": "min", "value": 1 }] } } }, { "name": "BlogPost", "directory": "content/blog", "localized": true, "fields": { "title": { "kind": "string", "required": true, "nullable": false, "default": null, "reference": null, "checks": [{ "type": "min", "value": 1 }, { "type": "max", "value": 200 }] }, "slug": { "kind": "string", "required": true, "nullable": false, "default": null, "reference": null, "checks": [{ "type": "regex", "value": "^[a-z0-9-]+$" }] }, "author": { "kind": "reference", "required": true, "nullable": false, "default": null, "reference": { "targetType": "Author" }, "checks": [] }, "publishedAt": { "kind": "date", "required": true, "nullable": false, "default": null, "reference": null, "checks": [] }, "tags": { "kind": "array", "required": false, "nullable": false, "default": [], "reference": null, "checks": [], "items": { "kind": "string", "checks": [] } } } } ] }, "schemaHash": "sha256_abc123def456789" }' ``` **Response:** ```json theme={null} { "data": { "synced": true, "schemaHash": "sha256_abc123def456789", "typesCount": 2, "types": ["Author", "BlogPost"], "syncedAt": "2026-01-20T10:00:00.000Z", "syncedBy": "880e8400-e29b-41d4-a716-446655440002" } } ``` In practice, you should never call this endpoint directly. Use `mdcms schema sync` from the CLI, which reads your `mdcms.config.ts`, resolves the schema, computes the hash, and sends the request. Schema sync does not perform destructive migrations. If you remove a field from the schema, existing documents that contain that field are not modified. The field simply stops being validated and stops appearing in Studio forms. # SDK Source: https://docs.mdcms.ai/api-reference/sdk TypeScript client SDK for consuming the MDCMS Content API The `@mdcms/sdk` package provides a type-safe TypeScript client for querying content from the MDCMS API. It handles authentication headers, response parsing, pagination, and error handling. ## Installation ```bash npm theme={null} npm install @mdcms/sdk ``` ```bash pnpm theme={null} pnpm add @mdcms/sdk ``` ```bash yarn theme={null} yarn add @mdcms/sdk ``` ```bash bun theme={null} bun add @mdcms/sdk ``` ## Client Setup Create a client instance with your server URL, API key, project, and environment: ```typescript theme={null} import { createClient } from "@mdcms/sdk"; const client = createClient({ serverUrl: "https://cms.example.com", apiKey: process.env.MDCMS_API_KEY!, project: "marketing-site", environment: "production", }); ``` ### Configuration Options | Option | Type | Required | Description | | ------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `serverUrl` | string | Yes | The base URL of your MDCMS server (e.g., `https://cms.example.com`). | | `apiKey` | string | Yes | API key with the appropriate scopes (prefixed with `mdcms_key_`). | | `project` | string | Yes | Project slug. Sets the `X-MDCMS-Project` header on every request. | | `environment` | string | Yes | Environment name. Sets the `X-MDCMS-Environment` header on every request. | | `fetch` | function | No | Custom `fetch` implementation. Useful for testing, caching, or request instrumentation. Defaults to the global `fetch`. | *** ## Querying -- get() The `get()` method retrieves a single document by ID, slug, or path. It returns the full `ContentDocumentResponse` object. ### By ID ```typescript theme={null} const post = await client.get("BlogPost", { id: "550e8400-e29b-41d4-a716-446655440000", }); ``` ### By Slug ```typescript theme={null} const post = await client.get("BlogPost", { slug: "hello-world", }); ``` ### With Locale and References ```typescript theme={null} const post = await client.get("BlogPost", { slug: "hello-world", locale: "fr", resolve: ["author"], draft: false, }); ``` ### get() Options | Option | Type | Description | | --------- | --------- | ----------------------------------------------------------------------- | | `id` | string | Document UUID. Mutually exclusive with `slug` and `path`. | | `slug` | string | Slug frontmatter value. Mutually exclusive with `id` and `path`. | | `path` | string | Document path. Mutually exclusive with `id` and `slug`. | | `locale` | string | BCP 47 locale tag. | | `resolve` | string\[] | Reference field names to resolve (one level deep). | | `draft` | boolean | When `true`, return the draft version instead of the published version. | When using `slug` or `path`, the query must match exactly one document. If multiple documents match (e.g., same slug in different locales without specifying `locale`), the SDK throws an `MdcmsClientError` with code `AMBIGUOUS_RESULT`. *** ## Querying -- list() The `list()` method retrieves a paginated list of documents matching the given filters. ```typescript theme={null} const posts = await client.list("BlogPost", { locale: "en", published: true, sort: "updatedAt", order: "desc", limit: 10, }); // Access results for (const post of posts.data) { console.log(post.frontmatter.title); } // Access pagination metadata console.log(posts.pagination.total); // 42 console.log(posts.pagination.hasMore); // true ``` ### list() Options | Option | Type | Description | | ----------------------- | --------- | -------------------------------------------------- | | `locale` | string | BCP 47 locale tag. | | `published` | boolean | Filter by publication status. | | `isDeleted` | boolean | Include soft-deleted documents. | | `hasUnpublishedChanges` | boolean | Filter to documents with pending draft changes. | | `draft` | boolean | Return draft content instead of published content. | | `resolve` | string\[] | Reference field names to resolve. | | `limit` | number | Results per page (default 20, max 100). | | `offset` | number | Number of results to skip. | | `sort` | string | Sort field: `createdAt`, `updatedAt`, or `path`. | | `order` | string | Sort direction: `asc` or `desc`. | ### Return Type ```typescript theme={null} { data: ContentDocumentResponse[]; pagination: { total: number; limit: number; offset: number; hasMore: boolean; }; } ``` *** ## Response Type Both `get()` and `list()` return `ContentDocumentResponse` objects with the following fields: | Field | Type | Description | | ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------ | | `documentId` | string | Stable UUID identifier. | | `translationGroupId` | string | UUID linking locale variants of the same content. | | `project` | string | Project slug. | | `environment` | string | Environment name. | | `path` | string | Filesystem-like path (e.g., `content/blog/hello-world`). | | `type` | string | Content type name (e.g., `BlogPost`). | | `locale` | string | BCP 47 locale tag, or `__mdcms_default__` for non-localized types. | | `format` | string | `md` or `mdx`. | | `isDeleted` | boolean | Whether the document is soft-deleted. | | `hasUnpublishedChanges` | boolean | Whether the draft differs from the published version. | | `version` | number | Current version number (incremented on each publish). | | `publishedVersion` | number \| null | Version number of the published snapshot, or `null` if never published. | | `draftRevision` | number | Incrementing counter for draft saves. Used for optimistic concurrency. | | `frontmatter` | object | Structured data matching the content type schema. References are UUIDs unless resolved. | | `body` | string | Markdown or MDX content. | | `resolveErrors` | object | Map of field paths to [resolve error](/api-reference/errors#resolve-errors) objects. Empty when no resolve errors. | | `createdBy` | string | User or API key ID that created the document. | | `createdAt` | string | ISO 8601 creation timestamp. | | `updatedBy` | string | User or API key ID that last updated the document. | | `updatedAt` | string | ISO 8601 last-update timestamp. | *** ## Error Handling The SDK provides two error classes for distinguishing between server errors and client-side issues: ```typescript theme={null} import { MdcmsApiError, MdcmsClientError } from "@mdcms/sdk"; try { const post = await client.get("BlogPost", { slug: "missing" }); } catch (error) { if (error instanceof MdcmsApiError) { // Server returned an error response console.error(error.statusCode); // 404 console.error(error.code); // "NOT_FOUND" console.error(error.message); // "Document not found" console.error(error.requestId); // "req_abc123" console.error(error.timestamp); // "2026-01-15T09:30:00.000Z" console.error(error.details); // {} } if (error instanceof MdcmsClientError) { // Client-side error (network, parsing, ambiguity) console.error(error.code); // e.g., "NOT_FOUND" console.error(error.message); // Human-readable description } } ``` ### MdcmsApiError Thrown when the server returns a non-2xx response. Properties: | Property | Type | Description | | ------------ | ------ | ------------------------------------------------------------------------ | | `statusCode` | number | HTTP status code (e.g., 404, 409, 500). | | `code` | string | Machine-readable error code (e.g., `NOT_FOUND`, `SCHEMA_HASH_MISMATCH`). | | `message` | string | Human-readable error description. | | `requestId` | string | Server request ID for debugging. | | `timestamp` | string | ISO 8601 error timestamp. | | `details` | object | Additional context specific to the error. | ### MdcmsClientError Thrown for client-side issues that prevent a valid response. Error codes: | Code | Description | | ----------------------- | ---------------------------------------------------------------------------------------------------- | | `INVALID_RESPONSE` | The server returned a response that could not be parsed as valid JSON. | | `NETWORK_ERROR` | The request failed due to a network issue (DNS failure, connection refused, timeout). | | `NOT_FOUND` | A `get()` call matched zero documents. | | `AMBIGUOUS_RESULT` | A `get()` call by slug or path matched multiple documents. Add a `locale` parameter to disambiguate. | | `PREVIEW_TOKEN_INVALID` | A preview request did not include a valid MDCMS preview token. | *** ## Next.js Integration The SDK is designed to work seamlessly with Next.js App Router for both static generation and server-side rendering. ### Client Setup Create a shared client instance: ```typescript theme={null} // lib/mdcms.ts import { createClient } from "@mdcms/sdk"; export const cms = createClient({ serverUrl: process.env.MDCMS_SERVER_URL!, apiKey: process.env.MDCMS_API_KEY!, project: process.env.MDCMS_PROJECT!, environment: process.env.MDCMS_ENVIRONMENT!, }); ``` ### Blog Page Example ```typescript theme={null} // app/blog/[slug]/page.tsx import { cms } from "@/lib/mdcms"; import { notFound } from "next/navigation"; import { MdcmsApiError } from "@mdcms/sdk"; export async function generateStaticParams() { const posts = await cms.list("BlogPost", { published: true }); return posts.data.map((post) => ({ slug: post.frontmatter.slug as string, })); } export default async function BlogPost({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; try { const post = await cms.get("BlogPost", { slug, resolve: ["author"], }); return (

{post.frontmatter.title as string}

{post.body}
); } catch (error) { if (error instanceof MdcmsApiError && error.statusCode === 404) { notFound(); } throw error; } } ``` ### Blog Index Example ```typescript theme={null} // app/blog/page.tsx import { cms } from "@/lib/mdcms"; import Link from "next/link"; export default async function BlogIndex() { const posts = await cms.list("BlogPost", { published: true, sort: "updatedAt", order: "desc", limit: 20, }); return (

Blog

); } ``` *** ## Draft Preview Studio mints a short-lived preview token before loading a configured host preview route. The SDK can verify that token and fetch the matching draft document in one call: ```typescript theme={null} // app/preview/[...path]/route.ts import { cms } from "@/lib/mdcms"; import { MdcmsClientError } from "@mdcms/sdk"; export const dynamic = "force-dynamic"; export async function GET(request: Request) { try { const document = await cms.getPreviewDocumentFromRequest(request, { secret: process.env.MDCMS_PREVIEW_TOKEN_SECRET!, resolve: ["author"], }); return Response.json(document, { headers: { "Cache-Control": "private, no-store", }, }); } catch (error) { if ( error instanceof MdcmsClientError && error.code === "PREVIEW_TOKEN_INVALID" ) { return new Response("Preview unavailable", { status: 401 }); } throw error; } } ``` For lower-level control, verify the request first and then choose your own draft fetch: ```typescript theme={null} import { verifyMdcmsPreviewRequest } from "@mdcms/sdk"; const preview = await verifyMdcmsPreviewRequest(request, { secret: process.env.MDCMS_PREVIEW_TOKEN_SECRET!, }); if (!preview.ok) { return new Response("Preview unavailable", { status: 401 }); } const document = await cms.get(preview.claims.type, { id: preview.claims.documentId, locale: preview.claims.locale, draft: true, }); ``` When the preview route renders inside Studio's iframe, post `{ type: "mdcms:live-preview-ready" }` from client-side code after the page is ready. Studio keeps the pane in a loading state until that message arrives and shows the fallback link if the route never signals readiness. The server-side API key used for private preview routes needs `content:read:draft`. Keep that API key and `MDCMS_PREVIEW_TOKEN_SECRET` in server-only environment variables, force dynamic rendering, and send `Cache-Control: private, no-store` or the framework equivalent. Some projects intentionally expose drafts on public or separately protected preview deployments. MDCMS allows that pattern. If unpublished content must stay private, do not treat `?preview=true` alone as authorization; require a valid MDCMS preview token, a host session, or another server-side gate before fetching with `draft: true`. # Data Model Source: https://docs.mdcms.ai/architecture/data-model Database schema, content lifecycle, and localization architecture MDCMS stores all content, schema metadata, authentication state, and access control grants in PostgreSQL. This page documents the complete data model, the content lifecycle state machine, and the localization architecture. ## Entity-Relationship Diagram The following diagram shows all tables and their primary relationships. ```mermaid theme={null} erDiagram authUsers ||--o{ authSessions : "has" authUsers ||--o{ authAccounts : "has" authUsers ||--o{ rbacGrants : "granted to" authUsers ||--o{ apiKeys : "created by" authUsers ||--o{ cliLoginChallenges : "authorizes" authUsers ||--o{ invites : "created by" projects ||--o{ environments : "contains" projects ||--o{ schemaRegistryEntries : "defines" projects ||--o{ schemaSyncs : "tracks" projects ||--o{ documents : "owns" projects ||--o{ documentVersions : "owns" projects ||--o{ media : "stores" projects ||--o{ migrations : "records" environments ||--o{ schemaRegistryEntries : "scopes" environments ||--o{ schemaSyncs : "scopes" environments ||--o{ documents : "scopes" environments ||--o{ documentVersions : "scopes" environments ||--o{ migrations : "scopes" documents ||--o{ documentVersions : "publishes to" ``` ## Core Tables The database schema is organized into four domains: authentication, authorization, project/environment management, and content. ### Authentication | Table | Description | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `authUsers` (users) | User accounts with name, email, email verification status, and avatar. | | `authSessions` (sessions) | Active login sessions with token, expiry, IP address, and user agent. Cascade-deleted when the user is removed. | | `authAccounts` (accounts) | OAuth/OIDC provider links (provider id, access/refresh/id tokens, scopes, or password hash for credential accounts). | | `authVerifications` (verifications) | Time-limited verification tokens for email confirmation and password reset flows. | | `authLoginBackoffs` (auth\_login\_backoffs) | Progressive login rate limiting. Tracks failure count, timestamps, and next-allowed-at per login key. | | `cliLoginChallenges` (cli\_login\_challenges) | Device-flow login challenges for the CLI. Status transitions: `pending` -> `authorized` -> `exchanged`. 10-minute TTL. | ### Authorization | Table | Description | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rbacGrants` (rbac\_grants) | Role-based access grants. Each grant binds a user to a role (`owner`, `admin`, `editor`, `viewer`) at a scope (`global`, `project`, or `folder_prefix`). Supports soft revocation via `revokedAt`. | | `apiKeys` (api\_keys) | API key credentials. Stores a key prefix (for display), a hash (for lookup), an array of operation scopes, and a `contextAllowlist` restricting which project/environment pairs the key can target. | | `invites` (invites) | Pending user invitations with pre-defined RBAC grants, token hash, expiry, and acceptance/revocation tracking. | ### Project and Environment | Table | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `projects` | Top-level tenants identified by slug. Each project owns all subordinate resources. | | `environments` | Named content spaces within a project (e.g., `production`, `staging`). Unique per project. | | `schemaSyncs` | Tracks the last schema synchronization per project/environment. Stores the schema hash and a raw config snapshot. | | `schemaRegistryEntries` | Per-type schema records. Each entry stores the schema type name, directory, localization flag, schema hash, and the resolved JSON Schema. Unique per project/environment/type. | ### Content | Table | Description | | ------------------ | ----------------------------------------------------------------------------------------------------- | | `documents` | The mutable head state of every content document. This is the working copy. | | `documentVersions` | Immutable publish snapshots. Each row captures the full document state at the moment of publication. | | `media` | File metadata for uploaded assets (filename, MIME type, size, S3 key, public URL). Scoped to project. | | `migrations` | Records of applied content migrations (name, schema type, documents affected, applied-by/at). | ## Document Table Columns The `documents` table is the central content table. Each row represents the current draft state of a single document. | Column | Type | Description | | ----------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------- | | `documentId` | UUID (PK) | Stable identifier for the document across its entire lifecycle. | | `translationGroupId` | UUID | Links locale variants of the same logical content. All translations share this ID. | | `projectId` | UUID (FK) | Owning project. | | `environmentId` | UUID (FK) | Owning environment. | | `path` | text | Filesystem-relative path (e.g., `content/blog/hello-world`). Unique per project/environment/locale when not deleted. | | `schemaType` | text | Content type name matching a `schemaRegistryEntries` record. | | `locale` | text | BCP 47 locale code, or `__mdcms_default__` for non-localized types. | | `contentFormat` | text | `md` or `mdx`. Constrained by database check. | | `body` | text | The Markdown/MDX body content. | | `frontmatter` | JSONB | Structured metadata fields defined by the schema type. | | `isDeleted` | boolean | Soft-delete flag. Deleted documents are excluded from all active queries via partial indexes. | | `hasUnpublishedChanges` | boolean | `true` when the draft diverges from the latest published version. | | `publishedVersion` | integer (FK) | Points to the latest `documentVersions.version` for this document, or `null` if never published. | | `draftRevision` | bigint | Monotonically increasing revision counter for optimistic concurrency control. | | `createdBy` | UUID | User who created the document. | | `createdAt` | timestamptz | Creation timestamp. | | `updatedBy` | UUID | User who last modified the document. | | `updatedAt` | timestamptz | Last modification timestamp. | ## Content Lifecycle MDCMS uses a two-table model for content management. The `documents` table holds the mutable head (working copy), while `documentVersions` holds immutable publish snapshots. This separation enables draft editing without affecting published content. ### State Machine ```mermaid theme={null} stateDiagram-v2 [*] --> Draft: Create Draft --> Published: Publish Published --> Editing: Edit draft Editing --> Published: Re-publish Published --> SoftDeleted: Delete Editing --> SoftDeleted: Delete Draft --> SoftDeleted: Delete SoftDeleted --> Draft: Restore ``` ### State Transitions | Transition | What changes | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Create** | New row in `documents` with `hasUnpublishedChanges = true`, `publishedVersion = null`, `draftRevision = 1`. | | **Edit draft** | `documents` row updated: `body`, `frontmatter`, `updatedBy`, `updatedAt` change. `draftRevision` increments. `hasUnpublishedChanges` set to `true`. | | **Publish** | New row in `documentVersions` capturing the full document state. `documents.publishedVersion` updated to new version number. `hasUnpublishedChanges` set to `false`. | | **Re-publish** | Same as Publish -- a new immutable version row is created. | | **Delete** | `documents.isDeleted` set to `true`. The document is excluded from all active-scoped indexes. No data is removed. | | **Restore** | `documents.isDeleted` set to `false`. The document re-enters the Draft state with `hasUnpublishedChanges = true`. | Published versions are **never modified or deleted**. The `documentVersions` table is append-only, providing a complete audit trail of every publication. ### Concurrency Control MDCMS uses optimistic concurrency via the `draftRevision` column. Every write request must include the current `draftRevision` value. The server atomically increments the revision on update and rejects any request whose revision does not match the current database value. ``` Client reads document (draftRevision: 5) Client sends update with draftRevision: 5 Server: WHERE draftRevision = 5 → UPDATE SET draftRevision = 6 ✓ Stale client sends update with draftRevision: 4 Server: WHERE draftRevision = 4 → 0 rows affected → 409 Conflict ✗ ``` The Studio editor automatically handles revision tracking. If you are building a custom integration, always read the current `draftRevision` before writing and include it in update requests. ## Localization Architecture MDCMS implements localization through translation groups -- a logical grouping of locale-specific document variants that represent the same content in different languages. ### Translation Groups The `translationGroupId` column links all locale variants of a document. When you create a translation, a new `documents` row is created with: * The same `translationGroupId` as the source document * A different `locale` value * Independent `body`, `frontmatter`, `publishedVersion`, and lifecycle state Each translation is a fully independent document. Publishing the English variant does not affect the French variant. Deleting one translation does not delete others. ### Default Locale Content types that are not localized (no `localized: true` in the schema definition) use the sentinel locale value `__mdcms_default__`. This ensures a consistent data model regardless of whether a type supports multiple languages. ### Locale Normalization MDCMS normalizes all locale values to BCP 47 format (e.g., `en-US`, `fr-FR`, `ja`). The normalization is applied at write time, ensuring consistent locale handling across the API, CLI, and Studio. ### CLI File Mapping The CLI maps documents to filesystem paths based on locale: | Type | File pattern | Example | | ------------- | ----------------------- | ------------------------------ | | Localized | `..` | `content/blog/hello.en-US.mdx` | | Non-localized | `.` | `content/authors/jane.md` | The `supportedLocales` and `defaultLocale` fields in the Studio mount context control which locales appear in the locale picker and which locale is selected by default when creating new documents. # Migrations Source: https://docs.mdcms.ai/architecture/migrations Schema evolution and content migration strategies As your content model evolves, some changes require explicit migration steps. This page explains when migrations are needed, how the database schema is managed, and how content migrations transform existing documents. ## When Migrations Are Needed Not every schema change requires a migration. MDCMS distinguishes between non-breaking changes (handled automatically) and breaking changes (requiring explicit migration). | Change Type | Migration Required? | Reason | | ------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------- | | Adding a new optional field | No | Existing documents remain valid -- the field defaults to `undefined`. | | Adding a new content type | No | No existing documents are affected. `mdcms schema sync` registers the new type. | | Adding a new environment | No | The environment starts empty. | | Renaming a content type | **Yes** | Existing documents reference the old type name. | | Removing a content type that has documents | **Yes** | Documents of that type must be migrated or deleted. | | Changing a field's type (e.g., string to number) | **Yes** | Existing frontmatter values are incompatible with the new type. | | Renaming a field | **Yes** | Existing frontmatter uses the old field name. | | Making an optional field required | **Yes** | Existing documents may have `undefined` for that field. | | Removing a field | **Yes** | The field should be cleaned from existing frontmatter for consistency. | ## Non-Breaking Changes For non-breaking changes, the standard schema sync workflow handles everything automatically: Modify your `mdcms.config.ts` to add the new field, type, or environment. Run `mdcms schema sync`. The server computes the new schema hash and updates the `schemaRegistryEntries` and `schemaSyncs` tables. Existing documents remain valid. New documents will include the updated field definitions in the Studio editor. Schema sync is idempotent. Running it multiple times with the same configuration produces the same result. It is safe to include in CI/CD pipelines. ## Database Migrations Database schema changes (adding tables, columns, or indexes to the PostgreSQL schema) are managed through Drizzle ORM's migration toolkit. Edit `apps/server/src/lib/db/schema.ts` to add, modify, or remove table definitions. Run the Drizzle migration generator: `bash cd apps/server bun run db:generate ` This produces a timestamped SQL migration file in the migrations directory. Always review the generated migration before applying it. Drizzle generates SQL based on the diff between the current schema definition and the last known state. Verify that destructive operations (column drops, type changes) are intentional. Run the migration against the target database: `bash cd apps/server bun run db:migrate ` Always back up the database before applying migrations in production. Drizzle migrations are forward-only -- there is no automatic rollback mechanism. ## Content Migrations Content migrations transform existing documents when the schema changes in a way that is incompatible with stored data. The `mdcms migrate` CLI command handles this process. ### Preview Mode By default, `mdcms migrate` generates a migration plan without applying it: ```bash theme={null} mdcms migrate ``` This analyzes the current schema against stored documents and reports: * Which documents are affected * What transformations will be applied * Whether any documents cannot be automatically migrated ### Apply Mode To execute the migration, add the `--apply` flag: ```bash theme={null} mdcms migrate --apply ``` When applied, the migration: 1. Transforms each affected document's frontmatter according to the migration rules 2. Updates the draft state (`body`, `frontmatter`, `updatedAt`, `draftRevision`) 3. Auto-publishes updated documents that were previously in a published state 4. Records the migration in the `migrations` table (name, schema type, documents affected, who applied it, when) Always back up the database before running content migrations in production. Content migrations modify document frontmatter in place and auto-publish affected documents. While the operation is logged in the `migrations` table, there is no built-in undo mechanism. ## Breaking Change Scenarios The following scenarios require explicit content migrations. ### Type Rename When renaming a content type (e.g., `Post` to `BlogPost`): 1. Add the new type to `mdcms.config.ts` 2. Run `mdcms schema sync` to register the new type 3. Run `mdcms migrate --apply` to update all documents from the old type to the new type 4. Remove the old type from the config 5. Run `mdcms schema sync` again to deregister the old type ### Field Type Change When changing a field's type (e.g., `tags: z.string()` to `tags: z.array(z.string())`): 1. Update the field definition in `mdcms.config.ts` 2. Run `mdcms schema sync` 3. Run `mdcms migrate` to preview the transformation (e.g., wrapping string values in arrays) 4. Run `mdcms migrate --apply` to execute ### Field Removal When removing a field from a content type: 1. Remove the field from `mdcms.config.ts` 2. Run `mdcms schema sync` 3. Run `mdcms migrate --apply` to clean the removed field from existing document frontmatter Field removal migrations are optional but recommended. Documents with extra frontmatter fields will not cause errors, but removing stale data keeps the content store clean and reduces confusion in API responses. ## Migration Records Every applied migration is recorded in the `migrations` table: | Column | Description | | ------------------- | ------------------------------- | | `id` | UUID primary key | | `name` | Human-readable migration name | | `projectId` | Target project | | `environmentId` | Target environment | | `schemaType` | Content type affected | | `appliedAt` | Timestamp of execution | | `appliedBy` | User who ran the migration | | `documentsAffected` | Number of documents transformed | This table provides an audit trail for all content transformations and can be queried to verify migration history. # Module System Source: https://docs.mdcms.ai/architecture/module-system How modules extend server, CLI, and Studio functionality MDCMS is built on a module system that organizes all server-side functionality into discrete, composable packages. Modules extend three surfaces -- the HTTP server, the CLI, and the Studio UI -- through a single, validated contract. ## Module Concept A module is a self-contained unit that can contribute to one or more runtime surfaces: | Surface | What it provides | | ---------- | ------------------------------------------------------------------------------------------------------- | | **Server** | HTTP route handlers mounted on the Elysia app, plus action catalog entries for discoverable operations. | | **CLI** | Action aliases, output formatters, and preflight hooks that extend the `mdcms` command. | | **Studio** | UI extension points including routes, navigation items, slot widgets, field kinds, and editor nodes. | Modules declare a manifest with metadata (id, version, API version, dependencies) and one or more surface implementations. ## Module Package Type Every module conforms to the `MdcmsModulePackage` type exported from `@mdcms/shared`: ```typescript theme={null} type MdcmsModulePackage = { manifest: ModuleManifest; server?: ServerSurface; cli?: CliSurface; }; type ModuleManifest = { id: string; version: string; apiVersion: "1"; kind?: "domain" | "core"; dependsOn?: string[]; minCoreVersion?: string; maxCoreVersion?: string; }; type ServerSurface = { mount: (app: App, deps: AppDeps) => void; actions?: ActionCatalogItem[]; }; type CliSurface = { actionAliases?: CliActionAlias[]; outputFormatters?: CliOutputFormatter[]; preflightHooks?: CliPreflightHook[]; }; ``` The `manifest.kind` field distinguishes infrastructure modules (`core`) from business-domain modules (`domain`). The `dependsOn` array declares inter-module dependencies that the loader validates at boot time. ## Loading Lifecycle Modules are registered at compile time in `packages/modules/src/index.ts` and loaded during server startup through a three-phase process: `buildServerModuleLoadReport()` validates every registered module package against the Zod schema, checks manifest compatibility (API version, core version range), resolves the dependency graph, and produces a `ServerModuleLoadReport`. `loadServerModules()` reads from the `installedModules` compile-time registry and delegates to the build step. If any module fails validation or has unresolvable dependencies, the server refuses to start. `mountLoadedServerModules()` iterates the loaded modules in dependency order and calls each module's `server.mount()`, passing the Elysia app instance and shared dependencies (database DAL, auth service, etc.). ```typescript theme={null} // apps/server/src/lib/module-loader.ts (simplified) const report = loadServerModules({ coreVersion: env.APP_VERSION, logger }); const actions = collectServerModuleActions(report); mountLoadedServerModules(app, deps, report); ``` ## Core Modules MDCMS ships with two first-party modules: **Kind:** `core` Provides the foundational infrastructure surface: health checks (`/healthz`), Studio bootstrap endpoint, authentication routes, user management, API key management, RBAC grant management, and invite flows. **Kind:** `domain` Provides the content domain surface: document CRUD, schema registry sync, media upload and management, webhook dispatch, environment cloning/promotion, search, and content migration execution. Both modules implement `server` and `cli` surfaces. The module registry in `packages/modules/src/index.ts` sorts them deterministically by manifest id before exposing them to the loader. ## Action Catalog Every server-side operation is registered as an **action** -- a discoverable, self-describing API endpoint. Actions are collected from all loaded modules and exposed through a unified catalog. ```typescript theme={null} type ActionCatalogItem = { id: string; // e.g. "content.documents.list" kind: "command" | "query"; // write vs read method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; path: string; // e.g. "/api/v1/content" permissions: string[]; // required scopes studio?: StudioActionMeta; // visibility, surface, label, form hints cli?: CliActionMeta; // visibility, alias, input mode requestSchema?: JsonSchema; // JSON Schema for request body responseSchema?: JsonSchema; // JSON Schema for response body }; ``` The catalog is queryable at runtime: * **`GET /api/v1/actions`** -- Returns all actions visible to the current principal. * **`GET /api/v1/actions/:id`** -- Returns a single action by id. Actions support visibility policies that filter the catalog per-request based on the caller's authentication context and permissions. The CLI uses the action catalog to dynamically discover available commands. Run `mdcms actions` to see all actions available for your current credentials. ## Studio Extensibility Surfaces The Studio component exposes several extension points that modules can populate: | Surface | Description | | ----------------- | --------------------------------------------------------------------- | | `routes` | Additional React Router routes mounted inside the Studio shell. | | `navItems` | Navigation entries added to the Studio sidebar. | | `slotWidgets` | Widgets rendered in named layout slots across Studio pages. | | `fieldKinds` | Custom form field renderers for schema-driven frontmatter editing. | | `editorNodes` | Custom TipTap/ProseMirror nodes for the MDX editor. | | `actionOverrides` | Replace or wrap default action behaviors (e.g., custom publish flow). | | `settingsPanels` | Additional panels on the Studio settings page. | Standard slot IDs used across Studio pages: | Slot ID | Location | | ------------------------------- | --------------------------------------------- | | `dashboard.main` | Main content area of the dashboard page. | | `content.list.toolbar` | Toolbar above the content list table. | | `content.editor.header.actions` | Action buttons in the document editor header. | | `content.editor.sidebar` | Sidebar panels in the document editor. | | `settings.general` | General settings section. | In v1, Studio extensibility surfaces are defined but only consumed by first-party modules. The contract is stable and documented for future third-party use. ## v1 Limitation The current module system supports **first-party modules only**. All modules are compiled into the server binary at build time via the `packages/modules/` registry. There is no dynamic plugin loading, no remote module resolution, and no third-party module marketplace. The module contract (`MdcmsModulePackage`, `ModuleManifest`, compatibility checks) is designed with future extensibility in mind, but v1 restricts the surface to modules maintained within the MDCMS monorepo. ## Multi-Tenancy Model MDCMS implements a two-level isolation hierarchy: **projects** and **environments**. ### Project Isolation A project is the top-level tenant boundary. Each project owns: * Its own **schema** (content types and field definitions) * Its own **content** (documents and versions) * Its own **environments** (independent content spaces) * Its own **media** (uploaded files in S3) * Its own **webhooks** (event subscriptions) * Its own **users and API keys** (scoped access control) Projects are identified by slug (e.g., `marketing-site`) and are completely isolated from one another at the database level. ### Environment Isolation Within a project, each environment maintains: * **Independent documents** -- Editing content in `staging` does not affect `production`. * **Independent versions** -- Publish history is per-environment. * **Schema overlays** -- Environments that extend another inherit the base schema and can add or modify fields. ### Target Routing Every scoped API request must include explicit targeting headers: ``` X-MDCMS-Project: marketing-site X-MDCMS-Environment: production ``` The server's target routing guard validates these headers before the request reaches any route handler. Routes that require project+environment context (content, schema, media, webhooks, search) reject requests missing either header with a `400` error. Routes that require only project context (environments API) require `X-MDCMS-Project` alone. API keys include a `contextAllowlist` that restricts which project/environment pairs they can access. A key scoped to `production` cannot be used to read `staging` content, even if it has `content:read` permission. # System Overview Source: https://docs.mdcms.ai/architecture/overview Architecture, deployment topology, technology stack, and design principles MDCMS is an open-source headless CMS that treats a PostgreSQL database as the source of truth for structured Markdown and MDX content. This page describes the design principles, deployment topology, technology choices, and monorepo structure that underpin the system. ## Design Principles MDCMS follows a small set of architectural invariants that inform every subsystem. | Principle | What it means | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Database is source of truth** | Content lives in PostgreSQL, not the filesystem. The CLI syncs files to and from the database via `mdcms push` / `mdcms pull`. | | **Schema-driven UI** | Content types are defined in TypeScript with Zod. The schema drives form generation, validation, API behavior, and Studio rendering automatically. | | **Explicit target routing** | Every API request carries a project and environment context via `X-MDCMS-Project` and `X-MDCMS-Environment` headers. There is no implicit "current" tenant. | | **Code-first configuration** | All configuration lives in `mdcms.config.ts` -- a TypeScript file that is type-checked and version-controlled alongside application code. | | **CQRS-lite** | Writes flow through command handlers that enforce validation, authorization, and versioning. Reads flow through query handlers that project data for specific consumers. The same database backs both paths. | ## Deployment Topology A production MDCMS deployment consists of the server process, a PostgreSQL database, a Redis instance for session caching and rate limiting, and S3-compatible object storage for media assets. Clients interact through the TypeScript SDK or the embedded Studio component. ```mermaid theme={null} graph TB Browser["Browser / App"] SDK["@mdcms/sdk"] StudioComp["@mdcms/studio"] Server["MDCMS Server
(Elysia / Bun)"] PG["PostgreSQL"] Redis["Redis"] S3["S3 / MinIO"] Browser --> SDK Browser --> StudioComp SDK --> Server StudioComp --> Server Server --> PG Server --> Redis Server --> S3 ``` In local development, `docker-compose.dev.yml` starts PostgreSQL, Redis, and MinIO automatically. The server, Studio, and studio-example app run via `bun run dev`. ## Technology Stack Every technology choice has a specific justification. The table below documents the role and rationale for each dependency. | Technology | Version | Role | Why | | ------------------ | ------- | ---------------------- | --------------------------------------------------------------------------------------- | | **Bun** | 1.3+ | Runtime | Fast startup, native TypeScript execution, built-in test runner. | | **TypeScript** | 5.9 | Language | End-to-end type safety across server, CLI, SDK, and Studio. | | **Nx** | 22.5 | Monorepo orchestration | Task caching, dependency graph, parallel builds. | | **Elysia** | 1.4 | HTTP framework | End-to-end type safety with Bun-native performance. | | **PostgreSQL** | 15+ | Primary database | JSONB for frontmatter, full-text search, append-only versioning via `documentVersions`. | | **Drizzle ORM** | 0.45 | Database access | Schema-as-code migrations, type-safe query builder. | | **better-auth** | 1.5 | Authentication | Session management, OIDC/SAML SSO, CSRF protection. | | **Redis** | 7 | Cache / rate limiting | Session cache, login backoff tracking. | | **S3 (MinIO dev)** | -- | Media storage | Stores uploaded images and files. MinIO provides local S3-compatible development. | | **React** | 19 | Studio UI | Component model for the embeddable CMS editor. | | **Next.js** | 15.2 | Studio host | Server-rendered Studio example app and review environment. | | **TailwindCSS** | 4.2 | Styling | Utility-first CSS for consistent Studio design. | | **TipTap** | 3.7 | Rich text editor | Extensible, ProseMirror-based MDX editing with custom node support. | | **Radix UI** | -- | Accessible primitives | Headless UI components for dialogs, menus, popovers, and form controls. | | **Zod** | 4.3 | Validation | Schema definition with Standard Schema interface for cross-boundary validation. | ## Monorepo Structure MDCMS is an Nx-managed Bun workspace. The repository is organized into `apps/` (deployable applications) and `packages/` (shared libraries). ``` mdcms/ ├── apps/ │ ├── server/ # Elysia HTTP server (the CMS API) │ ├── cli/ # `mdcms` CLI tool for push/pull/schema sync/migrate │ ├── studio-example/ # Next.js reference app embedding @mdcms/studio │ └── docs/ # Mintlify documentation site (you are here) ├── packages/ │ ├── shared/ # Runtime utilities, contracts, error types, validation │ ├── sdk/ # TypeScript SDK for querying the MDCMS API │ ├── studio/ # Embeddable React component (TipTap editor, forms, UI) │ └── modules/ # Module registry and first-party module implementations ├── scripts/ # CI/CD helpers and integration test runners ├── docker-compose.yml # Production-like compose configuration ├── docker-compose.dev.yml # Local development services (PG, Redis, MinIO) ├── nx.json # Nx workspace configuration └── package.json # Root workspace definition ``` ## Package Dependency Graph The packages and apps form a directed dependency graph. Core contracts flow from `shared` outward; higher-level packages never depend on apps. ```mermaid theme={null} graph LR shared["@mdcms/shared"] sdk["@mdcms/sdk"] studio["@mdcms/studio"] modules["@mdcms/modules"] server["apps/server"] cli["apps/cli"] studioExample["apps/studio-example"] sdk --> shared studio --> shared studio --> sdk modules --> shared server --> shared server --> modules cli --> shared cli --> modules cli --> sdk studioExample --> studio studioExample --> sdk ``` Run `bun nx graph` at the repository root to see the live, interactive dependency graph in your browser. # Request Flow Source: https://docs.mdcms.ai/architecture/request-flow HTTP middleware chain, authentication, authorization, and permission model Every HTTP request to the MDCMS server passes through a layered middleware chain before reaching a route handler. This page documents the full request flow, authentication mechanisms, RBAC model, API key scopes, and response envelope format. ## Middleware Chain The server processes each request through the following stages, in order. ```mermaid theme={null} sequenceDiagram participant Client participant CORS participant Logging participant CSRF participant Auth participant Target participant Authz participant Handler participant Envelope Client->>CORS: Request CORS->>CORS: Validate origin, handle preflight CORS->>Logging: Pass through Logging->>Logging: Record request metadata Logging->>CSRF: Pass through CSRF->>CSRF: Verify CSRF token (mutations) CSRF->>Auth: Pass through Auth->>Auth: Identify principal (API key or session) Auth->>Target: Pass through Target->>Target: Validate project + environment headers Target->>Authz: Pass through Authz->>Authz: Check permissions against RBAC grants Authz->>Handler: Pass through Handler->>Handler: Execute business logic Handler->>Envelope: Return result Envelope->>Client: Standard JSON response ``` | Stage | Responsibility | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **1. CORS** | Validates the `Origin` header against `MDCMS_STUDIO_ALLOWED_ORIGINS`. Handles `OPTIONS` preflight requests for Studio browser routes. Rejects disallowed origins with `403 FORBIDDEN_ORIGIN`. | | **2. Request Logging** | Captures request metadata (method, URL, timing) for observability. | | **3. CSRF** | For cookie-authenticated mutations, verifies the `X-MDCMS-CSRF-Token` header matches the `mdcms_csrf` cookie. API key requests bypass CSRF since they use `Authorization` headers. | | **4. Authentication** | Identifies the calling principal -- either an API key (via `Authorization: Bearer mdcms_key_...`) or a session (via cookie). | | **5. Target Routing** | Validates that scoped routes include the required `X-MDCMS-Project` and/or `X-MDCMS-Environment` headers. Returns `400` if headers are missing. | | **6. Authorization** | Checks the authenticated principal's permissions against the requirements of the target route (scope, role, resource path). | | **7. Route Handler** | Executes the business logic (content CRUD, schema sync, media upload, etc.). | | **8. Response Envelope** | Wraps the result in a standard JSON response format with appropriate status codes and CORS headers. | ## Authentication Flows MDCMS supports three authentication mechanisms, each designed for a different client context. ### API Key Authentication API keys are intended for server-to-server communication, CI/CD pipelines, and SDK usage. ``` Authorization: Bearer mdcms_key_abc123def456... ``` * Keys use a `mdcms_key_` prefix for easy identification in logs. * The key value is hashed (SHA-256) before storage -- the raw key is only shown once at creation time. * Each key carries an array of **operation scopes** (e.g., `content:read`, `schema:write`) and a **context allowlist** restricting which project/environment pairs it can access. * Keys support optional expiration via `expiresAt` and soft revocation via `revokedAt`. ### Session Authentication Sessions are used by the Studio UI and browser-based interactions. * Created via better-auth after successful password, OIDC, or SAML authentication. * Stored in the `sessions` table with a session token cookie. * **Inactivity timeout:** 7 days of no activity expires the session. * **Absolute max age:** 30 days regardless of activity. * **CSRF protection:** Mutations require a `X-MDCMS-CSRF-Token` header matching the `mdcms_csrf` cookie (24-byte random token). * Supported identity providers: password (credential), OIDC (Okta, Azure AD, Google Workspace, Auth0), SAML. ### CLI Device Flow The CLI authenticates via a device-authorization-like flow: The CLI creates a login challenge with a 10-minute TTL, specifying the target project, environment, redirect URI, and requested scopes. The user opens a browser URL, authenticates via session (password/OIDC/SAML), and authorizes the CLI challenge. The challenge status transitions from `pending` to `authorized`. The CLI polls for the authorization code, exchanges it for a scoped API key, and stores the credentials locally. The challenge status transitions from `authorized` to `exchanged`. Default CLI login scopes: `content:read`, `content:read:draft`, `content:write`, `content:delete`, `schema:read`, `schema:write`. ## RBAC Model MDCMS implements role-based access control with hierarchical scoping. ### Roles Roles form an ordered hierarchy. Higher roles inherit all capabilities of lower roles. | Role | Level | Scope Restriction | | -------- | ----- | --------------------------------- | | `viewer` | 0 | Global, project, or folder prefix | | `editor` | 1 | Global, project, or folder prefix | | `admin` | 2 | Global only | | `owner` | 3 | Global only | `admin` and `owner` roles are restricted to global scope by a database check constraint. They cannot be scoped to a specific project or folder prefix. ### Scopes Each RBAC grant is bound to a scope that determines where the role applies: | Scope Kind | Fields Required | Meaning | | --------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `global` | None | Applies to all projects and environments. | | `project` | `project` | Applies to all environments within a specific project. | | `folder_prefix` | `project`, `environment`, `pathPrefix` | Applies only to documents whose path starts with the given prefix within a specific project and environment. | ### Role Capability Matrix | Capability | `viewer` | `editor` | `admin` | `owner` | | -------------------- | -------- | -------- | ------- | ------- | | `content:read` | Yes | Yes | Yes | Yes | | `content:read:draft` | -- | Yes | Yes | Yes | | `content:write` | -- | Yes | Yes | Yes | | `content:publish` | -- | Yes | Yes | Yes | | `content:unpublish` | -- | Yes | Yes | Yes | | `content:delete` | -- | Yes | Yes | Yes | | `schema:read` | Yes | Yes | Yes | Yes | | `schema:write` | -- | -- | Yes | Yes | | `projects:read` | Yes | Yes | Yes | Yes | | `projects:write` | -- | -- | Yes | Yes | | `user:manage` | -- | -- | Yes | Yes | | `settings:manage` | -- | -- | Yes | Yes | ## API Key Scopes API keys use a fine-grained scope model independent of RBAC roles. Each key declares which operations it is permitted to perform. | Scope | Description | | ---------------------- | -------------------------------------------------------- | | `content:read` | Read published content documents. | | `content:read:draft` | Read draft (unpublished) content documents. | | `content:write` | Create and update content documents. | | `content:write:draft` | Legacy scope for draft writes (aliases `content:write`). | | `content:publish` | Publish documents (create immutable version snapshots). | | `content:delete` | Soft-delete and restore content documents. | | `schema:read` | Read schema registry entries and sync state. | | `schema:write` | Sync schema changes to the server. | | `media:upload` | Upload media files to S3 storage. | | `media:delete` | Delete media files. | | `webhooks:read` | List and inspect webhook configurations. | | `webhooks:write` | Create, update, and delete webhook configurations. | | `environments:clone` | Clone content between environments. | | `environments:promote` | Promote content from one environment to another. | | `migrations:run` | Execute content migrations. | | `projects:read` | Read project metadata. | | `projects:write` | Update project settings. | API keys are scoped to specific project/environment pairs via the `contextAllowlist`. A key with `content:read` scope but an allowlist of `[{project: "docs", environment: "production"}]` cannot read content from any other project or environment. ## Response Envelopes All API responses use one of three standard envelope formats. ### Single Resource ```json theme={null} { "data": { "documentId": "550e8400-e29b-41d4-a716-446655440000", "path": "content/blog/hello-world", "type": "BlogPost", "locale": "en-US", "format": "mdx", "body": "# Hello World\n\nWelcome to MDCMS.", "frontmatter": { "title": "Hello World", "slug": "hello-world" }, "hasUnpublishedChanges": false, "publishedVersion": 3, "draftRevision": 7 } } ``` ### Paginated Collection ```json theme={null} { "data": [ { "documentId": "...", "path": "..." }, { "documentId": "...", "path": "..." } ], "pagination": { "total": 142, "limit": 25, "offset": 0, "hasMore": true } } ``` ### Error ```json theme={null} { "status": "error", "code": "UNAUTHORIZED", "message": "Authentication required.", "details": { "path": "/api/v1/content" }, "requestId": "req_abc123", "timestamp": "2026-04-13T10:30:00.000Z" } ``` Error responses always include a machine-readable `code`, a human-readable `message`, and an ISO-8601 `timestamp`. The optional `details` object provides context-specific debugging information. The `requestId` is echoed from the `X-Request-Id` header when present. ## CORS Configuration Studio browser routes (content, schema, media, auth, search, webhooks, actions, environments, collaboration, and Studio bootstrap) are protected by origin validation. Configure allowed origins via the `MDCMS_STUDIO_ALLOWED_ORIGINS` environment variable: ```bash theme={null} MDCMS_STUDIO_ALLOWED_ORIGINS=http://localhost:4173,https://admin.example.com ``` The server validates that the `Origin` header matches either the request's own origin (same-origin) or one of the configured allowed origins. Requests from disallowed origins receive a `403 FORBIDDEN_ORIGIN` response. Preflight `OPTIONS` requests are handled automatically with the appropriate `access-control-allow-*` headers. In local development, the default docker-compose configuration sets `MDCMS_STUDIO_ALLOWED_ORIGINS` to the studio-example dev server origin. Add additional origins when embedding Studio in a separate application. # Contributing Source: https://docs.mdcms.ai/development/contributing Development workflow, branch naming, commit conventions, and code standards This page covers everything you need to know about contributing to MDCMS, from branch naming to code conventions and the PR process. ## Development Workflow Always branch off `main` before making changes: ```bash theme={null} git checkout -b feat/my-feature ``` Write failing tests first, then implement until they pass. Run unit tests during development for fast feedback: ```bash theme={null} bun run unit ``` Before committing, run the full CI gate: ```bash theme={null} bun run ci:required ``` This runs format check, typecheck, unit tests, and integration tests sequentially. ```bash theme={null} git commit -m "feat(studio): add locale switcher to toolbar" ``` ## Branch Naming All branches follow a `type/kebab-case-description` pattern: | Prefix | Use for | | ----------- | ----------------------------------------------- | | `feat/` | New features | | `fix/` | Bug fixes | | `chore/` | Maintenance, dependency updates, config changes | | `refactor/` | Code restructuring without behavior changes | Examples: `feat/media-upload-drag-drop`, `fix/reference-resolution-null`, `chore/bump-elysia`. ## Commit Conventions Commits follow the conventional commits format: ``` type(scope): brief message ``` | Type | Use for | | ---------- | ------------------------------- | | `feat` | New feature | | `fix` | Bug fix | | `chore` | Maintenance task | | `refactor` | Code restructuring | | `docs` | Documentation changes | | `test` | Test additions or modifications | The **scope** is the package name or area of the codebase (e.g., `server`, `studio`, `cli`, `shared`, `sdk`, `modules`). Rules: * First line only -- no body, no footer * Keep the message concise and descriptive * Use imperative mood ("add feature" not "added feature") ```bash Good theme={null} git commit -m "feat(server): add webhook retry with exponential backoff" git commit -m "fix(sdk): handle timeout on large content responses" git commit -m "chore(deps): bump drizzle-orm to 0.38" ``` ```bash Bad theme={null} git commit -m "fixed stuff" git commit -m "feat: update" git commit -m "WIP" ``` ## Code Conventions ### TypeScript * Strict mode is enabled across all packages * Prefer type inference when types are obvious from context * Use explicit types for function parameters and return values at module boundaries ### Formatting Prettier handles all formatting. Run it before committing: ```bash theme={null} bun run format ``` ### Naming | Entity | Convention | Example | | --------------------- | ---------------------- | ----------------- | | Files | kebab-case | `content-api.ts` | | Types / Interfaces | PascalCase | `ContentDocument` | | Functions / Variables | camelCase | `getDocument` | | Constants | SCREAMING\_SNAKE\_CASE | `MAX_LIMIT` | * No abbreviations except widely known ones (`id`, `url`, `ctx`) * Self-documenting names -- comments explain "why", not "what" ### General Principles * **DRY** -- Extract repeated values to constants, repeated logic to functions * **No unrelated changes** -- Keep PRs focused on a single concern * **No debug artifacts** -- Never commit `console.log`, debugger statements, or test artifacts ## PR Process Push your branch and open a PR against `main`: ```bash theme={null} git push -u origin feat/my-feature ``` GitHub Actions runs the full CI pipeline on every PR. All required checks must pass before merge. Make changes in new commits (do not force-push during review). Keep the conversation going until the reviewer approves. Once approved and CI passes, the PR is merged into `main`. ## Pre-push Gate A git pre-push hook is installed automatically when you run `bun install`. It executes: ```bash theme={null} bun run ci:required ``` This runs the following checks sequentially: 1. **Format check** -- Verifies code matches Prettier formatting 2. **Typecheck** -- Runs TypeScript compiler across all packages 3. **Unit tests** -- Runs all unit tests 4. **Integration tests** -- Runs integration tests (requires Docker services running) The pre-push hook blocks the push if any check fails. Fix all failures before pushing. Do not skip the hook with `--no-verify` unless you have a specific reason. Run `bun run quality` (format + typecheck) as a quick sanity check during development. Save the full `bun run ci:required` for when you are ready to push. # Monorepo Packages Source: https://docs.mdcms.ai/development/packages Overview of all apps and packages in the MDCMS monorepo MDCMS is organized as a Bun monorepo with Nx for task orchestration. The workspace is split into four applications and four shared packages. ## Overview The root `package.json` declares workspaces: ```json theme={null} { "workspaces": ["apps/*", "packages/*"] } ``` Bun handles dependency resolution and linking. Nx manages build ordering, caching, and parallel task execution across the workspace. ## Dependency Graph ```mermaid theme={null} graph TD server["apps/server"] --> shared["packages/shared"] server --> modules["packages/modules"] cli["apps/cli"] --> shared cli --> modules studioEx["apps/studio-example"] --> studio["packages/studio"] studioEx --> sdk["packages/sdk"] studio --> shared sdk --> shared modules --> shared ``` ## Applications ### apps/server | | | | --------------- | ------------------- | | **Package** | `@mdcms/server` | | **Entry** | `src/index.ts` | | **Port** | 4000 | | **Dev command** | `bun nx dev server` | The Elysia HTTP server is the core of MDCMS. It exposes the REST API for content CRUD, schema registry, authentication, media uploads, webhooks, and environment management. Key dependencies: Elysia, Drizzle ORM, better-auth, `@mdcms/shared`, `@mdcms/modules`. ### apps/cli | | | | --------------- | ---------------- | | **Package** | `@mdcms/cli` | | **Binary** | `mdcms` | | **Dev command** | `bun nx dev cli` | The command-line tool for managing MDCMS projects from your terminal or CI/CD pipelines. Available commands: | Command | Description | | ------------- | ------------------------------------------- | | `init` | Interactive project setup wizard | | `login` | Authenticate via browser OAuth flow | | `logout` | Clear stored credentials | | `push` | Push local content to the server | | `pull` | Pull content from the server to local files | | `schema-sync` | Sync schema definitions to the server | | `status` | Show sync status between local and server | | `migrate` | Run content migrations | Dependencies: `@mdcms/shared`, `@mdcms/modules`. ### apps/studio-example | | | | --------------- | --------------------------- | | **Package** | `@mdcms/studio-example` | | **Framework** | Next.js 15.2 | | **Port** | 4173 | | **Dev command** | `bun nx dev studio-example` | A demo Next.js application that embeds the `` component from `@mdcms/studio`. Used for local development and testing of the Studio UI. Not published to any registry. Dependencies: `@mdcms/studio`, `@mdcms/sdk`. ## Shared Packages ### packages/shared | | | | ----------- | --------------- | | **Package** | `@mdcms/shared` | The foundational package that all other packages depend on. Contains contracts, types, and utilities shared across the entire monorepo. Key exports: | Export | Purpose | | ---------------------- | ------------------------------------------- | | `defineConfig` | Configuration builder for `mdcms.config.ts` | | `defineType` | Content type definition helper | | `fieldTypes.reference` | Typed reference field helper | | `API_V1_BASE_PATH` | API version path constant | | `RuntimeError` | Base error class for structured errors | | Content response types | TypeScript types for API responses | | Schema types | TypeScript types for schema definitions | ### packages/sdk | | | | ----------- | ------------ | | **Package** | `@mdcms/sdk` | The TypeScript client SDK for querying MDCMS content from your application at build time or runtime. Key exports: | Export | Purpose | | ------------------ | ----------------------------------------------------- | | `createClient` | Factory function to create a configured MDCMS client | | `MdcmsApiError` | Error class for API-level errors | | `MdcmsClientError` | Error class for client-level errors (network, config) | Dependencies: `@mdcms/shared`. ### packages/studio | | | | ----------- | --------------- | | **Package** | `@mdcms/studio` | The embeddable React UI component that provides the full visual editing experience -- dashboard, content editor, media management, and settings. Built with: | Library | Purpose | | -------------------- | ------------------------------- | | React 19 | UI framework | | TipTap 3.7 | Rich text / MDX editor | | Radix UI | Accessible component primitives | | TailwindCSS | Styling | | TanStack React Query | Server state management | | Lucide | Icon library | Dependencies: `@mdcms/shared`. ### packages/modules | | | | ----------- | ---------------- | | **Package** | `@mdcms/modules` | The module registry that provides extensibility. Modules can register server actions, CLI commands, and Studio UI extensions. Built-in modules: | Module | Description | | ---------------- | ------------------------- | | `core.system` | Core system functionality | | `domain.content` | Content domain operations | Dependencies: `@mdcms/shared`. # Local Development Setup Source: https://docs.mdcms.ai/development/setup Prerequisites, installation, environment variables, and running MDCMS locally This guide walks you through setting up a local MDCMS development environment from scratch for **contributing to MDCMS itself**. By the end, you will have all services running and be ready to develop. If you want to use MDCMS in your own project (not contribute to the MDCMS codebase), see the [Quick Start](/guide/quickstart) instead. ## Prerequisites Before you begin, install the following tools: | Tool | Version | Purpose | | --------------------------- | ---------------------------------- | -------------------------------------------------- | | **Bun** | 1.3.11+ (pinned in `.bun-version`) | Package manager, runtime, and test runner | | **Docker & Docker Compose** | Latest stable | Infrastructure services (PostgreSQL, Redis, MinIO) | | **Node.js** | 20+ | Required for Next.js apps (studio-example) | | **Git** | Latest stable | Source control | ```bash theme={null} # Install Bun brew install oven-sh/bun/bun # Install Docker Desktop (includes Docker Compose) brew install --cask docker # Install Node.js via nvm brew install nvm nvm install 20 ``` ```bash theme={null} # Install Bun curl -fsSL https://bun.sh/install | bash # Install Docker & Docker Compose # Follow https://docs.docker.com/engine/install/ for your distro # Install Node.js via nvm curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash nvm install 20 ``` The repository includes a `.bun-version` file that pins Bun to 1.3.11. If you use a version manager like `mise` or `proto`, it will pick this up automatically. ## Clone & Install `bash git clone git@github.com:mdcms-ai/mdcms.git && cd mdcms ` `bash bun install ` This also sets up the pre-push git hook that runs CI checks before every push. `bash cp .env.example .env ` Edit `.env` to override any defaults for your local setup. See the environment variables section below. ## Environment Variables ### Required These must be set for the server to start. | Variable | Description | Example | | --------------- | ------------------------------ | ----------------------------------------------------- | | `DATABASE_URL` | PostgreSQL connection string | `postgresql://postgres:postgres@localhost:5432/mdcms` | | `REDIS_URL` | Redis connection string | `redis://localhost:6379` | | `S3_ENDPOINT` | S3-compatible storage endpoint | `http://localhost:9000` | | `S3_ACCESS_KEY` | S3 access key | `minioadmin` | | `S3_SECRET_KEY` | S3 secret key | `minioadmin` | | `S3_BUCKET` | S3 bucket name | `mdcms` | ### Server | Variable | Description | Example | | ----------- | ------------------------------------------------ | ------------- | | `PORT` | Server listen port | `4000` | | `NODE_ENV` | Runtime environment | `development` | | `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `debug` | ### Studio | Variable | Description | Example | | ------------------------------ | ------------------------------------ | ----------------------- | | `MDCMS_STUDIO_ALLOWED_ORIGINS` | CORS allowed origins for Studio | `http://localhost:4173` | | `MDCMS_AUTH_INSECURE_COOKIES` | Allow HTTP cookies in dev (no HTTPS) | `true` | ### Auth Providers (optional) | Variable | Description | Example | | --------------------------- | ---------------------------------- | ------- | | `MDCMS_AUTH_OIDC_PROVIDERS` | OIDC provider configuration (JSON) | | | `MDCMS_AUTH_SAML_PROVIDERS` | SAML provider configuration (JSON) | | ### Demo | Variable | Description | Example | | ------------------------ | -------------------------- | --------------------------------------------- | | `MDCMS_DEMO_API_KEY` | API key for demo seed data | `mdcms_key_demo_local_compose_seed_2026_read` | | `MDCMS_DEMO_PROJECT` | Demo project slug | `marketing-site` | | `MDCMS_DEMO_ENVIRONMENT` | Demo environment name | `staging` | The Docker Compose dev configuration sets all required variables automatically. You only need to configure `.env` manually if running services outside Docker. ## Start Development The simplest way to get everything running: ```bash theme={null} docker compose -f docker-compose.dev.yml up -d ``` This starts all services together: * **PostgreSQL 16** -- primary database * **Redis 7** -- sessions and caching * **MinIO** -- S3-compatible object storage * **Mailhog** -- email capture for development * **Server** -- Elysia API with watch mode (auto-reloads on changes) * **Studio Example** -- Next.js demo app To stop everything: ```bash theme={null} docker compose -f docker-compose.dev.yml down ``` Start infrastructure services only, then run apps individually for more control: ```bash theme={null} # Start infrastructure docker compose up postgres redis minio -d ``` In separate terminals: ```bash theme={null} # Terminal 1: Start the server bun nx dev server ``` ```bash theme={null} # Terminal 2: Start the Studio example app bun nx dev studio-example ``` ## Service Endpoints Once running, these services are available: | Service | URL | | -------------- | ---------------------------------------------- | | Server API | [http://localhost:4000](http://localhost:4000) | | Studio Example | [http://localhost:4173](http://localhost:4173) | | MinIO Console | [http://localhost:9001](http://localhost:9001) | | Mailhog | [http://localhost:8025](http://localhost:8025) | | PostgreSQL | `localhost:5432` | | Redis | `localhost:6379` | ## Demo Credentials The dev seed data creates a demo user you can use to log in to Studio: | Field | Value | | -------- | ------------------ | | Email | `demo@mdcms.local` | | Password | `Demo12345!` | ## Available Commands These scripts are defined in the root `package.json` and orchestrated by Nx: | Command | Description | | -------------------------- | ------------------------------------------------------------- | | `bun run dev` | Start the server in development mode | | `bun run compose:dev` | Start full dev stack via Docker Compose | | `bun run compose:dev:down` | Stop the dev Docker Compose stack | | `bun run build` | Build all packages and apps | | `bun run typecheck` | Run TypeScript type checking across all packages | | `bun run quality` | Run format check + typecheck | | `bun run unit` | Run unit tests | | `bun run integration` | Run integration tests (requires Docker services) | | `bun run ci:required` | Run the full CI gate: format + typecheck + unit + integration | | `bun run format` | Format code with Prettier | # Testing Source: https://docs.mdcms.ai/development/testing Unit tests, integration tests, CI gates, and database migrations MDCMS uses Bun's built-in test runner for both unit and integration tests. This page covers how to run tests, write new ones, and manage database migrations. ## Unit Tests Unit tests live alongside source files with the `*.test.ts` suffix. | | | | --------------- | ----------------------------------- | | **Pattern** | `*.test.ts` next to the source file | | **Runner** | Bun test (`bun test`) | | **Command** | `bun run unit` | | **Per-package** | `bun nx test ` | Unit tests cover config parsing, validation logic, contracts, SDK methods, and utility functions. They do not require any external services. ```bash theme={null} # Run all unit tests bun run unit # Run tests for a specific package bun nx test shared bun nx test sdk ``` ## Integration Tests Integration tests exercise the full stack -- API endpoints, authentication flows, database operations, and the content lifecycle. | | | | ------------ | -------------------------------------------------- | | **Requires** | Docker Compose services (PostgreSQL, Redis, MinIO) | | **Command** | `bun run integration` | Integration tests require Docker services to be running. Start them before running: ```bash theme={null} docker compose -f docker-compose.dev.yml up -d ``` ```bash theme={null} # Run all integration tests bun run integration ``` ## CI Gates The CI pipeline runs three sequential stages. All must pass before a PR can be merged. Format check + TypeScript typecheck across all packages. ```bash theme={null} bun run quality ``` All unit tests across every package. ```bash theme={null} bun run unit ``` Full integration test suite. Requires Docker services. ```bash theme={null} bun run integration ``` The combined command that runs all three stages: ```bash theme={null} bun run ci:required ``` The pre-push git hook runs `ci:required` automatically before every push. ## Writing Tests Follow these principles when adding tests: 1. **Write the failing test first.** Verify it actually fails before writing any implementation. 2. **Implement until the test passes.** Do not modify the test to make it pass -- fix the implementation. 3. **Test behavior, not implementation details.** Assert on observable outcomes (return values, side effects, API responses), not internal state. 4. **Keep tests focused.** Each test should verify one behavior. Use descriptive test names that explain the expected behavior. ```typescript theme={null} import { describe, expect, test } from "bun:test"; import { parseConfig } from "./parse-config"; describe("parseConfig", () => { test("returns parsed config when input is valid", () => { const result = parseConfig({ project: "my-site", serverUrl: "http://localhost:4000", }); expect(result.project).toBe("my-site"); }); test("throws on missing project field", () => { expect(() => parseConfig({ serverUrl: "http://localhost:4000" })).toThrow(); }); }); ``` Run `bun run unit` during development for fast feedback. Save `bun run integration` for before pushing. ## Database Migrations MDCMS uses Drizzle ORM for database schema management. Migrations are generated from the schema definition and applied via Drizzle Kit. Edit the Drizzle schema file: ``` apps/server/src/lib/db/schema.ts ``` ```bash theme={null} bunx drizzle-kit generate ``` This creates a new SQL migration file in the `drizzle/` directory. Open the generated file in `drizzle/` and verify the SQL matches your intent. Pay attention to destructive operations like column drops or type changes. ```bash theme={null} bunx drizzle-kit push ``` Verify the migration works correctly against a database that already has data. Check that existing records are preserved and new constraints do not cause failures. Commit the schema change and the generated migration file in the same commit: ```bash theme={null} git add apps/server/src/lib/db/schema.ts drizzle/ git commit -m "feat(server): add webhook retry columns" ``` Always test migrations against a database with realistic data before deploying to production. A migration that works on an empty database may fail or corrupt data on a populated one. # CI/CD Integration Source: https://docs.mdcms.ai/guide/cli/ci-cd Automating schema sync and content deployment in pipelines The MDCMS CLI is designed for headless operation in CI/CD pipelines. All commands accept configuration via environment variables, and interactive prompts are automatically skipped in non-TTY environments. ## Environment variables Set these in your CI provider's secret management: | Variable | Description | | ------------------- | --------------------------------------------------- | | `MDCMS_API_KEY` | API key for authentication (replaces `mdcms login`) | | `MDCMS_PROJECT` | Target project slug | | `MDCMS_ENVIRONMENT` | Target environment name | | `MDCMS_SERVER_URL` | MDCMS server URL (overrides `mdcms.config.ts`) | The CLI also auto-loads `.env*` files before reading `mdcms.config.ts`. In CI, shell-exported variables from the `env:` block override values from files. Add `--no-env-file` to CLI commands, or set `MDCMS_DOTENV=0`, when the pipeline should rely only on CI-provided variables. `NODE_ENV` only selects dotenv filenames such as `.env.production`. It is separate from `MDCMS_ENVIRONMENT`, which selects the MDCMS content environment targeted by schema sync, push, pull, and status commands. Create a dedicated API key with minimal scopes for CI use. Schema sync requires `schema:read` and `schema:write`. Content push requires `content:write` and `content:read:draft`. ## GitHub Actions: Schema sync Automatically sync the schema to the server whenever `mdcms.config.ts` changes on the main branch. ```yaml .github/workflows/mdcms-schema-sync.yml theme={null} name: MDCMS Schema Sync on: push: branches: [main] paths: - "mdcms.config.ts" jobs: schema-sync: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: bun install --frozen-lockfile - name: Sync schema run: bunx mdcms schema sync env: MDCMS_API_KEY: ${{ secrets.MDCMS_API_KEY }} MDCMS_PROJECT: ${{ vars.MDCMS_PROJECT }} MDCMS_ENVIRONMENT: ${{ vars.MDCMS_ENVIRONMENT }} MDCMS_SERVER_URL: ${{ vars.MDCMS_SERVER_URL }} ``` ## GitHub Actions: Content push Push content changes to the server on every merge to main. The `--force` flag skips interactive prompts. ```yaml .github/workflows/mdcms-content-push.yml theme={null} name: MDCMS Content Push on: push: branches: [main] paths: - "content/**" jobs: content-push: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: bun install --frozen-lockfile - name: Sync schema run: bunx mdcms schema sync env: MDCMS_API_KEY: ${{ secrets.MDCMS_API_KEY }} MDCMS_PROJECT: ${{ vars.MDCMS_PROJECT }} MDCMS_ENVIRONMENT: ${{ vars.MDCMS_ENVIRONMENT }} MDCMS_SERVER_URL: ${{ vars.MDCMS_SERVER_URL }} - name: Push content run: bunx mdcms push --force env: MDCMS_API_KEY: ${{ secrets.MDCMS_API_KEY }} MDCMS_PROJECT: ${{ vars.MDCMS_PROJECT }} MDCMS_ENVIRONMENT: ${{ vars.MDCMS_ENVIRONMENT }} MDCMS_SERVER_URL: ${{ vars.MDCMS_SERVER_URL }} ``` Schema sync runs before content push to ensure the schema hash is available. Push requires a valid schema hash from a prior sync. ## Status check Use `mdcms status` as a CI gate. The command exits with code `1` when drift is detected, making it suitable for blocking deployments. ```yaml theme={null} - name: Check sync status run: bunx mdcms status env: MDCMS_API_KEY: ${{ secrets.MDCMS_API_KEY }} MDCMS_PROJECT: ${{ vars.MDCMS_PROJECT }} MDCMS_ENVIRONMENT: ${{ vars.MDCMS_ENVIRONMENT }} MDCMS_SERVER_URL: ${{ vars.MDCMS_SERVER_URL }} ``` This step fails the workflow if local content or schema is out of sync with the server. ## Schema drift detection Prevent deployments when the local schema definition has diverged from the server. This is useful as a pull request check to ensure schema changes are synced before merging. ```yaml .github/workflows/mdcms-drift-check.yml theme={null} name: MDCMS Drift Check on: pull_request: branches: [main] jobs: drift-check: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: bun install --frozen-lockfile - name: Check for drift run: bunx mdcms status env: MDCMS_API_KEY: ${{ secrets.MDCMS_API_KEY }} MDCMS_PROJECT: ${{ vars.MDCMS_PROJECT }} MDCMS_ENVIRONMENT: ${{ vars.MDCMS_ENVIRONMENT }} MDCMS_SERVER_URL: ${{ vars.MDCMS_SERVER_URL }} ``` If `mdcms status` reports drift on a pull request, the author should run `mdcms schema sync` and `mdcms push` locally before merging, or include the schema sync step in the merge workflow. # Commands Source: https://docs.mdcms.ai/guide/cli/commands Complete reference for all MDCMS CLI commands ## `mdcms init` Interactive wizard that bootstraps a new MDCMS project from an existing repository. ```bash theme={null} mdcms init ``` The wizard runs through the following steps: Prompts for the MDCMS server URL and validates it with a health check. Lists existing projects on the server or creates a new one. Opens a browser window for authentication using the device flow (same mechanism as `mdcms login`). Choose an existing environment or create one (e.g. `development`, `staging`). Recursively scans the repository for `.md` and `.mdx` files and groups them by top-level content directories. Analyzes file naming patterns to detect locale suffixes (e.g. `post.en.mdx`, `post.fr.mdx`) and infers default/supported locale configuration. Parses frontmatter from discovered files to infer content types and their field schemas using Zod types. Writes `mdcms.config.ts` with `defineConfig` and `defineType` calls reflecting all discovered types, directories, and locale settings. Uploads the inferred schema to the server for the selected project/environment. Pushes all discovered content files to the server as draft documents and writes the initial manifest. Appends `.mdcms/` and manifest paths to `.gitignore`. Runs `git rm -r --cached` on any managed files that were previously committed to git. Running `mdcms init` in a repository that already has an `mdcms.config.ts` will prompt before overwriting. *** ## `mdcms login` Authenticate with the MDCMS server using a browser-based OAuth flow and store credentials locally. ```bash theme={null} mdcms login ``` The CLI sends a login challenge to the server with a cryptographic state parameter and a loopback redirect URI. Your default browser opens the server's authorization page. Complete the login there. The CLI listens on a local port for the OAuth callback. The browser redirects back with an authorization code. The CLI exchanges the authorization code for a scoped API key. The API key is stored in the local credential store, scoped to the current server/project/environment tuple. ### Credential storage Credentials are stored per server/project/environment combination: * **macOS**: OS Keychain via the `security` CLI (service: `mdcms-cli`), with file-based fallback * **Other platforms**: `~/.mdcms/credentials.json` with `0600` permissions ### Authentication precedence When resolving an API key, the CLI checks these sources in order: 1. `--api-key` flag 2. `MDCMS_API_KEY` environment variable 3. Stored credential profile (from `mdcms login`) ### Default scopes The login flow requests the following scopes: ``` projects:read, projects:write, schema:read, schema:write, content:read, content:read:draft, content:write, content:delete ``` *** ## `mdcms logout` Revoke the stored API key and clear local credentials for the current server/project/environment tuple. ```bash theme={null} mdcms logout ``` The CLI attempts to revoke the API key on the server as a best-effort operation. Even if the remote revocation fails (e.g. the server is unreachable), the local credential profile is always removed. *** ## `mdcms push` Upload local content changes to the MDCMS server. ```bash theme={null} mdcms push [--force] [--dry-run] [--validate] [--published] ``` | Flag | Description | | ------------- | ------------------------------------------------------------------- | | `--force` | Skip all interactive prompts; auto-select all new and deleted files | | `--dry-run` | Show the push plan without making any API writes | | `--validate` | Validate frontmatter against the local schema before pushing | | `--published` | Reserved for future use | ### How push works 1. **Load manifest** — Reads `.mdcms.manifest.json` to determine which documents are tracked locally. 2. **Hash-based change detection** — Computes a content hash for each tracked file and compares against the manifest. Only changed files are included in the push plan. 3. **New file detection** — Scans content directories for untracked `.md`/`.mdx` files not yet in the manifest. In interactive mode, you select which new files to upload. 4. **Deletion detection** — Files present in the manifest but missing from disk are flagged as candidates for server-side deletion. 5. **Plan summary** — The CLI prints a summary of changes, new files, and deletions before writing anything. 6. **Confirmation** — Destructive changes (deletions, overwrites) require explicit confirmation unless `--force` is passed. 7. **Execute** — Changed files are uploaded, new documents are created, and deleted documents are removed on the server. 8. **Update manifest** — The local manifest is updated with new document IDs, revision numbers, and hashes. ### Concurrency control Push uses optimistic concurrency via the `draftRevision` field. If the server's current revision does not match the manifest's recorded revision, the push for that document is rejected. Run `mdcms pull` to reconcile before retrying. Push requires a valid schema hash from a previous `mdcms schema sync`. If the schema has never been synced or the local hash is missing, the command will fail. *** ## `mdcms pull` Download content from the MDCMS server to your local filesystem. ```bash theme={null} mdcms pull [--published] [--force] [--dry-run] ``` | Flag | Description | | ------------- | ------------------------------------------------------ | | `--published` | Pull published snapshots instead of draft heads | | `--force` | Skip overwrite confirmation for locally modified files | | `--dry-run` | Show the pull plan without writing any files | ### Change categories The pull command classifies each document into one of these categories: | Category | Behavior | | --------------------- | ----------------------------------------------------------------------------------------- | | **Both Modified** | Local and server changes detected. Prompts for confirmation before overwriting | | **Server Modified** | Server has newer content. File is updated automatically | | **Locally Modified** | Only local changes exist. File is skipped (not overwritten) | | **New** | Document exists on server but not locally. File is created | | **Moved/Renamed** | Server path differs from manifest path. File is moved to the new location | | **Deleted on Server** | Document was removed server-side. Prompts for confirmation before deleting the local file | | **Unknown Type** | Document type has no matching config entry. Skipped with a warning | | **Unchanged** | Content hashes match. No action taken | Use `--force` to skip confirmation prompts in non-interactive environments (e.g. CI pipelines). Locally modified files will be overwritten. *** ## `mdcms schema sync` Upload the local schema definition to the MDCMS server. ```bash theme={null} mdcms schema sync ``` This command: 1. Parses `mdcms.config.ts` and resolves all type definitions 2. Applies environment-specific overlays if an `environments` block is defined in the config 3. Builds a schema sync payload and uploads it to the server via `PUT /api/v1/schema` 4. Persists the returned `schemaHash` to `.mdcms/schema/..json` The schema hash is required by `mdcms push` to ensure content is validated against the correct schema version. ### Overrides Schema sync respects the standard `--project` and `--environment` global flags: ```bash theme={null} mdcms schema sync --environment staging ``` At least one content type must be defined in `mdcms.config.ts` before running schema sync. The command will fail with a `NO_TYPES_DEFINED` error if the `types` array is empty. *** ## `mdcms status` Compare local content and schema state against the server. ```bash theme={null} mdcms status ``` The status command reports: * **Content drift** — Documents that have been modified locally, modified on the server, or are out of sync * **Schema drift** — Whether the local schema hash matches the server's current schema ### Drift categories | Category | Description | | -------------------- | -------------------------------------------------------- | | `modified_on_server` | Server has newer content than the local manifest records | | `modified_locally` | Local file has changed since last push/pull | | `both_modified` | Both local and server content have diverged | | `new_on_server` | Document exists on server but not in the local manifest | | `deleted_on_server` | Document is in the manifest but no longer on the server | | `moved_renamed` | Server path differs from the manifest path | | `unchanged` | Everything is in sync | ### Exit codes | Code | Meaning | | ---- | ---------------------------------- | | `0` | Everything is in sync | | `1` | Drift detected (content or schema) | Use `mdcms status` in CI pipelines as a gate. A non-zero exit code can block deployments when content or schema is out of sync. *** ## `mdcms migrate` Generate and apply content migrations when the schema changes. ```bash theme={null} mdcms migrate [--apply] ``` | Flag | Description | | --------- | ------------------------------------------------------------- | | `--apply` | Execute the generated migration instead of just previewing it | ### How migrations work 1. **Detect schema changes** — Compares the current `mdcms.config.ts` types against the server's active schema 2. **Generate transforms** — Creates per-document migration operations (e.g. adding default values for new required fields, renaming fields) 3. **Preview** — Without `--apply`, the CLI prints a summary of what would change 4. **Apply** — With `--apply`, each document is transformed and pushed as a new draft revision. If the document was previously published, a new version is auto-published Migrations are forward-only. Always review the preview output before running with `--apply`. # Configuration Source: https://docs.mdcms.ai/guide/cli/configuration mdcms.config.ts reference and local state files ## Config file The CLI reads its configuration from `mdcms.config.ts` at the project root. Use `defineConfig` and `defineType` from `@mdcms/cli` to get full type safety. ```typescript mdcms.config.ts theme={null} import { defineConfig, defineType, fieldTypes } from "@mdcms/cli"; import { z } from "zod"; const Author = defineType("Author", { directory: "content/authors", fields: { name: z.string().min(1), bio: z.string().optional(), avatar: z.string().url().optional(), twitter: z.string().optional(), }, }); const BlogPost = defineType("BlogPost", { directory: "content/blog", localized: true, fields: { title: z.string().min(1).max(200), slug: z.string().regex(/^[a-z0-9-]+$/), author: fieldTypes.reference("Author"), publishedAt: z.coerce.date(), tags: z.array(z.string()).default([]), featured: z.boolean().default(false), excerpt: z.string().max(300).optional(), }, }); export default defineConfig({ project: "marketing-site", serverUrl: "https://cms.example.com", environment: "development", contentDirectories: ["content"], locales: { default: "en", supported: ["en", "fr", "de"], aliases: { "en-US": "en", "en-GB": "en", }, }, types: [Author, BlogPost], environments: { staging: { serverUrl: "https://cms-staging.example.com", }, production: { serverUrl: "https://cms.example.com", }, }, components: [ { name: "Callout", importPath: "@/components/Callout" }, { name: "CodeBlock", importPath: "@/components/CodeBlock" }, ], }); ``` ## Config options All properties accepted by `defineConfig`: | Property | Type | Required | Description | | -------------------- | ------------------------ | -------- | ------------------------------------------------------------------------- | | `project` | `string` | Yes | Project slug. Must match the project on the server | | `serverUrl` | `string` | Yes | Base URL of the MDCMS server | | `environment` | `string` | No | Default environment name (e.g. `development`) | | `contentDirectories` | `string[]` | No | Directories to scan for content files. Default: `["content"]` | | `locales` | `object` | No | Locale configuration (see below) | | `types` | `MdcmsTypeDefinition[]` | No | Content type definitions created with `defineType` | | `environments` | `Record` | No | Per-environment config overlays (can override `serverUrl`, `environment`) | | `components` | `object[]` | No | MDX component registrations for the Studio editor | ### Locale options | Property | Type | Description | | ------------------- | ------------------------ | -------------------------------------------- | | `locales.default` | `string` | The default locale code (e.g. `"en"`) | | `locales.supported` | `string[]` | All supported locale codes | | `locales.aliases` | `Record` | Map alternate locale codes to canonical ones | ### Type definition options Each `defineType(name, options)` accepts: | Property | Type | Description | | ----------- | --------------------------- | -------------------------------------------------------------- | | `directory` | `string` | Filesystem directory for this type's content files | | `localized` | `boolean` | Whether documents of this type are localized. Default: `false` | | `fields` | `Record` | Frontmatter field definitions using Zod schemas | *** ## Environment files The CLI loads `.env*` files before importing `mdcms.config.ts`, so config files can read values from `process.env`: ```typescript mdcms.config.ts theme={null} export default defineConfig({ serverUrl: process.env.MDCMS_SERVER_URL, project: process.env.MDCMS_PROJECT, environment: process.env.MDCMS_ENVIRONMENT ?? "development", types: [BlogPost], }); ``` Env files are read from the directory that contains the resolved `mdcms.config.ts`, `mdcms.config.js`, or `mdcms.config.mjs`. If you pass `--config ./config/mdcms.config.ts`, the CLI looks for env files in `./config`. Without `--config`, the CLI searches upward from the current directory for the nearest config file. Put local developer values in `.env.local` next to `mdcms.config.ts`: ```bash .env.local theme={null} MDCMS_SERVER_URL=http://localhost:4000 MDCMS_PROJECT=marketing-site MDCMS_ENVIRONMENT=staging MDCMS_API_KEY=mdcms_key_local ``` Loading order, highest precedence first: | Order | File | When loaded | | ----- | ----------------------- | --------------------------- | | 1 | `.env.{NODE_ENV}.local` | Always | | 2 | `.env.local` | Except when `NODE_ENV=test` | | 3 | `.env.{NODE_ENV}` | Always | | 4 | `.env` | Always | `NODE_ENV` is only the dotenv file selector. It controls names such as `.env.production`; it does not select the MDCMS content environment. Use `MDCMS_ENVIRONMENT` or the config `environment` field to choose an MDCMS environment such as `staging` or `production`. `NODE_ENV` defaults to `development` when unset. Shell-exported variables override all file values, so CI secrets and one-off command exports always win. Use `--no-env-file` or `MDCMS_DOTENV=0` to disable automatic env file loading for a run. *** ## Manifest file The manifest tracks the mapping between server documents and local files. It is stored at `.mdcms.manifest.json` in the project root, scoped to the current project and environment. The manifest file should be added to `.gitignore`. It contains local state that varies between developers and environments. ### Structure The manifest is a JSON object where each key is a `documentId` and the value describes the local file: ```json .mdcms.manifest.json theme={null} { "doc_a1b2c3d4": { "path": "content/blog/hello-world.en.mdx", "format": "mdx", "draftRevision": 3, "publishedVersion": 1, "hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "doc_e5f6g7h8": { "path": "content/authors/jane-doe.md", "format": "md", "draftRevision": 1, "publishedVersion": null, "hash": "sha256:abc123..." } } ``` | Field | Type | Description | | ------------------ | ---------------- | ------------------------------------------------------ | | `path` | `string` | Relative path to the content file from project root | | `format` | `"md" \| "mdx"` | File format | | `draftRevision` | `number` | Current draft revision number on the server | | `publishedVersion` | `number \| null` | Published version number, or `null` if never published | | `hash` | `string` | Content hash of the file at last sync | *** ## Schema state After each `mdcms schema sync`, the CLI writes a state file to: ``` .mdcms/schema/..json ``` For example, `marketing-site` project in the `development` environment produces: ``` .mdcms/schema/marketing-site.development.json ``` ### Contents ```json .mdcms/schema/marketing-site.development.json theme={null} { "schemaHash": "a1b2c3d4e5f6...", "syncedAt": "2026-04-13T10:30:00.000Z", "serverUrl": "https://cms.example.com" } ``` The `schemaHash` is required by `mdcms push` to validate that content is being pushed against the correct schema version. If this file is missing or the hash is stale, push operations will fail. The `.mdcms/` directory should be added to `.gitignore`. The `mdcms init` wizard does this automatically. *** ## File mapping The CLI maps server documents to local files based on the type's `directory` setting and the document's `path` and `locale`. ### Localized documents Localized types produce files with the locale code inserted before the file extension: ``` /.. ``` For example, a `BlogPost` document with path `hello-world`, locale `en`, and format `mdx`: ``` content/blog/hello-world.en.mdx ``` The same document in French: ``` content/blog/hello-world.fr.mdx ``` ### Non-localized documents Non-localized types use a simpler pattern without the locale segment: ``` /. ``` For example, an `Author` document with path `jane-doe` and format `md`: ``` content/authors/jane-doe.md ``` # Installation Source: https://docs.mdcms.ai/guide/cli/installation Installing and setting up the MDCMS CLI Install and configure the MDCMS CLI in this project. Run `npm install --save-dev @mdcms/cli`. Then run `npx mdcms init` to interactively set up the project — it will prompt for server URL (default [http://localhost:4000](http://localhost:4000)), project name, and environment. After init completes, run `npx mdcms schema sync` to push the generated schema to the server. Verify with `npx mdcms status` (exit code 0 means success). The MDCMS CLI lets you sync content and schema between your local filesystem and the MDCMS server. It handles authentication, push/pull workflows, and CI/CD integration. ## Install ```bash npm theme={null} npm install --save-dev @mdcms/cli ``` ```bash bun theme={null} bun add -D @mdcms/cli ``` Or install globally to use the `mdcms` command directly: ```bash theme={null} npm install -g @mdcms/cli ``` The binary name is `mdcms`. If installed as a project dependency, use your package manager's runner: ```bash theme={null} npx mdcms --help # or bunx mdcms --help ``` ### Prerequisites * **Node.js** 20+ or **Bun** 1.3.11+ * A running MDCMS server instance ## First-time setup The `mdcms init` command walks you through an interactive wizard that configures everything needed to start managing content. ```bash theme={null} mdcms init ``` Provide the URL of your MDCMS server (e.g. `https://cms.example.com`). The CLI validates the connection with a health check before proceeding. Select an existing project or create a new one, then choose the target environment (e.g. `development`, `staging`, `production`). The CLI opens your default browser for OAuth authentication using a device flow. Complete the login in your browser and the CLI receives credentials automatically. The wizard scans your repository for Markdown and MDX files, groups them by directory, and detects locale patterns from file names (e.g. `post.en.mdx`, `post.fr.mdx`). A configuration file is generated at the project root with your project settings, content type definitions inferred from existing files, and locale configuration. The inferred schema is synced to the server, and all discovered content files are pushed as initial draft documents. The wizard appends managed files (manifest, schema state) to your `.gitignore` and untracks them from git with `git rm -r --cached` if they were previously committed. ## Global options Every command accepts these flags: | Flag | Description | | ---------------------- | ------------------------------------------------ | | `--project ` | Override the target project slug | | `--environment ` | Override the target environment name | | `--api-key ` | Provide an API key directly (useful for CI) | | `--server-url ` | Override the server URL from config | | `--config ` | Path to config file (default: `mdcms.config.ts`) | | `-V`, `--version` | Print installed CLI version and exit | | `-h`, `--help` | Show help text | ## Resolution order When the CLI needs a value for project, environment, server URL, or API key, it resolves from the highest-priority source first: 1. **CLI flags** — `--project`, `--environment`, `--api-key`, `--server-url` 2. **Environment variables** — `MDCMS_PROJECT`, `MDCMS_ENVIRONMENT`, `MDCMS_API_KEY`, `MDCMS_SERVER_URL` 3. **Config file** — values from `mdcms.config.ts` 4. **Stored credentials** — API key from the local credential store (set by `mdcms login`) CLI flags always win. This makes it straightforward to override config values in CI pipelines or when working across multiple environments. # Key Concepts Source: https://docs.mdcms.ai/guide/concepts Core terminology and mental model for MDCMS This page covers every core concept in MDCMS. After reading it, you should be able to reason about how projects, environments, content types, documents, localization, references, and access control fit together. ## Projects A **project** is the top-level tenant in MDCMS. Each project owns its own content, schema, environments, users, and API keys. Projects are identified by a slug (e.g., `marketing-site`, `developer-docs`). You set the project in your config file: ```typescript mdcms.config.ts theme={null} import { defineConfig } from "@mdcms/cli"; export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", // ... }); ``` All CLI commands, SDK queries, and Studio sessions operate within the scope of a single project. If you manage multiple sites or products, create a separate project for each. ## Environments An **environment** is an isolated content space within a project. Typical setups use `production`, `staging`, and `development`, but you can define any set of environments. Every project starts with a `production` environment. Additional environments are declared in `mdcms.config.ts`: ```typescript mdcms.config.ts theme={null} import { defineConfig, defineType } from "@mdcms/cli"; import { z } from "zod"; const BlogPost = defineType("BlogPost", { directory: "content/blog", fields: { title: z.string().min(1), slug: z.string().min(1), featured: z.boolean().default(false).env("staging"), abTestVariant: z.string().optional().env("staging"), }, }); export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], environments: { production: {}, staging: { extends: "production", }, }, types: [BlogPost], }); ``` Key properties of environments: * **Isolation** -- Each environment has its own documents. Editing content in `staging` does not affect `production`. * **Schema overlays** -- Environments that `extend` another inherit the base schema and can add, modify, or omit fields. In the example above, `staging` extends `production` and adds `featured` and `abTestVariant` fields via the `.env("staging")` sugar. * **Clone & promote** -- You can clone all content from one environment to another, or promote a subset of documents between environments. The `environment` field in the config selects which environment the CLI operates against by default. If omitted, operations target `production`. ## Content Types (Schema) Content types define the shape of your content. They are declared in `mdcms.config.ts` using `defineType()` with [Zod](https://zod.dev) validators: ```typescript mdcms.config.ts theme={null} import { defineConfig, defineType, fieldTypes } from "@mdcms/cli"; import { z } from "zod"; const Author = defineType("Author", { directory: "content/authors", fields: { name: z.string().min(1), bio: z.string().optional(), website: z.string().url().optional(), }, }); const BlogPost = defineType("BlogPost", { directory: "content/blog", localized: true, fields: { title: z.string().min(1).max(200), slug: z.string().regex(/^[a-z0-9-]+$/), excerpt: z.string().max(500).optional(), author: fieldTypes.reference("Author"), publishedAt: z.coerce.date(), tags: z.array(z.string()).default([]), }, }); export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], types: [Author, BlogPost], }); ``` Each `defineType()` call accepts: | Parameter | Description | | ----------- | ------------------------------------------------------------------------------------------- | | `name` | Unique type identifier (first argument). Used in API queries and references. | | `directory` | Filesystem directory where documents of this type live (relative to a content directory). | | `localized` | When `true`, documents of this type support multiple locale variants. | | `fields` | A record of Zod schemas. Each field becomes a frontmatter property and a Studio form field. | The schema drives three things: 1. **Form generation** -- Studio renders appropriate input controls (text fields, date pickers, toggles, reference selectors) based on the Zod type of each field. 2. **Validation** -- Both the CLI and server validate frontmatter against the schema before accepting content. 3. **API behavior** -- The API exposes schema metadata so clients can introspect available types and their fields. After modifying your types, sync the schema to the server: ```bash theme={null} mdcms schema sync ``` ## Documents A **document** is an instance of a content type. It consists of structured frontmatter (validated against the schema) and a Markdown or MDX body. | Property | Description | | -------------------- | -------------------------------------------------------------------------------------------------------- | | `documentId` | Stable UUID. The true identity of a document -- never changes, even if the path does. | | `translationGroupId` | UUID that links locale variants of the same logical content together. | | `path` | Mutable, filesystem-like path (e.g., `content/blog/hello-world`). Can be renamed without losing history. | | `locale` | BCP 47 language tag (e.g., `en`, `fr`, `ja`). Set to `__mdcms_default__` for non-localized types. | | `format` | `md` for standard Markdown, `mdx` for MDX with component support. | | `frontmatter` | JSON object containing structured data that matches the content type schema. | | `body` | The Markdown or MDX content string. | Because `documentId` is stable, you can safely reference documents by ID in your application code. The `path` is designed for human readability and filesystem mapping, but should not be treated as a permanent identifier. On disk, a document looks like a standard Markdown file with YAML frontmatter: ```markdown content/blog/hello-world.mdx theme={null} --- title: Hello World slug: hello-world author: 11111111-1111-1111-1111-111111111111 publishedAt: 2026-01-15 tags: - announcements - getting-started --- Welcome to our blog. This is the first post. ``` ## Draft/Publish Workflow Every document follows a draft/publish lifecycle that tracks changes through immutable version snapshots. A new document starts as a **draft**. It has no published version and is only visible to users with `content:read:draft` permission. Edits are auto-saved. Each save increments the `draftRevision` counter. Drafts do not create version history entries -- they represent work in progress. Publishing creates an **immutable version snapshot**. The `version` number increments and the snapshot captures the full frontmatter and body at that point in time. You can attach an optional change summary to each published version. After publishing, further edits create new draft revisions on top of the published version. The published content remains stable and visible to readers. Publishing again creates a new version snapshot. The full version history is preserved, allowing you to review or compare any previous published state. The `hasUnpublishedChanges` flag on a document tells you whether the current draft differs from the latest published version. This is useful for building editorial dashboards that show pending changes. ## Localization MDCMS supports content localization at the type level. When a content type is defined with `localized: true`, each document can have independent locale variants. ```typescript theme={null} const Campaign = defineType("Campaign", { directory: "content/campaigns", localized: true, fields: { title: z.string().min(1), slug: z.string().min(1), summary: z.string().min(1), }, }); ``` Locale configuration is set globally in the config: ```typescript theme={null} export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], locales: { default: "en", supported: ["en", "fr", "ja"], aliases: { "en-US": "en", "en-GB": "en", }, }, types: [Campaign], }); ``` How localized documents work: * Each locale variant is a **separate document** with its own `documentId`, `path`, `body`, `frontmatter`, and version history. * Locale variants are linked by a shared `translationGroupId`. This lets the Studio display a locale switcher and the API return all variants of a piece of content. * Each variant can be at a different stage in the draft/publish workflow. Publishing the English version does not affect the French draft. * The SDK accepts a `locale` parameter to query content in a specific language. Non-localized types (where `localized` is `false` or omitted) operate in implicit single-locale mode. Their documents use the internal locale token `__mdcms_default__` and do not participate in translation groups. ## References The `fieldTypes.reference()` helper creates a typed relationship between content types. A reference field stores the `documentId` of the target document. ```typescript theme={null} const BlogPost = defineType("BlogPost", { directory: "content/blog", fields: { title: z.string().min(1), author: fieldTypes.reference("Author"), relatedPosts: z.array(fieldTypes.reference("BlogPost")).default([]), }, }); ``` How references are resolved: * **At rest**, reference fields store raw UUID strings (the target document's `documentId`). * **At query time**, you can request resolution via the `resolve` parameter in the API or SDK. Resolution is **shallow** (one level deep) -- it replaces the UUID with the target document's data but does not recursively resolve references within the resolved document. * **Unresolved references** return `null` in the frontmatter and include an error entry in the `resolveErrors` object on the response. Reference resolution error codes: | Error Code | Meaning | | ------------------------- | ---------------------------------------------------------------------------------------------- | | `REFERENCE_NOT_FOUND` | The referenced document does not exist in the target project/environment. | | `REFERENCE_DELETED` | The referenced document has been soft-deleted. | | `REFERENCE_TYPE_MISMATCH` | The referenced document's type does not match the expected target type declared in the schema. | | `REFERENCE_FORBIDDEN` | The current API key or user does not have read access to the referenced document. | References can be optional (`fieldTypes.reference("Author").optional()`) or collected in arrays (`z.array(fieldTypes.reference("BlogPost"))`). The resolution engine handles both forms. ## API Keys API keys provide scoped, token-based access to the MDCMS API. They are the primary authentication mechanism for server-to-server integrations and CI/CD pipelines. Each API key has: | Property | Description | | ------------------ | -------------------------------------------------------------------- | | `label` | Human-readable name (e.g., "Production Read-Only", "CI Deploy Key"). | | `scopes` | List of permitted operations. | | `contextAllowlist` | Restricts the key to specific project/environment pairs. | | `expiresAt` | Optional expiration timestamp. | API keys are prefixed with `mdcms_key_` for easy identification in logs and secret scanners. ### Available Scopes | Scope | Description | | ---------------------- | ------------------------------------- | | `content:read` | Read published content. | | `content:read:draft` | Read draft (unpublished) content. | | `content:write` | Create and update content. | | `content:write:draft` | Create and update draft content. | | `content:publish` | Publish documents. | | `content:delete` | Delete documents. | | `schema:read` | Read schema registry entries. | | `schema:write` | Sync schema changes. | | `media:upload` | Upload media files. | | `media:delete` | Delete media files. | | `webhooks:read` | List webhook configurations. | | `webhooks:write` | Create, update, and delete webhooks. | | `environments:clone` | Clone content between environments. | | `environments:promote` | Promote content between environments. | | `migrations:run` | Execute content migrations. | | `projects:read` | Read project metadata. | | `projects:write` | Update project settings. | A typical production frontend key would use only `content:read` and `schema:read` scopes, locked to the `production` environment via the context allowlist. ## RBAC Roles MDCMS uses role-based access control for user permissions. Roles are hierarchical -- each role includes all permissions of the roles below it. | Permission | Viewer | Editor | Admin | Owner | | -------------------- | ------ | ------ | ----- | ----- | | `content:read` | Yes | Yes | Yes | Yes | | `content:read:draft` | | Yes | Yes | Yes | | `content:write` | | Yes | Yes | Yes | | `content:publish` | | Yes | Yes | Yes | | `content:unpublish` | | Yes | Yes | Yes | | `content:delete` | | Yes | Yes | Yes | | `schema:read` | Yes | Yes | Yes | Yes | | `schema:write` | | | Yes | Yes | | `projects:read` | Yes | Yes | Yes | Yes | | `projects:write` | | | Yes | Yes | | `user:manage` | | | Yes | Yes | | `settings:manage` | | | Yes | Yes | ### Scope and Constraints * **Viewer** and **Editor** roles can be scoped to a specific project, or even a folder prefix within a project/environment. This allows fine-grained access like "editor for `content/blog/` in `production`". * **Admin** and **Owner** roles are always instance-wide (global scope). * Exactly **one Owner** must exist at all times. The system prevents removing or demoting the last Owner. ## Architecture at a Glance The following diagram shows how the main MDCMS components connect: ```mermaid theme={null} graph LR A["Your App
(Next.js, Astro, etc.)"] --> B["@mdcms/sdk"] A --> C["@mdcms/studio"] B --> D["MDCMS Server
(Elysia / Bun)"] C --> D E["@mdcms/cli"] --> D D --> F["PostgreSQL"] D --> G["Redis"] D --> H["S3-Compatible
Object Storage"] ``` | Component | Role | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | **MDCMS Server** | Elysia-based HTTP API running on Bun. Handles content CRUD, schema registry, auth, webhooks, and media. | | **@mdcms/cli** | Command-line tool for schema sync, content push/pull, migrations, and project initialization. | | **@mdcms/sdk** | TypeScript client for querying content from your application at build time or runtime. | | **@mdcms/studio** | Embeddable React component that provides the visual editing interface. Communicates directly with the server. | | **PostgreSQL** | Primary data store for all content, schema metadata, users, API keys, and version history. | | **Redis** | Used for session management, caching, and real-time features. | | **S3-Compatible Storage** | Stores uploaded media files (images, documents, etc.). Works with AWS S3, MinIO, Cloudflare R2, or any S3-compatible provider. | # Integrating MDCMS Source: https://docs.mdcms.ai/guide/integration Embed MDCMS Studio and the content SDK into your own application Wire MDCMS into a real application you own. This guide covers host app responsibilities, Studio embedding, authentication, and production configuration. Need a running server first? See [Self-Hosting](/guide/self-hosting). Need to install the CLI and define your schema? See the [Quick Start](/guide/quickstart). ## Prerequisites Before integrating, you need: 1. **A running MDCMS server** — self-hosted via Docker Compose or a managed instance 2. **The CLI installed and a project initialized** — `npx mdcms init` creates your `mdcms.config.ts` and syncs your schema 3. **A React application** — Next.js App Router is the primary example below; any React framework with catch-all routing works ## Host app responsibilities MDCMS handles content storage, the Studio UI, authentication flows, and the API. Your host app provides the mount point and configuration. | Your app provides | MDCMS handles | | ----------------------------------------------------------- | ------------------------------------------ | | A catch-all route where Studio mounts | Studio UI, runtime, and in-app navigation | | `MDCMS_STUDIO_ALLOWED_ORIGINS` includes your app's origin | Authentication flow and session management | | `mdcms.config.ts` with project, environment, and server URL | Content storage, versioning, and media | | Optional: custom MDX component registrations | Schema validation and API serving | | Optional: SDK queries for rendering content | Published content delivery | ## Installing packages Install `@mdcms/studio` to embed the visual editor. Add `@mdcms/sdk` if you also query content for rendering. ```bash npm theme={null} npm install @mdcms/studio # Optional: content SDK npm install @mdcms/sdk ``` ```bash bun theme={null} bun add @mdcms/studio # Optional: content SDK bun add @mdcms/sdk ``` ## Project configuration Your `mdcms.config.ts` (created by `mdcms init`) drives both CLI sync and Studio embedding. The fields that matter for integration: ```typescript mdcms.config.ts theme={null} import { defineConfig, defineType } from "@mdcms/cli"; import { z } from "zod"; const BlogPost = defineType("BlogPost", { directory: "content/blog", localized: true, fields: { title: z.string().min(1), slug: z.string().regex(/^[a-z0-9-]+$/), }, }); export default defineConfig({ // Core runtime fields used by StudioEmbedConfig: project: "my-site", environment: "production", serverUrl: "https://cms.example.com", // Optional — locales are not part of StudioEmbedConfig but are // used by Studio when available on the full MdcmsConfig: locales: { default: "en", supported: ["en", "fr"], }, // Schema and sync config: contentDirectories: ["content"], types: [BlogPost], }); ``` See the [Schema Guide](/guide/schema/defining-types) for field types, references, and environment overlays. ## Live preview routes Real-app live preview is configured per content type in `mdcms.config.ts`. Keep route mapping with the model definition so Studio, the CLI, and host app code agree on where a document should render. ```typescript mdcms.config.ts theme={null} const BlogPost = defineType("BlogPost", { directory: "content/blog", fields: { title: z.string().min(1), slug: z.string().min(1), }, resolvePreviewUrl(document) { const slug = document.frontmatter.slug; return typeof slug === "string" ? `/preview/blog/${slug}` : undefined; }, }); ``` When Studio opens a preview pane it: 1. resolves the preview URL from the persisted draft snapshot; 2. asks the MDCMS server for a short-lived preview token; 3. appends that token as `mdcms_preview_token`; 4. loads the tokenized URL in the iframe; 5. waits for the framed route to post `{ type: "mdcms:live-preview-ready" }`. Send the ready message from client-side code after the preview page has rendered: ```tsx theme={null} useEffect(() => { window.parent.postMessage({ type: "mdcms:live-preview-ready" }, "*"); }, []); ``` Private preview routes should verify the token before fetching drafts: ```typescript theme={null} import { createClient } from "@mdcms/sdk"; const cms = createClient({ serverUrl: process.env.MDCMS_SERVER_URL!, apiKey: process.env.MDCMS_PREVIEW_API_KEY!, project: process.env.MDCMS_PROJECT!, environment: process.env.MDCMS_ENVIRONMENT!, fetch: (input, init) => fetch(input, { ...init, cache: "no-store", }), }); export const dynamic = "force-dynamic"; export async function GET(request: Request) { const document = await cms.getPreviewDocumentFromRequest(request, { secret: process.env.MDCMS_PREVIEW_TOKEN_SECRET!, }); return Response.json(document, { headers: { "Cache-Control": "private, no-store", }, }); } ``` `preview=true` can be useful as a readable mode flag, but it is not proof that the caller may read private drafts. If your deployment intentionally makes drafts public, document that decision and still avoid reusing static published-page caches for preview responses. ## Embedding Studio Studio is a React component that owns all rendering under a catch-all route (e.g., `/admin/*`). The recommended pattern splits server and client concerns so the config can be safely serialized across the boundary. ### Basic setup Use this when you do not register custom MDX components. The server component imports your config, strips non-serializable values via `createStudioEmbedConfig`, and passes the result to the client component. ```tsx app/admin/[[...path]]/page.tsx theme={null} import { createStudioEmbedConfig } from "@mdcms/studio/runtime"; import config from "../../../mdcms.config"; import { AdminStudioClient } from "./admin-studio-client"; export default async function AdminPage() { return ; } ``` The client component renders Studio. The `basePath` prop tells Studio which URL prefix it owns. ```tsx app/admin/[[...path]]/admin-studio-client.tsx theme={null} "use client"; import { Studio, type MdcmsConfig } from "@mdcms/studio"; export function AdminStudioClient({ config }: { config: MdcmsConfig }) { return ; } ``` `basePath` is required. Studio cannot infer its mount prefix from deep links like `/admin/content/posts/abc`. Set it to the path prefix your catch-all route uses. **Other React frameworks:** The pattern is the same for any framework — create a catch-all route that renders the `` component. In Remix, use a splat route (`admin.$.tsx`). In Vite with React Router, use a wildcard route (`/admin/*`). The key requirement is that Studio receives all sub-paths under its mount point. ### Setup with MDX components If your app registers custom MDX components for the Studio editor (e.g., ``, ``, ``), use `prepareStudioConfig` on the server to extract TypeScript prop metadata at build time. Add component registrations to your `mdcms.config.ts`. Each component needs a `name`, `importPath`, and a `load` function. Optional: `propHints` for widget customization, `propsEditor`/`loadPropsEditor` for fully custom prop editing UI. ```typescript mdcms.config.ts theme={null} import { defineConfig, defineType } from "@mdcms/cli"; export const mdxComponents = [ { name: "Chart", importPath: "./components/mdx/Chart", description: "Inline data chart.", load: () => import("./components/mdx/Chart").then((m) => m.Chart), propHints: { color: { widget: "color-picker" }, }, }, { name: "Callout", importPath: "./components/mdx/Callout", description: "Styled callout block with nested content.", load: () => import("./components/mdx/Callout").then((m) => m.Callout), }, ] as const; export default defineConfig({ project: "my-site", environment: "production", serverUrl: "https://cms.example.com", contentDirectories: ["content"], components: [...mdxComponents], types: [/* your types */], }); ``` `prepareStudioConfig` reads your component source files and extracts TypeScript prop types so the Studio editor can generate form controls automatically. ```tsx app/admin/[[...path]]/page.tsx theme={null} import { resolve } from "node:path"; import { prepareStudioConfig } from "@mdcms/studio/runtime"; import config from "../../../mdcms.config"; import { AdminStudioClient } from "./admin-studio-client"; export default async function AdminPage() { const appRoot = process.cwd(); const prepared = await prepareStudioConfig(config, { cwd: appRoot, tsconfigPath: resolve(appRoot, "tsconfig.json"), }); // Extract only the serializable metadata for the client const componentMeta = prepared.components?.map((c) => ({ name: c.name, ...(c.extractedProps ? { extractedProps: c.extractedProps } : {}), })) ?? []; return ( ); } ``` The client component merges extracted prop metadata back onto the component registrations (which include the runtime `load` callbacks). ```tsx app/admin/[[...path]]/admin-studio-client.tsx theme={null} "use client"; import { Studio, type MdcmsConfig } from "@mdcms/studio"; import { mdxComponents } from "../../../mdcms.config"; type ComponentMeta = { name: string; extractedProps?: Record }; export function AdminStudioClient(props: { preparedComponents: ComponentMeta[]; schemaHash?: string; }) { const extractedByName = new Map( props.preparedComponents.map((c) => [c.name, c.extractedProps]), ); const config: MdcmsConfig = { project: "my-site", environment: "production", serverUrl: "https://cms.example.com", ...(props.schemaHash ? { _schemaHash: props.schemaHash } : {}), components: [...mdxComponents].map((component) => { const extracted = extractedByName.get(component.name); return extracted ? { ...component, extractedProps: extracted } : component; }), }; return ; } ``` `prepareStudioConfig` reads your TypeScript source files at build time. It requires `cwd` pointing to your project root and `tsconfigPath` to your `tsconfig.json`. If your components are in a separate package, adjust these paths accordingly. ## Authentication and CORS Studio communicates directly with the MDCMS server API. Authentication determines how Studio identifies the current user. The default mode. Studio sends requests with `credentials: "include"` and the server manages session cookies. CSRF protection is automatic via the `x-mdcms-csrf-token` header. ```tsx theme={null} ``` **Required server config:** The MDCMS server must allow your host app's origin for CORS and cookie delivery: ```bash theme={null} MDCMS_STUDIO_ALLOWED_ORIGINS=https://myapp.example.com ``` For local development with HTTP: ```bash theme={null} MDCMS_STUDIO_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 MDCMS_AUTH_INSECURE_COOKIES=true ``` For headless or non-browser contexts, pass a Bearer API key directly: ```tsx theme={null} ``` API keys are created in **Settings > API Keys** inside Studio and use the `mdcms_key_` prefix. ### Studio props reference | Prop | Type | Description | | ------------ | -------------------------------------------------------- | ---------------------------------------------------------------------- | | `config` | `MdcmsConfig` | Project, environment, server URL, and optional component registrations | | `basePath` | `string` | URL prefix where Studio is mounted (e.g., `/admin`) | | `auth` | `{ mode: "cookie" } \| { mode: "token"; token: string }` | Authentication strategy (defaults to cookie) | | `hostBridge` | `HostBridgeV1` | Optional bridge for MDX preview rendering from the host app | | `fetcher` | `typeof fetch` | Custom fetch implementation | ## Fetching content with the SDK If your app renders MDCMS content (blog posts, pages, etc.), use `@mdcms/sdk` to query the API: ```typescript theme={null} import { createClient } from "@mdcms/sdk"; const cms = createClient({ serverUrl: "https://cms.example.com", apiKey: process.env.MDCMS_API_KEY!, project: "my-site", environment: "production", }); // List published posts const { data: posts } = await cms.list("BlogPost", { locale: "en", published: true, limit: 10, }); // Get a single post by slug const post = await cms.get("BlogPost", { slug: "hello-world", resolve: ["author"], }); ``` See the [SDK reference](/api-reference/sdk) for filtering, pagination, error handling, and Next.js patterns. ## Production checklist Before going live, verify each item: 1. **HTTPS everywhere** — both the MDCMS server and your host app serve over HTTPS 2. **Secure cookies** — `MDCMS_AUTH_INSECURE_COOKIES` is removed or set to `false` 3. **CORS locked down** — `MDCMS_STUDIO_ALLOWED_ORIGINS` lists only your production origin, not `localhost` 4. **Production mode** — `NODE_ENV=production` on the MDCMS server 5. **Strong credentials** — database, S3, and Redis credentials are unique and not defaults 6. **API keys scoped** — keys are scoped to the minimum required permissions and rotated periodically 7. **Real email provider** — SMTP is configured with a production provider, not Mailhog See the [Self-Hosting guide](/guide/self-hosting#production-considerations) for detailed server-side production hardening. ## Next steps Dashboard overview, navigation, and all Studio features Field types, references, environment overlays, and MDX components Push, pull, login, schema sync, and all CLI flags Full typed client API for content reads # Quick Start Source: https://docs.mdcms.ai/guide/quickstart Install MDCMS packages and start managing content in your project Get MDCMS running in your own application. This guide assumes you have an MDCMS server available (self-hosted or managed). Need to set up your own server first? Follow the [Self-Hosting guide](/guide/self-hosting) to get MDCMS running with Docker Compose, then come back here. Looking to contribute to MDCMS itself? See [Development Setup](/development/setup). **Using an AI coding agent?** `npx skills add mdcms-ai/mdcms/skills` installs the [MDCMS Skills Pack](/agent-skills) so your agent can walk through this quickstart for you. ## 1. Install the CLI ```bash npm theme={null} npm install --save-dev @mdcms/cli ``` ```bash bun theme={null} bun add -D @mdcms/cli ``` ## 2. Initialize your project The `mdcms init` wizard configures everything — server connection, project/environment selection, content type detection, and initial sync: ```bash theme={null} npx mdcms init ``` It will: * Ask for your MDCMS server URL * Open a browser for authentication * Scan your repo for existing Markdown/MDX files * Generate a `mdcms.config.ts` with inferred content types * Push the initial schema and content to the server See [CLI Installation](/guide/cli/installation) for details on the init wizard. ## 3. Define your content schema The generated `mdcms.config.ts` is your starting point. Customize it to match your content model: ```typescript mdcms.config.ts theme={null} import { defineConfig, defineType, fieldTypes } from "@mdcms/cli"; import { z } from "zod"; const Author = defineType("Author", { directory: "content/authors", fields: { name: z.string().min(1), bio: z.string().optional(), }, }); const BlogPost = defineType("BlogPost", { directory: "content/blog", localized: true, fields: { title: z.string().min(1), author: fieldTypes.reference("Author"), tags: z.array(z.string()), }, }); export default defineConfig({ project: "my-site", environment: "production", serverUrl: "https://your-mdcms-server.example.com", contentDirectories: ["content"], locales: { default: "en", supported: ["en", "fr"], }, types: [Author, BlogPost], }); ``` See the [Schema Guide](/guide/schema/defining-types) for field types, references, and environment overlays. ## 4. Sync schema and content ```bash theme={null} # Push your schema to the server npx mdcms push # Pull content from the server to local Markdown files npx mdcms pull # Edit .md or .mdx files with any tool, then push changes back npx mdcms push ``` See the [CLI Commands](/guide/cli/commands) reference for all available flags. ## 5. Fetch content in your app Install the SDK: ```bash npm theme={null} npm install @mdcms/sdk ``` ```bash bun theme={null} bun add @mdcms/sdk ``` Query content in your application: ```typescript theme={null} import { createClient } from "@mdcms/sdk"; const cms = createClient({ serverUrl: "https://your-mdcms-server.example.com", apiKey: process.env.MDCMS_API_KEY!, project: "my-site", environment: "production", }); // List published blog posts const { data: posts } = await cms.list("BlogPost", { locale: "en", published: true, limit: 10, }); // Fetch a single post with resolved references const post = await cms.get("BlogPost", { slug: "hello-world", resolve: ["author"], }); ``` See the [SDK reference](/api-reference/sdk) for the full API. ## 6. Embed the Studio UI (optional) Add a visual editing interface to your app by installing `@mdcms/studio` and mounting it on a catch-all route: ```tsx app/admin/[[...path]]/page.tsx theme={null} import { createStudioEmbedConfig } from "@mdcms/studio/runtime"; import config from "../../../mdcms.config"; import { AdminStudioClient } from "./admin-studio-client"; export default async function AdminPage() { return ; } ``` For the full setup — including MDX component registration, authentication, CORS configuration, and production hardening — see the [Integration Guide](/guide/integration). ## Next steps Push, pull, login, schema sync, and all CLI flags Field types, references, environment overlays, MDX components Visual content editing, publishing, and version history Full typed client API for content reads # Defining Content Types Source: https://docs.mdcms.ai/guide/schema/defining-types Content type definitions, field types, modifiers, and validation with Zod MDCMS uses a code-first schema system. You define content types in `mdcms.config.ts` using TypeScript and Zod, then sync the schema to your server. The schema drives Studio form generation, content validation, and API behavior automatically — there is no manual schema editor in Studio. ## Basic syntax Use `defineType()` to create a content type and `defineConfig()` to register it: ```typescript mdcms.config.ts theme={null} import { defineConfig, defineType } from "@mdcms/cli"; import { z } from "zod"; const Page = defineType("Page", { directory: "content/pages", fields: { title: z.string().min(1), description: z.string().optional(), }, }); export default defineConfig({ project: "my-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], types: [Page], }); ``` ## Type options Each type accepts the following options: | Option | Type | Required | Description | | ----------- | --------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------- | | `directory` | `string` | Yes | Filesystem path where documents of this type are stored. Must be covered by `contentDirectories`. | | `localized` | `boolean` | No (default: `false`) | Whether documents of this type support per-locale variants. Requires `locales` config when `true`. | | `fields` | `Record` | Yes | Map of field names to Zod schemas. Each field becomes a frontmatter property and a Studio form control. | ## Field types Every Zod type maps to a specific Studio form control. MDCMS serializes each field into a schema registry snapshot that drives both server-side validation and client-side rendering. ### Primitives | Zod schema | Registry kind | Studio control | Notes | | ----------------- | ------------- | -------------- | -------------------------------------------- | | `z.string()` | `string` | Text input | Base type for most text fields | | `z.number()` | `number` | Number input | Supports `.int()`, `.min()`, `.max()` checks | | `z.boolean()` | `boolean` | Toggle switch | Commonly paired with `.default(false)` | | `z.coerce.date()` | `date` | Date picker | Coerces string values to `Date` objects | ```typescript theme={null} fields: { title: z.string(), viewCount: z.number(), featured: z.boolean(), publishedAt: z.coerce.date(), } ``` ### Complex types | Zod schema | Registry kind | Studio control | Notes | | ------------------------- | ------------------------ | ------------------------ | -------------------------------------- | | `z.array(z.string())` | `array` (item: `string`) | Tag input | Repeatable string entries | | `z.array(z.number())` | `array` (item: `number`) | Repeatable number inputs | | | `z.object({...})` | `object` | Nested fieldset | Each property rendered recursively | | `z.enum(["a", "b", "c"])` | `enum` | Dropdown select | Options extracted from enum entries | | `z.literal("x")` | `literal` | Hidden / read-only | Fixed value, useful for discriminators | ```typescript theme={null} fields: { tags: z.array(z.string()), scores: z.array(z.number()), seo: z.object({ metaTitle: z.string(), metaDescription: z.string().optional(), }), status: z.enum(["draft", "review", "published"]), kind: z.literal("article"), } ``` ## Modifiers Modifiers control whether fields are required, nullable, or have defaults. They affect both the schema registry snapshot and Studio form behavior. | Modifier | Effect | Registry impact | | ----------------- | ------------------------------------------------ | ------------------------------------- | | `.optional()` | Field can be omitted entirely | `required: false` | | `.nullable()` | Field can be explicitly set to `null` | `nullable: true` | | `.default(value)` | Provides a default value; field becomes optional | `required: false`, `default: ` | ```typescript theme={null} fields: { subtitle: z.string().optional(), deletedAt: z.coerce.date().nullable(), featured: z.boolean().default(false), tags: z.array(z.string()).default([]), } ``` A field with `.default()` is implicitly optional in the schema registry. The default value is stored in the registry snapshot and applied during validation. ## Validation Zod check methods are serialized into the schema registry as `checks` entries. They run both server-side (on write) and in Studio (on form input). ### Common validators ```typescript theme={null} fields: { title: z.string().min(1).max(200), slug: z.string().regex(/^[a-z0-9-]+$/), email: z.string().email(), rating: z.number().int().min(1).max(5), url: z.string().url(), } ``` ### Available checks by type \| Method | Description | |---|---| | `.min(n)` | Minimum length | | `.max(n)` \| Maximum length | | `.email()` | Valid email format | | `.url()` | Valid URL format | | `.regex(pattern)` | Matches regular expression (pattern and flags are serialized) | \| Method | Description | |---|---| | `.min(n)` | Minimum value | | `.max(n)` | Maximum value | | `.int()` | Must be an integer | \| Method | Description | |---|---| | `.min(n)` | Minimum array length | | `.max(n)` | Maximum array length | Custom Zod validators (`.refine()`, `.transform()`, `.pipe()`) cannot be serialized to the schema registry and will cause a sync error. Use only built-in Zod checks. ## Registering types Add your types to the `types` array in `defineConfig()`: ```typescript mdcms.config.ts theme={null} export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], types: [Author, BlogPost, Page], }); ``` Then sync the schema to your server: ```bash theme={null} mdcms schema sync ``` This serializes every type into a registry snapshot, computes a schema hash, and uploads it to the server. The server stores the resolved schema and uses it for all subsequent content validation. ## Schema hash Every write operation to the MDCMS API requires an `x-mdcms-schema-hash` header that matches the currently synced schema. This prevents writes against a stale schema. * The CLI and SDK handle the schema hash automatically. * If the hash doesn't match, the server returns a `SCHEMA_HASH_MISMATCH` error. * Re-run `mdcms schema sync` to update after config changes. In CI/CD pipelines, always run `mdcms schema sync` before `mdcms push` to ensure the schema hash is current. ## Environment overlays Environment overlays let you customize the schema per environment without duplicating type definitions. Define them in the `environments` config: ```typescript mdcms.config.ts theme={null} export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], types: [Author, BlogPost], environments: { production: {}, staging: { extends: "production", types: { BlogPost: { add: { previewToken: z.string().optional() }, modify: { title: z.string().min(1).max(500) }, omit: ["seoScore"], }, }, }, }, }); ``` ### Overlay operations | Operation | Description | | --------- | ------------------------------------------------------------------------------------- | | `add` | Add new fields that don't exist on the base type. Errors if the field already exists. | | `modify` | Replace the schema for an existing field. Errors if the field doesn't exist. | | `omit` | Remove fields by name. Errors if the field doesn't exist. | | `extends` | Inherit from another environment instead of the base types. Supports chaining. | ### Field-level environment targeting For simpler cases, you can use the `.env()` modifier directly on field definitions to target specific environments: ```typescript theme={null} const Post = defineType("post", { directory: "content/posts", fields: { title: z.string().min(1), featured: z.boolean().default(false).env("staging"), abTestVariant: z.string().min(1).optional().env("staging"), }, }); ``` Fields with `.env()` are only included in the specified environments. This is syntactic sugar that gets expanded into `add` overlays during config parsing. You cannot use `.env()` on fields inside overlay `add`/`modify` maps. Keep environment targeting on the base type only. ## Complete example A full configuration with multiple types, localization, validation, references, and environment overlays: ```typescript mdcms.config.ts theme={null} import { defineConfig, defineType, fieldTypes } from "@mdcms/cli"; import { z } from "zod"; const Author = defineType("Author", { directory: "content/authors", fields: { name: z.string().min(1), bio: z.string().max(500).optional(), avatar: z.string().url().optional(), }, }); const BlogPost = defineType("BlogPost", { directory: "content/blog", localized: true, fields: { title: z.string().min(1).max(200), slug: z.string().regex(/^[a-z0-9-]+$/), author: fieldTypes.reference("Author"), publishedAt: z.coerce.date(), tags: z.array(z.string()).default([]), excerpt: z.string().max(300).optional(), featured: z.boolean().default(false), }, }); export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], locales: { default: "en", supported: ["en", "fr", "de"], }, types: [Author, BlogPost], environments: { production: {}, staging: { extends: "production", types: { BlogPost: { add: { previewToken: z.string().optional() }, }, }, }, }, }); ``` Changing type names or removing types that have existing content requires a migration. Use `mdcms migrate` to handle these changes safely. # MDX Components Source: https://docs.mdcms.ai/guide/schema/mdx-components Registering custom components for the Studio editor MDX components let you define custom React components that content authors can insert directly in the Studio editor. Each component gets a visual prop editor generated from its TypeScript types, so authors fill in structured data through form controls instead of writing raw JSX. ## Registration Register components in the `components` array of your config. Each entry needs a `name`, an `importPath` for Studio to resolve the component, and optionally a `description` and `propHints`: ```typescript mdcms.config.ts theme={null} import { defineConfig } from "@mdcms/cli"; export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], types: [ /* ... */ ], components: [ { name: "Callout", importPath: "./components/mdx/Callout", description: "A styled callout box for tips, warnings, or notes", load: () => import("./components/mdx/Callout").then((m) => m.Callout), }, { name: "VideoEmbed", importPath: "./components/mdx/VideoEmbed", description: "Embed a video player", load: () => import("./components/mdx/VideoEmbed").then((m) => m.VideoEmbed), propHints: { url: { format: "url" }, }, }, ], }); ``` ### Registration options | Option | Type | Required | Description | | ----------------- | -------------------------- | -------- | ------------------------------------------------------------------------ | | `name` | `string` | Yes | Component name. Must match the exported React component name. | | `importPath` | `string` | Yes | Module path for Studio to resolve the component at runtime. | | `description` | `string` | No | Shown in the component catalog to help authors pick the right component. | | `load` | `() => Promise` | No | Dynamic import function for Studio to load the component at runtime. | | `propHints` | `Record` | No | Override auto-detected prop types with specific widgets. | | `propsEditor` | `string` | No | Module path to a custom props editor component, for complex editing UIs. | | `loadPropsEditor` | `() => Promise` | No | Dynamic import for the custom props editor. | ## Prop extraction MDCMS uses TypeScript's compiler API to extract prop types from your component source files. Each prop is mapped to a form control in Studio based on its type: | TypeScript type | Extracted type | Studio control | | ------------------------------------------- | ------------------------- | ------------------------ | | `string` | `string` | Text input | | `number` | `number` | Number input | | `boolean` | `boolean` | Toggle switch | | `Date` | `date` | Date picker | | `"a" \| "b" \| "c"` (string literal union) | `enum` | Dropdown select | | `string[]` | `array` (items: `string`) | Tag input | | `number[]` | `array` (items: `number`) | Repeatable number inputs | | `ReactNode` / `children` | `rich-text` | Nested rich-text editor | | JSON-serializable object (with `json` hint) | `json` | JSON editor | Props with function types, ref types, or other non-serializable types are automatically excluded from the form. Only serializable props appear in the Studio editor. ### Example component ```tsx components/mdx/Callout.tsx theme={null} type CalloutProps = { type: "info" | "warning" | "error"; title: string; children: React.ReactNode; }; export function Callout({ type, title, children }: CalloutProps) { return (
{title}
{children}
); } ``` From this component, MDCMS extracts: * `type` as `enum` with values `["info", "warning", "error"]` (dropdown) * `title` as `string` (text input) * `children` as `rich-text` (nested content editor) ## Prop hints Prop hints override the auto-detected form control for a given prop. Use them when the default control doesn't match the intended input experience. ### Available hints Renders a text input with URL validation. Only valid for `string` props. ```typescript theme={null} propHints: { imageUrl: { format: "url" }, } ``` Renders a color picker input. Only valid for `string` props. ```typescript theme={null} propHints: { backgroundColor: { widget: "color-picker" }, } ``` Renders a multi-line textarea instead of a single-line text input. Only valid for `string` props. ```typescript theme={null} propHints: { description: { widget: "textarea" }, } ``` Renders a range slider. Only valid for `number` props. Requires `min` and `max`; `step` is optional. ```typescript theme={null} propHints: { opacity: { widget: "slider", min: 0, max: 100, step: 5 }, } ``` Renders an image picker/uploader. Only valid for `string` props (stores the image URL). ```typescript theme={null} propHints: { thumbnail: { widget: "image" }, } ``` Renders a dropdown with explicit options. Valid for `string`, `number`, `boolean`, or `enum` props. Options can be simple values or `{ label, value }` objects. ```typescript theme={null} propHints: { size: { widget: "select", options: [ { label: "Small", value: "sm" }, { label: "Medium", value: "md" }, { label: "Large", value: "lg" }, ], }, } ``` Hides the prop from the Studio form entirely. Useful for internal props that should not be edited by content authors. ```typescript theme={null} propHints: { internalId: { widget: "hidden" }, } ``` Renders a JSON editor for complex structured data. Only valid for props whose TypeScript type is JSON-serializable. ```typescript theme={null} propHints: { config: { widget: "json" }, } ``` Prop hints must be compatible with the extracted prop type. For example, applying `widget: "slider"` to a `string` prop will cause a config validation error. The compatibility rules are enforced at build time. ## Inserting components in Studio Content authors can insert registered components in two ways: Type `/` in the editor to open the command palette. The component catalog shows all registered components with their names and descriptions. Use the editor toolbar to browse and insert components from the catalog. After insertion, the prop editor panel appears. Fill in the form controls to configure the component. ## Output format Inserted components are written as standard MDX syntax in the document body: ```mdx theme={null} # My Blog Post Some introductory text. MDCMS supports custom MDX components with visual editing. ``` Components without children render as self-closing tags. Components with `children` (rich-text) props render as wrapper tags with nested content. ## Wrapper components If your component accepts a `children` prop typed as `ReactNode`, it becomes a **wrapper component**. Studio renders a content hole inside the component block where authors can write rich text, insert other components, or add any editor content. ```tsx components/mdx/Callout.tsx theme={null} type CalloutProps = { type: "info" | "warning" | "error"; title: string; children: React.ReactNode; // enables nested rich-text editing }; export function Callout({ type, title, children }: CalloutProps) { return (
{title}
{children}
); } ``` Components without a `children` prop are **void components** — they render as self-closing tags and don't accept nested content. ## Custom props editors For components with complex prop structures that don't map well to auto-generated forms, you can provide a fully custom props editor: ```typescript theme={null} { name: "PricingTable", importPath: "./components/mdx/PricingTable", description: "Pricing grid with configurable tiers", load: () => import("./components/mdx/PricingTable").then((m) => m.PricingTable), propsEditor: "./components/mdx/PricingTable.editor", loadPropsEditor: () => import("./components/mdx/PricingTable.editor").then((m) => m.default), } ``` The custom props editor receives the current prop values and a callback to update them. It replaces the auto-generated form entirely for that component. Use custom props editors sparingly. The auto-generated form with prop hints covers most cases. Reserve custom editors for components like data tables, chart builders, or other interactive configuration UIs. # References Source: https://docs.mdcms.ai/guide/schema/references Cross-type references, resolution, and error handling References let you create relationships between content types. A `BlogPost` can reference an `Author`, a `Page` can reference related `BlogPost` entries, and so on. MDCMS handles storage, validation, and resolution of these relationships. ## Defining references Use the `fieldTypes.reference()` helper to create a reference field. It accepts the target type name as a string: ```typescript mdcms.config.ts theme={null} import { defineType, fieldTypes } from "@mdcms/cli"; import { z } from "zod"; const BlogPost = defineType("BlogPost", { directory: "content/blog", fields: { title: z.string().min(1), author: fieldTypes.reference("Author"), reviewer: fieldTypes.reference("Author").optional(), relatedPosts: z.array(fieldTypes.reference("BlogPost")).optional(), }, }); ``` Under the hood, `fieldTypes.reference()` returns a `z.string()` schema with metadata that marks it as a reference to the target type. This means reference fields support the same modifiers as strings: `.optional()`, `.nullable()`, and `.default()`. ## What gets stored Reference fields store a **documentId** (UUID) in frontmatter, not a file path or slug. When you create or update a document through Studio or the API, MDCMS validates that the referenced documentId exists, belongs to the correct type, and has not been deleted. ```yaml theme={null} # What the frontmatter looks like on disk --- title: "Getting Started with MDCMS" author: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" --- ``` The server validates reference identity on every write. If you supply a documentId that doesn't exist, belongs to a different type, or references a deleted document, the write will fail with an `INVALID_INPUT` error. ## Resolving references By default, the API returns the raw documentId string for reference fields. To expand references into full document objects, use the `resolve` query parameter. ```bash cURL theme={null} # Resolve the author field curl "http://localhost:4000/api/content/blog/my-post?type=BlogPost&resolve=author" ``` ```typescript SDK theme={null} const post = await client.content.get("blog/my-post", { type: "BlogPost", resolve: ["author"], }); // post.frontmatter.author is now the full Author document object ``` ### Resolved response structure When a reference is resolved successfully, the raw UUID is replaced with the full document response object: ```json theme={null} { "documentId": "...", "path": "blog/my-post", "type": "BlogPost", "frontmatter": { "title": "Getting Started with MDCMS", "author": { "documentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "path": "authors/jane-doe", "type": "Author", "frontmatter": { "name": "Jane Doe" } } } } ``` ### Nested field paths You can resolve references inside nested objects using dot notation: ```bash theme={null} # Resolve a reference inside a nested object field curl "http://localhost:4000/api/content/posts/my-post?type=Post&resolve=metadata.reviewer" ``` The resolve path must point to a field that is registered as a reference in the schema. If the path targets a non-reference field, the server returns an `INVALID_QUERY_PARAM` error. ## Shallow resolution Reference resolution is **one level deep only**. If a resolved document itself contains reference fields, those nested references are returned as raw documentId strings, not expanded. For example, if `Author` had a `team: fieldTypes.reference("Team")` field, resolving `author` on a BlogPost would return the Author document with `team` as a UUID string. To get the Team document, you would need a separate API call. ## Resolve errors If a reference cannot be resolved, the field value is set to `null` and an entry is added to the `resolveErrors` map in the response. This map is keyed by the full frontmatter path (e.g., `frontmatter.author`). ```json theme={null} { "documentId": "...", "path": "blog/my-post", "type": "BlogPost", "frontmatter": { "title": "Getting Started with MDCMS", "author": null }, "resolveErrors": { "frontmatter.author": { "code": "REFERENCE_NOT_FOUND", "message": "Referenced document could not be resolved in the target project/environment.", "ref": { "documentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "type": "Author" } } } } ``` ### Error codes | Code | Description | | ------------------------- | ------------------------------------------------------------------------------------------------ | | `REFERENCE_NOT_FOUND` | The referenced document does not exist in the target project/environment. | | `REFERENCE_DELETED` | The referenced document has been soft-deleted. | | `REFERENCE_TYPE_MISMATCH` | The referenced document exists but belongs to a different type than the reference field expects. | | `REFERENCE_FORBIDDEN` | The referenced document exists but is not readable with the current API key scope. | Check for the `resolveErrors` key in API responses when using `resolve`. A missing key means all references resolved successfully. ## Studio behavior In Studio, reference fields render as a **document picker** filtered by the target type. When editing a BlogPost with an `author: fieldTypes.reference("Author")` field, the picker shows only Author documents. The document picker opens, showing documents of the target type. Filter by title or browse the list. Only documents matching the target type are shown. The selected document's ID is stored in frontmatter. Studio displays the document title as a preview. ## Multiple references Use `z.array(fieldTypes.reference("TypeName"))` for one-to-many relationships: ```typescript theme={null} fields: { authors: z.array(fieldTypes.reference("Author")), relatedPosts: z.array(fieldTypes.reference("BlogPost")).optional(), } ``` Array reference fields render as a repeatable document picker in Studio. Each entry in the array is validated independently. When resolving, pass the field name as usual — the entire array is resolved: ```bash theme={null} curl "http://localhost:4000/api/content/posts/my-post?type=Post&resolve=authors" ``` Deleting a referenced document does not cascade. Documents that reference the deleted document will get `REFERENCE_DELETED` or `REFERENCE_NOT_FOUND` resolve errors until the reference is updated or removed. # Self-Hosting Source: https://docs.mdcms.ai/guide/self-hosting Stand up your own MDCMS server with Docker Compose Run MDCMS on your own infrastructure. This guide walks you through setting up a self-hosted instance from scratch using Docker Compose. Already have a server running? Skip to the [Quick Start](/guide/quickstart) to install the CLI and start managing content. ## Prerequisites You need **Docker** and **Docker Compose** installed. No other runtime (Bun, Node.js) is required for running the server. | Tool | Version | Install | | -------------- | ---------------------------------- | ----------------------------------------------------------------- | | Docker | 24+ | [docs.docker.com/get-docker](https://docs.docker.com/get-docker/) | | Docker Compose | v2+ (included with Docker Desktop) | Bundled with Docker Desktop on macOS/Windows | `bash brew install --cask docker ` Follow the [official Docker install guide](https://docs.docker.com/engine/install/) for your distribution. Docker Compose v2 is included as a Docker plugin. Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/). ## Setup ```bash theme={null} git clone https://github.com/mdcms-ai/mdcms.git cd mdcms ``` Copy the example environment file and review it: ```bash theme={null} cp .env.example .env ``` The defaults work for local evaluation. For production, you should change database credentials, S3 keys, and configure auth providers. See the [environment variables](#environment-variables) section below. ```bash theme={null} docker compose up -d --build ``` This starts: * **MDCMS Server** on port `4000` — the API backend * **PostgreSQL 16** on port `5432` — primary database * **Redis 7** on port `6379` — sessions and caching * **MinIO** on ports `9000`/`9001` — S3-compatible object storage * **Mailhog** on ports `1025`/`8025` — email capture (dev/staging) Database migrations run automatically before the server starts. ```bash theme={null} curl http://localhost:4000/healthz ``` You should get a `200 OK` response. If the server is still starting, wait a few seconds and try again. ## Service Endpoints | Service | URL | Purpose | | ---------------- | ---------------------------------------------- | --------------------------------------------------------- | | MDCMS Server API | [http://localhost:4000](http://localhost:4000) | Backend API — all clients connect here | | MinIO Console | [http://localhost:9001](http://localhost:9001) | Object storage admin (default: `minioadmin`/`minioadmin`) | | Mailhog UI | [http://localhost:8025](http://localhost:8025) | Captured email viewer (auth flows, invites) | | PostgreSQL | `localhost:5432` | Direct database access | | Redis | `localhost:6379` | Direct cache access | ## First Boot After the server starts, you need to create your first user and connect the CLI. ### Create an admin user Open the Studio UI from your host app (see [Embed the Studio UI](#embed-the-studio-ui) below), or use the CLI to authenticate: ```bash theme={null} npx mdcms login --server-url http://localhost:4000 ``` This opens your browser for authentication. The first user to authenticate becomes the instance owner. ### Connect the CLI Initialize a project against your server: ```bash theme={null} npx mdcms init --server-url http://localhost:4000 ``` The init wizard walks you through project creation, environment selection, content directory scanning, and initial schema sync. See the [CLI Installation](/guide/cli/installation) guide for details. ### Embed the Studio UI Add the visual content editor to your application. Install `@mdcms/studio` and mount it on a catch-all route in your app. See the [Integration Guide](/guide/integration) for full step-by-step instructions covering Studio embedding, MDX components, authentication, and CORS setup. ## Environment Variables ### Required These must be set for the server to start. The Docker Compose defaults handle these automatically for local use. | Variable | Description | Default | | --------------- | ------------------------------ | ---------------------------------------------- | | `DATABASE_URL` | PostgreSQL connection string | `postgresql://mdcms:mdcms@postgres:5432/mdcms` | | `REDIS_URL` | Redis connection string | `redis://redis:6379` | | `S3_ENDPOINT` | S3-compatible storage endpoint | `http://minio:9000` | | `S3_ACCESS_KEY` | S3 access key | `minioadmin` | | `S3_SECRET_KEY` | S3 secret key | `minioadmin` | | `S3_BUCKET` | S3 bucket name | `mdcms-media` | ### Server | Variable | Description | Default | | ----------- | ------------------------------------------------ | ------------- | | `PORT` | Server listen port | `4000` | | `NODE_ENV` | Runtime environment | `development` | | `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` | ### Email | Variable | Description | Default | | ----------- | -------------------- | --------- | | `SMTP_HOST` | SMTP server hostname | `mailhog` | | `SMTP_PORT` | SMTP server port | `1025` | The default email configuration uses Mailhog, which captures all outgoing email without delivering it. For production, configure a real SMTP provider. ### Studio | Variable | Description | Default | | ------------------------------ | ----------------------------------------------------------- | ------- | | `MDCMS_STUDIO_ALLOWED_ORIGINS` | Comma-separated CORS origins for Studio embedding | — | | `MDCMS_AUTH_INSECURE_COOKIES` | Allow HTTP cookies (set `true` for local dev without HTTPS) | `false` | ### Auth Providers (optional) Configure SSO providers for your organization. Providers are enabled by presence — no feature flag is needed. | Variable | Description | | --------------------------- | --------------------------------------------------------- | | `MDCMS_AUTH_OIDC_PROVIDERS` | JSON array of OIDC provider configurations | | `MDCMS_AUTH_SAML_PROVIDERS` | JSON array of SAML provider configurations | | `MDCMS_AUTH_ADMIN_EMAILS` | Comma-separated emails that get admin role on first login | ```json theme={null} [ { "providerId": "okta", "issuer": "https://example.okta.com/oauth2/default", "domain": "example.com", "clientId": "your-client-id", "clientSecret": "your-client-secret" } ] ``` ```json theme={null} [ { "providerId": "okta-saml", "issuer": "https://www.okta.com/exk123456789", "domain": "example.com", "entryPoint": "https://example.okta.com/app/example/sso/saml", "cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", "audience": "https://cms.example.com/saml/okta-saml/sp", "spEntityId": "https://cms.example.com/saml/okta-saml/sp" } ] ``` ## Production Considerations ### Security * Set strong, unique values for `S3_ACCESS_KEY`, `S3_SECRET_KEY`, and database credentials * Configure `MDCMS_STUDIO_ALLOWED_ORIGINS` to restrict CORS to your actual Studio host domain * Set `NODE_ENV=production` to enable secure cookie defaults and disable development features * Remove `MDCMS_AUTH_INSECURE_COOKIES` (or set to `false`) when running behind HTTPS * Replace Mailhog with a production SMTP provider ### Persistence The Docker Compose configuration uses named volumes (`pgdata`, `miniodata`) for PostgreSQL and MinIO. These persist across container restarts. To back up your data: ```bash theme={null} # Dump the database docker compose exec postgres pg_dump -U mdcms mdcms > backup.sql # Restore from a dump docker compose exec -T postgres psql -U mdcms mdcms < backup.sql ``` ### Upgrading To upgrade to a newer version of MDCMS: ```bash theme={null} git pull origin main docker compose up -d --build ``` Migrations run automatically on server startup. The migration runner completes before the server accepts traffic, so there is no window where the server runs against an outdated schema. ### Using an External Database To use an existing PostgreSQL instance instead of the bundled container, set `DATABASE_URL` to your external connection string and remove the `postgres` service from `docker-compose.yml`. The same applies to Redis (`REDIS_URL`) and S3-compatible storage (`S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`). ## Stopping and Resetting ```bash theme={null} # Stop all services (preserves data) docker compose down # Stop and remove all data volumes (fresh start) docker compose down -v ``` ## Next Steps Install the CLI, define your schema, and start managing content Push, pull, login, schema sync, and all CLI flags Embed the visual content editor in your application REST API endpoints, authentication, and the TypeScript SDK # Content Editor Source: https://docs.mdcms.ai/guide/studio/content-editor Creating, editing, and managing documents in the Studio ## Browsing content Each content type has a paginated document table at `/admin/content/:type`. The table includes: | Column | Description | | ------------ | --------------------------------------------------------------------------- | | Title / Path | Document title from frontmatter, with the filesystem path below it | | Translations | Locale coverage summary (only for localized types, e.g., "2/4 locales") | | Status | **Published**, **Draft**, or **Changed** (published with unpublished edits) | | Updated | Relative timestamp (e.g., "5 min ago", "Yesterday") | | Author | Avatar and initials of the creator | The table supports: * **Server-side search** -- Debounced text search across document titles and paths * **Status filter** -- All, Published, Draft only, Has changes * **Sort order** -- Last updated, Created, Path A-Z, Path Z-A * **Pagination** -- Server-side with page navigation controls Row actions are available via a dropdown menu on each row: Edit, Publish, Unpublish, Duplicate, and Delete (soft-delete to trash). ## Creating a document Go to **Content** in the sidebar, then select the type you want to create a document for (e.g., `BlogPost`). Click the **New Document** button in the top-right corner. A dialog appears asking for the document path and, for localized types, the locale. Enter a path relative to the type's content directory (e.g., `my-first-post.mdx`). For localized types, select the target locale. In the right sidebar under the **Fields** tab, fill in schema-driven frontmatter fields. Required fields are marked with an asterisk. Use the TipTap-based MDX editor on the left panel to write your document content. The editor supports standard Markdown formatting and MDX component insertion. Changes auto-save approximately 5 seconds after your last edit, or immediately when the editor loses focus. ## Editor layout The document editor at `/admin/content/:type/:documentId` uses a two-panel layout: * **Left panel** -- TipTap rich text editor for the document body. Supports Markdown formatting, headings, lists, code blocks, and MDX component insertion. * **Right sidebar** -- Tabbed panel with: * **Fields** -- Schema-driven frontmatter form with type-appropriate controls * **Info** -- Read-only document metadata (document ID, translation group ID, path, schema type, content format, locale, status, published version, draft revision, created by, last updated by) * **SEO** -- Coming soon (disabled tab) When an MDX component node is selected in the editor, the sidebar also shows the MDX props panel below the tabs for editing component properties. ## Live preview If a content type defines `resolvePreviewUrl` in `mdcms.config.ts`, the document editor can show a split or full-width real-app preview pane. The resolver receives the latest persisted draft snapshot and returns the host route that should render that document. Studio saves pending draft changes before manual preview refresh, requests a short-lived preview token from MDCMS, and loads the tokenized route in the iframe. When a content type has no resolver, or the server is not configured to mint preview tokens, Studio shows **Live preview not available** with integration guidance. Route mapping does not come from frontmatter fields or hardcoded Studio paths; keep it in `mdcms.config.ts` beside the model definition. ## Frontmatter editing The Fields tab generates form controls based on your schema field definitions. Each Zod type maps to a specific UI control: | Schema type | Form control | Notes | | ------------------------------ | --------------- | ----------------------------------------------------- | | `z.string()` | Text input | Supports `.min()` / `.max()` validation | | `z.number()` | Number input | | | `z.boolean()` | Toggle switch | | | `z.enum([...])` | Dropdown select | Options derived from enum values | | `z.coerce.date()` | Date picker | Calendar popover with formatted display | | `z.array(z.string())` | Tag input | Type and press Enter to add tags; click to remove | | `fieldTypes.reference("Type")` | Document picker | Select from existing documents of the referenced type | Complex or nested Zod types that don't map to a standard control display a "Not editable yet" message in the form. The underlying data is preserved -- it won't be lost if you save the document. This ensures forward compatibility as more field types gain UI support. Required fields show a red asterisk next to their label. Validation errors from Zod constraints (like `min`, `max`, `regex`) are displayed inline below the field. ## MDX components If your `mdcms.config.ts` registers MDX components, you can insert them directly in the editor. ### Insertion methods There are two ways to insert a component: 1. **Slash command** -- Type `/` in an empty line to open the component picker. Search by name and select a component. 2. **Toolbar action** -- Click the **Insert Component** button in the editor toolbar to browse the full component catalog. Both methods use the same catalog derived from your local config's `components` array. ### Component types * **Void components** -- Render as self-closing tags (``) with no nested content * **Wrapper components** -- Render with opening/closing tags (`...`) and support nested rich text content inside the node view ### Props editing When you select an MDX component node in the editor, the sidebar props panel activates. Props editing follows this resolution order: 1. **Custom editor** -- If the component registration includes a `loadPropsEditor`, it loads asynchronously and provides a fully custom editing experience 2. **Auto-form** -- If no custom editor exists but the component has extracted props metadata, an auto-generated form is shown. For wrapper components, the `children` prop is excluded since it's edited inline. 3. **Empty** -- Components with no editable props show an empty state Inline preview renders through the host bridge if your host app provides one. Otherwise, a fallback label with the component name and prop summary is shown. The component catalog is sourced from your local `mdcms.config.ts`, not from the server. No backend component sync is required. The server only needs to know about your content schema. ## Auto-save Draft changes are saved automatically using a debounced strategy: * Changes trigger a save after approximately **5 seconds** of inactivity * Saving also fires immediately when the editor **loses focus** (blur event) * The save status is shown in the editor UI: * **Unsaved** -- Changes pending * **Saving** -- Write request in flight * **Saved** -- Draft persisted successfully Each auto-save increments the `draftRevision` counter on the document. Auto-saves do not create version history entries -- only explicit [publishes](/guide/studio/publishing) do. The save operation uses `PUT /api/v1/content/:documentId` and includes the active environment's schema hash for conflict detection. If the schema has drifted (e.g., someone ran `mdcms schema sync` with a different schema), the editor switches to read-only mode and shows a schema recovery prompt. Fields that only exist in certain environments (due to schema overlays) are shown with environment badges in the sidebar. For example, a field defined only in your staging environment overlay will display a "staging" badge. Editing these fields in the wrong environment has no effect -- the server validates against the active environment's schema. # Dashboard Source: https://docs.mdcms.ai/guide/studio/dashboard Studio dashboard overview, navigation, and embedding setup ## Embedding Studio For a complete walkthrough of integrating MDCMS into your application — including MDX components, authentication, and production setup — see the [Integration Guide](/guide/integration). MDCMS Studio is an embeddable React component from the `@mdcms/studio` package. You mount it inside a Next.js catch-all route so it owns all `/admin/*` paths. Studio is a client component. The recommended setup splits server and client concerns: ```tsx theme={null} // app/admin/[[...path]]/page.tsx import { createStudioEmbedConfig } from "@mdcms/studio/runtime"; import config from "../../../mdcms.config"; import { AdminStudioClient } from "./admin-studio-client"; export default async function AdminPage() { return ; } ``` ```tsx theme={null} // app/admin/[[...path]]/admin-studio-client.tsx "use client"; import { Studio, type MdcmsConfig } from "@mdcms/studio"; export function AdminStudioClient({ config }: { config: MdcmsConfig }) { return ; } ``` `createStudioEmbedConfig` strips client-only MDX loader callbacks so the config is safe to pass through the server-to-client boundary. If your config includes MDX component registrations with runtime loaders (`load`, `loadPropsEditor`), keep everything in the client component instead. `basePath` is required because the remote runtime cannot infer its subtree root from deep links. Set it to whatever path prefix your catch-all route uses. ### Studio props | Prop | Type | Description | | ------------ | -------------------------------------------------------- | ---------------------------------------------------------------------- | | `config` | `MdcmsConfig` | Project, environment, server URL, and optional component registrations | | `basePath` | `string` | The URL prefix where Studio is mounted (e.g., `/admin`) | | `auth` | `{ mode: "cookie" } \| { mode: "token"; token: string }` | Authentication strategy (defaults to cookie) | | `hostBridge` | `HostBridgeV1` | Optional bridge for MDX preview rendering from the host app | | `fetcher` | `typeof fetch` | Custom fetch implementation | ## Dashboard features The dashboard lives at `/admin` and provides an at-a-glance overview of your content. ### Stats cards Three summary cards are shown at the top of the dashboard: * **Documents** -- Total document count across all content types, with a subtitle showing how many content types exist * **Published** -- Number of published documents and their percentage of the total * **Drafts** -- Number of unpublished documents ### Content type summary Below the stats, a **Content** card lists every synced content type. Each row shows: * Type name * A **Localized** badge for types with `localized: true` * Total document count * A progress bar showing the published/draft ratio Content type cards are gated by read permissions. If your session lacks access to a type, it does not appear. ### Recently updated documents A **Recently updated** card shows the most recently modified documents across all types. Each entry links directly to the document editor and displays the document title (or path), type, draft status, and relative timestamp. ### Quick actions If you have create permissions, a **New Document** button appears above the content cards. ## Navigation Studio uses a fixed sidebar for navigation. The sidebar contains these items: | Route | Label | Visibility | | --------------------- | ------------ | ----------------------------------------- | | `/admin` | Dashboard | Always | | `/admin/content` | Content | Always | | `/admin/environments` | Environments | Admin/owner only | | `/admin/media` | Media | Always | | `/admin/schema` | Schema | Users with schema read permission | | `/admin/users` | Users | Users with user management permission | | `/admin/settings` | Settings | Users with settings management permission | | `/admin/workflows` | Workflows | Always | | `/admin/api` | API | Always | | `/admin/trash` | Trash | Always | The sidebar is collapsible. In collapsed mode, only icons are shown with tooltips on hover. Content browsing is schema-first: navigate to **Content**, then select a content type to see its documents. The content list is organized by type rather than folder path. ## Environment awareness Studio is always scoped to a single environment. The current project and environment are visible in the Settings > General panel and are determined by the `config` you pass to the `` component. Fields that exist only in certain environments (due to schema overlays defined in `environments` config) are displayed with environment-specific badges in the document editor sidebar, such as "staging only". ## Bootstrap On load, Studio fetches a bootstrap manifest from `GET /api/v1/studio/bootstrap` on the MDCMS server. This manifest identifies the active runtime build, its integrity hash, and compatibility bounds. If the server is unreachable, Studio shows an error screen with the server URL, error details, and a **Retry** button. The bootstrap process validates: 1. Manifest shape and compatibility version 2. Runtime asset integrity (SHA-256 hash match) 3. Signature and key ID for the served build If validation rejects the served build, Studio retries bootstrap once with `rejectedBuildId` and `rejectionReason` so the server can fall back to its `lastKnownGood` build. ## Authentication modes Studio supports two authentication modes, set via the `auth` prop: The default mode. Studio sends requests with `credentials: "include"` and expects the MDCMS server to set session cookies. CSRF protection is handled via the `x-mdcms-csrf-token` header, which Studio obtains automatically from the session bootstrap. ```tsx theme={null} // or explicitly: ``` For headless or non-browser contexts, pass a Bearer API key directly. Studio sends it as an `Authorization: Bearer ` header on every request. ```tsx theme={null} ``` API keys are created in **Settings > API Keys** and use the `mdcms_key_` prefix. # Localization Source: https://docs.mdcms.ai/guide/studio/localization Managing multilingual content in the Studio ## When it applies Localization features only appear for content types defined with `localized: true` in your schema: ```typescript theme={null} const BlogPost = defineType("BlogPost", { directory: "content/blog", localized: true, fields: { title: z.string().min(1), slug: z.string(), }, }); ``` Non-localized types use an internal `__mdcms_default__` locale behind the scenes. The locale switcher does not appear in the editor for these types. ## Locale configuration Locale support is configured in `mdcms.config.ts` under the `locales` key: ```typescript theme={null} export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], locales: { default: "en", supported: ["en", "fr", "de", "ja"], aliases: { "en-US": "en" }, }, types: [BlogPost], }); ``` | Property | Type | Description | | ----------- | ------------------------ | ---------------------------------------------------------------------------- | | `default` | `string` | The default locale used when no locale is specified | | `supported` | `string[]` | All locales your project supports | | `aliases` | `Record` | Maps variant codes to canonical locales (e.g., `"en-US"` resolves to `"en"`) | Non-localized types ignore the locale configuration entirely. The locale switcher does not appear in their editor, and they are stored under the internal `__mdcms_default__` locale. ## Locale switcher When editing a localized document, a locale dropdown appears in the document editor. The switcher shows: * **Existing variants** -- Locales that already have a translation for this document, displayed as links that navigate directly to that variant * **Untranslated locales** -- Supported locales that don't have a variant yet, with a **Create** option to start a new translation The variant list is fetched from `GET /api/v1/content/:documentId/variants`, which returns all locale variants within the same translation group. ## Creating translations Navigate to a localized document in any locale. Click the locale dropdown in the editor. Choose a locale that shows a "Create" option. A new document is created in the target locale. You can optionally prefill it from the default locale to use as a starting point for translation. New translations are created via the document create endpoint with the target `locale` parameter and an optional `sourceDocumentId` to copy content from an existing variant. ## Independence of variants Each locale variant is a fully independent document: * **Own path** -- Each variant has its own filesystem path (e.g., `content/blog/en/my-post.mdx`, `content/blog/fr/my-post.mdx`) * **Own body** -- The MDX content is independently editable * **Own frontmatter** -- Field values are independent across locales * **Own version history** -- Publishing, version snapshots, and draft revisions are tracked separately per variant * **Own status** -- One locale can be published while another remains in draft Changes to one locale variant do not affect any other variant. There is no automatic content synchronization between locales. Locale variants are linked together through a shared `translationGroupId`. This is visible in the document Info tab and is used internally to group variants for the locale switcher and translation coverage reporting. ## Translation status in content lists The content list for localized types includes a **Translations** column that shows coverage status per document. For example: * **2/4 locales** -- Two of four supported locales have variants * **4/4 locales** -- All locales have variants This summary is fetched alongside the content list and helps identify documents that need translation work. The translations column only appears when the content type is localized and the project has more than one supported locale. # Publishing & Version History Source: https://docs.mdcms.ai/guide/studio/publishing Draft/publish workflow, version management, and document restoration ## Publishing a document Publishing creates an immutable version snapshot that represents the "live" state of your content. The draft/publish workflow is explicit -- auto-saves update the working draft, but content is only published when you choose. Make your changes in the document editor. The draft auto-saves as you work. From the document editor or the content list row actions menu, click **Publish**. Enter a short description of what changed. This appears in the version history. The publish request is sent to the server, and a new immutable version is created. Change summaries are optional but recommended. They make version history useful for teams by providing context about what changed and why, rather than requiring reviewers to read diffs. ## What happens on publish When you publish a document, the server: 1. Creates a new row in the `documentVersions` table containing: * **Version number** -- Incremented sequentially (v1, v2, v3...) * **Snapshot** -- Complete immutable copy of the document's path, frontmatter, and body at publish time * **Timestamp** -- When the version was created * **Publisher** -- The user who triggered the publish * **Change summary** -- Optional description provided during publish 2. Updates the `publishedVersion` pointer on the document head to reference the new version The publish request is `POST /api/v1/content/:documentId/publish` and accepts an optional `changeSummary` and `actorId`. After publishing, the document status changes from **Draft** or **Changed** to **Published**. If you make further edits after publishing, the status becomes **Changed**, indicating the draft has diverged from the last published version. ## Unpublishing Unpublishing removes the published version reference from the document head. The document reverts to **Draft** status. * The version history is fully preserved -- unpublishing does not delete any version snapshots * The document is no longer available via published content queries * You can republish at any time, which creates a new version Unpublish is available from the content list row actions or the document editor, and requires the `content:publish` capability. ## Version history The version history is accessible from the right sidebar in the document editor. Each version entry shows: * **Version number** (e.g., v1, v2, v3) * **Timestamp** of when the version was published * **Publisher** who created the version * **Change summary** if one was provided ### Comparing versions Studio includes a diff viewer for comparing any two versions side-by-side. The diff covers three dimensions: * **Path** -- Whether the document's filesystem path changed between versions * **Frontmatter** -- Field-by-field comparison showing before/after values for each changed property * **Body** -- Line-by-line diff of the Markdown/MDX content using LCS-based comparison, with lines marked as unchanged, added, removed, or changed Version history only tracks published snapshots, not auto-saves. Each auto-save increments the `draftRevision` counter, but these intermediate states are not preserved. Publish whenever you want to create a recoverable checkpoint. ## Restoring a version You can restore any previous version to make it the current working draft: Navigate to the document editor and open the version history in the sidebar. Find the version you want to restore. Click **Restore**. The version's snapshot (path, frontmatter, body) becomes the new draft content. The original version remains intact in the history -- restoring does not modify or delete existing versions. It creates a new draft state from the selected version's snapshot. ## Restoring deleted documents Documents are soft-deleted, not permanently removed. Deleted documents are recoverable from the **Trash** page at `/admin/trash`. The trash page provides: * **Search** -- Filter deleted documents by title or path * **Type filter** -- Narrow results to a specific content type * **Sort options** -- Newest first, Created, Path A-Z, Path Z-A * **Pagination** -- For large numbers of deleted documents * **Restore action** -- Per-row action to recover a document When you restore a deleted document, it returns as a draft. If a document already exists at the same path and locale, the restore will fail with a `CONTENT_PATH_CONFLICT` error -- you need to rename or delete the conflicting document first. Restoring a deleted document requires the `content:write` capability. The trash page itself requires read access to deleted content. ## Duplicating a document You can duplicate any document from the content list row actions. Duplication: * Creates a new document with a fresh `documentId` * Generates an auto-suffixed path to avoid conflicts (e.g., `my-post.mdx` becomes `my-post-copy.mdx`) * Copies the current draft frontmatter and body * Does not copy version history -- the duplicate starts fresh with no published versions After duplication, you are redirected to the new document's editor. # Settings Source: https://docs.mdcms.ai/guide/studio/settings Read-only project context and API key management Settings access requires the **admin** or **owner** role. Users without these roles see an "Access denied" message. ## General The **General** tab shows read-only context for the active Studio target: | Field | Description | | -------------------- | ---------------------------------------------------------- | | **Project** | The current project identifier | | **Environment** | The current environment identifier | | **Server URL** | The backend URL Studio is using for API requests | | **Schema hash** | The latest synced schema hash returned by the schema API | | **Last schema sync** | The timestamp returned by the latest schema registry state | The schema and project configuration remain code-first. To change content type definitions or sync schema metadata, update `mdcms.config.ts` in the host project and run `mdcms schema sync` from the CLI. ## API Keys API keys provide programmatic access to the MDCMS API for external integrations, CI/CD pipelines, and scripts. Manage them at **Settings > API Keys**. ### Creating a key Click **Create API Key** and configure: | Field | Description | | --------------------- | ------------------------------------------------------------------------- | | **Label** | A human-readable name for the key (e.g., "CI Pipeline", "Preview Deploy") | | **Scopes** | Which operations the key can perform (see table below) | | **Context allowlist** | Restrict the key to specific project/environment pairs | | **Expiration** | Optional expiry date; keys without expiration remain active until revoked | After creation, the full key is displayed **once**. Copy it immediately -- it cannot be retrieved again. All keys use the `mdcms_key_` prefix. ### Available scopes | Scope | Description | | ---------------------- | ------------------------------------ | | `content:read` | Read published content | | `content:read:draft` | Read draft content | | `content:write` | Create and update content | | `content:write:draft` | Write draft content | | `content:publish` | Publish and unpublish documents | | `content:delete` | Delete documents | | `schema:read` | Read schema definitions | | `schema:write` | Sync schema to the server | | `media:read` | Read media metadata | | `media:upload` | Upload media files | | `media:delete` | Delete media files | | `webhooks:read` | Read webhook configurations | | `webhooks:write` | Create and update webhooks | | `environments:clone` | Clone environment content | | `environments:promote` | Promote content between environments | | `migrations:run` | Run database migrations | | `projects:read` | Read project metadata | | `projects:write` | Update project settings | ### Managing keys The API Keys table shows all keys with their label, key prefix, assigned scopes, context restrictions, creation date, expiration, and status (Active, Expired, or Revoked). To disable a key, click **Revoke**. Revoked keys cannot be reactivated -- create a new one instead. ## Users & Roles Manage team access at **Users** in the sidebar. This page lets you list users, send invitations, update roles, and remove team members. ### Inviting users Opens a dialog with email, role, and optional folder prefix fields. The invitee receives an email with a link to accept the invitation. Choose Admin, Editor, or Viewer. The Owner role cannot be assigned via invitation. For Editor and Viewer roles, you can restrict access to content under a specific path (e.g., `content/blog`). Leave empty for full project access. The invitation appears in the Pending Invitations section until accepted or revoked. ### Role capabilities | Capability | Viewer | Editor | Admin | Owner | | ---------------------- | ------ | ------ | ----- | ----- | | Read published content | Yes | Yes | Yes | Yes | | Read draft content | Yes | Yes | Yes | Yes | | Create documents | No | Yes | Yes | Yes | | Edit documents | No | Yes | Yes | Yes | | Publish / Unpublish | No | Yes | Yes | Yes | | Delete documents | No | Yes | Yes | Yes | | Manage users | No | No | Yes | Yes | | Manage settings | No | No | Yes | Yes | | Manage environments | No | No | Yes | Yes | | Read schema | No | No | Yes | Yes | | Transfer ownership | No | No | No | Yes | Editors and Viewers can be scoped to a folder prefix. When a folder prefix is set (e.g., `content/blog`), the user can only access documents whose paths start with that prefix. This is useful for teams where different people own different content sections. Admins always have global scope and cannot be restricted to folders. ### Managing existing users From the user table, you can: * **Edit role** -- Change a user's role and folder prefix via the row action menu * **Revoke sessions** -- Immediately invalidate all active sessions for a user * **Remove user** -- Permanently remove the user from the project (with confirmation) Owners cannot be removed. Ownership must be transferred first. ## Schema Browser The Schema Browser at `/admin/schema` provides a read-only view of all content types synced to the current project and environment. It shows: * **Type name** and **directory** path * **Localization mode** (Localized or Single locale) * **Sync timestamp** and **schema hash** * **Field table** for each type with: * Field name * Kind (string, number, boolean, enum, date, array, reference, object) * Required / Nullable status * Constraints (defaults, reference targets, enum options, validation checks) The schema browser is useful for verifying that `mdcms schema sync` applied your latest type definitions correctly. The schema is read-only in Studio. To change content type definitions, update your `mdcms.config.ts` file and run `mdcms schema sync` from the CLI. ## Webhooks (post-MVP) Webhook support will allow you to configure HTTP endpoints that receive notifications when content events occur. Webhook creation and editing open addressable settings pages at `/admin/settings/webhooks/new` and `/admin/settings/webhooks/:id`, so refreshing the browser or using back navigation keeps operators in the webhook workflow. The configuration table also includes a direct active/inactive toggle for quick enablement changes. Planned configuration per webhook: | Field | Description | | ------------------ | --------------------------------------------------------------------------------------------------------- | | **URL** | The endpoint to receive POST requests | | **Events** | Filter which events trigger the webhook (e.g., `content.published`, `content.updated`, `content.deleted`) | | **Signing secret** | Generated on create and used by receivers to verify webhook payload signatures | | **Active** | Toggle to enable or disable the webhook without deleting it | ## Media Library The Media page at `/admin/media` lists uploaded files for the current project and environment. Operators can search by filename, filter by media category, uploader, and upload date, sort by filename, size, or upload time, page through results, open assets, and copy asset URLs. Uploads, deletion, folders, tags, CDN transforms, and usage tracking remain outside this library view. # MDCMS Source: https://docs.mdcms.ai/index Open-source headless CMS for Markdown and MDX content MDCMS is an open-source, headless CMS for teams managing structured Markdown and MDX content. Unlike file-based CMS tools that commit content directly to git, MDCMS treats a PostgreSQL database as the source of truth. Content files are synced between the database and your local filesystem via CLI commands (`mdcms push` / `mdcms pull`), keeping your repository clean and your content workflow independent of your deployment pipeline. ## Why MDCMS Content types defined in TypeScript with Zod. The schema drives form generation, validation, and API behavior automatically. Embeddable React component with a TipTap-based MDX editor, frontmatter editing, and full version history. `mdcms push` and `mdcms pull` sync between your local filesystem and the CMS. Designed for CI/CD pipelines. Isolated content per environment with schema overlays. Clone and promote content between environments. Built-in i18n with translation groups and per-type localization control. Module system for custom server actions, CLI commands, and Studio UI extensions. ## Get Started Install the packages, define your schema, and start managing content in your project. Stand up your own MDCMS server with Docker Compose — database, storage, and API in one command. Explore the REST API, authentication, and the TypeScript SDK for querying content. Clone the monorepo, run the dev environment, and start contributing to MDCMS. ## Quick Start Install the CLI and SDK in your project: ```bash theme={null} npm install @mdcms/cli @mdcms/sdk ``` Define your content types in `mdcms.config.ts` at the root of your project: ```typescript mdcms.config.ts theme={null} import { defineConfig, defineType, fieldTypes } from "@mdcms/cli"; import { z } from "zod"; const Author = defineType("Author", { directory: "content/authors", fields: { name: z.string().min(1), }, }); const BlogPost = defineType("BlogPost", { directory: "content/blog", localized: true, fields: { title: z.string().min(1).max(200), slug: z.string().regex(/^[a-z0-9-]+$/), author: fieldTypes.reference("Author"), publishedAt: z.coerce.date(), tags: z.array(z.string()).default([]), }, }); export default defineConfig({ project: "marketing-site", serverUrl: "http://localhost:4000", contentDirectories: ["content"], types: [Author, BlogPost], }); ``` Then sync your schema and start editing: ```bash theme={null} # Sync schema to the server mdcms schema sync # Pull existing content to your filesystem mdcms pull # Push local content changes to the server mdcms push ```