Skip to main content
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:
mdcms.config.ts
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:
OptionTypeRequiredDescription
directorystringYesFilesystem path where documents of this type are stored. Must be covered by contentDirectories.
localizedbooleanNo (default: false)Whether documents of this type support per-locale variants. Requires locales config when true.
fieldsRecord<string, ZodSchema>YesMap 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 schemaRegistry kindStudio controlNotes
z.string()stringText inputBase type for most text fields
z.number()numberNumber inputSupports .int(), .min(), .max() checks
z.boolean()booleanToggle switchCommonly paired with .default(false)
z.coerce.date()dateDate pickerCoerces string values to Date objects
fields: {
  title: z.string(),
  viewCount: z.number(),
  featured: z.boolean(),
  publishedAt: z.coerce.date(),
}

Complex types

Zod schemaRegistry kindStudio controlNotes
z.array(z.string())array (item: string)Tag inputRepeatable string entries
z.array(z.number())array (item: number)Repeatable number inputs
z.object({...})objectNested fieldsetEach property rendered recursively
z.enum(["a", "b", "c"])enumDropdown selectOptions extracted from enum entries
z.literal("x")literalHidden / read-onlyFixed value, useful for discriminators
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.
ModifierEffectRegistry impact
.optional()Field can be omitted entirelyrequired: false
.nullable()Field can be explicitly set to nullnullable: true
.default(value)Provides a default value; field becomes optionalrequired: false, default: <value>
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

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():
mdcms.config.ts
export default defineConfig({
  project: "marketing-site",
  serverUrl: "http://localhost:4000",
  contentDirectories: ["content"],
  types: [Author, BlogPost, Page],
});
Then sync the schema to your server:
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:
mdcms.config.ts
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

OperationDescription
addAdd new fields that don’t exist on the base type. Errors if the field already exists.
modifyReplace the schema for an existing field. Errors if the field doesn’t exist.
omitRemove fields by name. Errors if the field doesn’t exist.
extendsInherit 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:
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:
mdcms.config.ts
import { defineConfig, defineType, reference } 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: 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.