@xndrjs/contentful-to-zod
v0.2.1
Published
Generate Zod 4 schemas from Contentful content types (CMA) with optional Object field overrides.
Downloads
881
Readme
@xndrjs/contentful-to-zod
Generate Zod 4 schemas from Contentful content types (CMA). Stop hand-writing codegen and get precise z.infer types where graphql-codegen stays on string.
This package outputs Zod schemas and optional locale helpers only — no domain.shape in the generated file. If you use xndrjs, wire schemas with zodToValidator from @xndrjs/domain-zod in your own code.
Principles
- Transport-aware mapping from the CMA content model to Zod (field type + validations; absent/null transport values normalize to
null). - Two schema shapes (configurable): flat / CMA (single value per field) and delivery (
localized: true→z.record(ContentfulLocaleCodeSchema, T)). - Default
locale.mode: "both"— each content type exports flat + delivery field schemas, an entry schema, andflatten*EntryFieldshelpers. - Locales from your space — enum and constants are generated from a CMA
/localessnapshot;CONTENTFUL_DEFAULT_LOCALEis only the default parameter for helpers (no runtime rule that the default locale must exist in every record). - Self-contained output — generated file depends only on
zod; shared primitives (entry/asset links, location, …) are inlined once at the top. - Optional Object overrides — CMA declares
Objectwithout inner shape; supply Zod schemas via config for{contentTypeId}.{fieldId}keys.
Install
pnpm add zod@^4
pnpm add -D @xndrjs/contentful-to-zod @dotenvx/dotenvxCLI
Because @xndrjs/contentful-to-zod is a codegen dependency, keep the codegen options in contentful-to-zod.config.ts and run the local CLI through your package manager:
import { defineConfig } from "@xndrjs/contentful-to-zod";
export default defineConfig({
cma: {
spaceId: process.env.CONTENTFUL_BLOG_SPACE_ID,
managementToken: process.env.CONTENTFUL_BLOG_MANAGEMENT_TOKEN,
environment: process.env.CONTENTFUL_BLOG_ENVIRONMENT ?? "master",
},
out: "./src/generated/contentful.schemas.ts",
snapshot: "./src/generated/content-types.json",
snapshotLocales: "./src/generated/locales.json",
});{
"scripts": {
"contentful:schema": "dotenvx run -- contentful-to-zod --config ./contentful-to-zod.config.ts"
}
}Live fetch from CMA (writes snapshots for reproducible CI):
pnpm run contentful:schemaFor a one-off run, you can also use npx:
npx @xndrjs/contentful-to-zod \
--space-id "your_space_id" \
--management-token "your_management_token" \
--environment master \
--out ./src/generated/contentful.schemas.ts \
--snapshot ./src/generated/content-types.json \
--snapshot-locales ./src/generated/locales.jsonOther flags: --content-types blogPost,author, --config ./contentful-to-zod.config.ts, --dry-run (print to stdout).
If an option is set in both CLI args and config, the CLI arg wins and contentful-to-zod prints a warning.
Programmatic API
import { fetchContentTypes, fetchLocales, generateZodSchemas } from "@xndrjs/contentful-to-zod";
import { writeFile } from "node:fs/promises";
const cma = { spaceId, accessToken, environmentId: "master" };
const [contentTypes, locales] = await Promise.all([fetchContentTypes(cma), fetchLocales(cma)]);
const source = generateZodSchemas(contentTypes, {
locales,
config: { locale: { mode: "both" } },
});
await writeFile("./src/generated/contentful.schemas.ts", source, "utf8");generateZodSchemas options: contentTypeIds, locales (required when mode is delivery or both), localeMode, config.
Locale mode
In contentful-to-zod.config.ts (or generateZodSchemas options):
import { defineConfig } from "@xndrjs/contentful-to-zod";
export default defineConfig({
locale: {
/** Default: "both" */
mode: "both", // "cma" | "delivery" | "both"
},
});Fields marked disabled, omitted, or deleted in the CMA blueprint are excluded from generated schemas and flatten helpers unless you opt in:
export default defineConfig({
fields: {
includeOmitted: true,
includeDisabled: true,
includeDeleted: true,
},
});| locale.mode | Generated exports |
| ------------------ | ------------------------------------------------------------------------------------------ |
| "cma" | Flat field schemas only (BlogPostFieldsSchema, BlogPostFields) |
| "delivery" | Delivery field schemas + entry wrappers + pickLocale + locale enum/constants |
| "both" (default) | Flat + delivery field schemas + entry wrappers + pickLocale + flatten{Type}EntryFields |
Rules:
- Flat field schemas (
*FieldsSchema) wrap every field inflatField()— sameundefined/nullnormalization as delivery, for use afterflatten*EntryFields(or direct parse of a flat shape). - Delivery field schemas (
*DeliveryFieldsSchema,*EntrySchema) wrap every field intransportField(). CMArequireddoes not apply at the transport boundary. localized: true— flat usesflatField(T); delivery usestransportField(z.record(ContentfulLocaleCodeSchema, T)).disabled/omitted/deletedfields are excluded by default. Opt in via config:fields.includeDisabled,fields.includeOmitted,fields.includeDeleted.
Generated locale primitives
When delivery or both mode is active, the file starts with:
/** @generated from space locales snapshot */
export const ContentfulLocaleCodeSchema = z.enum(["en-US", "it-IT"]);
export type ContentfulLocaleCode = z.infer<typeof ContentfulLocaleCodeSchema>;
export const CONTENTFUL_LOCALE_CODES = ContentfulLocaleCodeSchema.options;
export const CONTENTFUL_DEFAULT_LOCALE = "en-US" as const;Flat vs delivery example
// flat / CMA — single value per field, normalized by flatField()
export const BlogPostFieldsSchema = z.object({
title: flatField(z.string().max(256)),
slug: flatField(z.string()),
author: flatField(ContentfulEntryLinkSchema),
});
export type BlogPostFields = z.infer<typeof BlogPostFieldsSchema>;
// delivery — REST/Preview fields, normalized by transportField()
export const BlogPostDeliveryFieldsSchema = z.object({
title: transportField(z.record(ContentfulLocaleCodeSchema, z.string().max(256))),
slug: transportField(z.string()),
author: transportField(ContentfulEntryLinkSchema),
});
export type BlogPostDeliveryFields = z.infer<typeof BlogPostDeliveryFieldsSchema>;Generated helpers
Helpers are pure functions in the same output file. They do not validate — parse after flattening:
import {
BlogPostEntrySchema,
BlogPostFieldsSchema,
flattenBlogPostEntryFields,
} from "./generated/contentful.schemas";
const entry = BlogPostEntrySchema.parse(rawFromContentful);
const flat = flattenBlogPostEntryFields(entry.fields, "it-IT");
const post = BlogPostFieldsSchema.parse(flat);pickLocale— read one locale from a localized delivery field (Record<ContentfulLocaleCode, T> | null); missing locale ornullinput →null. Default locale parameter isCONTENTFUL_DEFAULT_LOCALE.flatten{ContentType}EntryFields— map validated*DeliveryFieldsfromentry.fields→ flat*Fieldsfor one locale (one per content type whenmodeisboth). Passesnullthrough for absent localized values.
There is no runtime dependency on @xndrjs/contentful-to-zod in production — only the generated file and zod.
Object field overrides
// contentful-to-zod.config.ts
import { z } from "zod";
import { defineConfig } from "@xndrjs/contentful-to-zod";
export default defineConfig({
objects: {
"blogPost.metadata": z.object({
seoTitle: z.string(),
noIndex: z.boolean().optional(),
}),
},
});Overrides apply to the base field type T. In delivery mode, localized fields wrap z.record(ContentfulLocaleCodeSchema, T).nullable() around that base (plus .optional() when applicable).
Overrides are inlined at codegen time — the config is not imported at runtime.
Mapping Delivery / REST data
- Parse raw entries with
*EntrySchema.parse(...). - Flatten validated
entry.fieldswithflatten*EntryFields(...)whenmodeisboth. - Validate the flat shape with
*FieldsSchema.parse(...).
Entry/asset link objects and CMA validations (size, range, regex, etc.) are reflected in the generated Zod chains.
Resolved entry links (linkContentType)
Contentful REST does not include the target content type on unresolved link stubs. The CMA field validation linkContentType is the source of truth — the codegen reads it from your content-type snapshot (no extra config).
When locale.mode includes delivery, the generated file also exports parseEntryAsLinkField for fields that declare linkContentType:
import { BlogPostEntrySchema, parseEntryAsLinkField } from "./generated/contentful.schemas";
const post = BlogPostEntrySchema.parse(rawPost);
const authorLink = post.fields.author; // unresolved link stub
const resolvedAuthor = await contentfulClient.getEntry(authorLink!.sys.id);
const author = parseEntryAsLinkField("blogPost", "author", resolvedAuthor);
// `author` is typed as AuthorEntry when linkContentType is ["author"]- Single target → return type is that
*Entrytype. - Multiple targets → union of the allowed
*Entrytypes; narrow withentry.sys.contentType.sys.id. - Wrong content type →
LinkFieldTargetErrorwith parent field id and allowed targets. getAllowedEntryLinkContentTypes(parentCtype, fieldName)→ readonly allow-list from CMA (same data as the parser uses; useful before fetch or for custom checks).
Target content types must be present in the same snapshot used for codegen.
xndrjs recipe (optional)
Wire flat field schemas and the locale enum into @xndrjs/domain-zod:
import { domain, zodToValidator } from "@xndrjs/domain-zod";
import { BlogPostFieldsSchema, ContentfulLocaleCodeSchema } from "./generated/contentful.schemas";
export const BlogPost = domain.shape("BlogPost", zodToValidator(BlogPostFieldsSchema));
export const SupportedLocale = domain.primitive(
"SupportedLocale",
zodToValidator(ContentfulLocaleCodeSchema)
);Use SupportedLocale (or your own name) wherever application code should accept only locales known to the space snapshot.
CMA field mapping (summary)
| CMA type | Zod base |
| ------------ | ------------------------------------------------------ |
| Symbol, Text | z.string() + validations |
| Integer | z.number().int() |
| Number | z.number() |
| Boolean | z.boolean() |
| Date | z.string() / z.iso.datetime() |
| Location | z.object({ lat, lon }) |
| Object | z.record(z.string(), z.unknown()) or config override |
| Link | Contentful link object |
| Array | z.array(itemSchema) |
| Rich Text | z.looseObject({ nodeType: z.literal("document") }) |
