Setting Up TypeScript Types from Headless CMS Schemas

Generate TypeScript types from your CMS schema instead of hand-maintaining them, or schema drift will bite: an editor adds a subtitle field, the interface stays stale, and you ship either a runtime undefined crash or any casts that erase type safety entirely. The disconnect is worst across multi-environment setups, where staging schemas diverge from production. This guide covers the extraction pipelines — GraphQL codegen and REST/JSON-Schema introspection — plus runtime validation and CI gating that keep types and content models in lockstep. It’s part of the Contentful Integration Guide, and the approach applies to any platform in Platform Integration Deep Dives.

From content model to safe componentsThe CMS schema is introspected or exported, types are generated and committed, runtime schemas validate API responses at the fetch boundary, and components receive typed, validated data; CI regenerates types and fails on differences.CMS schemaGeneratetypesRuntime schemaat fetch boundaryTypedcomponentsCI difffail on drift
Static types catch code mismatches; runtime schemas catch data mismatches.

The Schema Drift Problem

Type instability comes from a timing mismatch: content teams iterate on schemas on a different clock than code deploys, and the frontend gets no signal when a field is renamed, tightened, or re-related. Lacking an extraction pipeline, developers fall back on memory and stale docs — which collapses at agency velocity.

The canonical failure: a required author reference is made optional to support drafts. A component reads author.name, the API returns null, and TypeScript passes because the interface was never updated — TypeError: Cannot read properties of null in production. Treating the CMS schema as the single source of truth, generated automatically, moves that failure to development where it’s cheap.

Automated Type Generation Strategies

The reliable approach generates interfaces straight from the schema registry via an extraction step that runs locally and in CI, using introspection endpoints or exported schema definitions.

GraphQL Codegen Pipeline for Content-First Frameworks

For GraphQL endpoints, @graphql-codegen/cli introspects the live schema and maps it to TypeScript interfaces with strict null checks, so every field, union, and enum matches what the API returns.

TypeScript
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: [
    {
      [`https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}/environments/${process.env.CONTENTFUL_ENVIRONMENT ?? 'master'}`]: {
        headers: { Authorization: `Bearer ${process.env.CONTENTFUL_ACCESS_TOKEN}` },
      },
    },
  ],
  documents: ['src/queries/**/*.graphql'],
  generates: {
    'src/types/cms.generated.ts': {
      plugins: ['typescript', 'typescript-operations'],
      config: {
        strictScalars: true,
        scalars: {
          DateTime: 'string',
          JSON: 'Record<string, unknown>',
        },
        avoidOptionals: { field: true },
        maybeValue: 'T | undefined',
      },
    },
  },
};

export default config;

npx graphql-codegen writes cms.generated.ts mirroring your exact query shapes. avoidOptionals makes every field present in the type, with nullable fields typed as possibly undefined through maybeValue, so components must handle missing values explicitly instead of forgetting that a field can be absent. Note that Contentful’s GraphQL schema marks most fields nullable, because entries can be saved without them; tighten the types with runtime schemas where the model guarantees values. The same fragments and generated types stay in sync with the patterns in the Contentful Integration Guide across feature branches.

REST API & JSON Schema Introspection

For REST platforms, fetch the schema definition and transform it. openapi-typescript and json-schema-to-typescript parse OpenAPI specs or raw JSON Schema into .d.ts files per the JSON Schema Specification.

Write a small Node script that authenticates with the management API, pulls the latest content-type definitions, and pipes them through the transformer, mapping CMS field types (richText, slug, link) to their TypeScript equivalents. Commit the output so schema evolution shows up as a reviewable diff.

Bridging Compile-Time Types with Runtime Validation

Generated interfaces exist only at compile time — tsc never checks an actual API response against them. Pair them with a runtime validator: define a schema that mirrors the generated interface, then parse and narrow each response with it. On a validation failure, degrade to a fallback UI instead of crashing. This dual layer — static types for the IDE, runtime schemas for data integrity — is what keeps a corrupted payload from silently rendering into a statically generated page. It also composes with TypeScript’s Utility Types for transformations driven by runtime data.

A runtime schema next to the generated type

A runtime schema for each content type mirrors the generated type and narrows it where the model guarantees values. With Zod, the inferred type can be checked against the generated one at compile time, so the two cannot drift silently.

TypeScript
// lib/cms/article-schema.ts
import { z } from "zod";
import type { ArticleQuery } from "@/types/cms.generated";

type GeneratedArticle = NonNullable<NonNullable<ArticleQuery["articleCollection"]>["items"][number]>;

export const Article = z.object({
  sys: z.object({ id: z.string() }),
  title: z.string().min(1),                        // required in the model, nullable in GraphQL
  slug: z.string().regex(/^[a-z0-9-]+$/),
  publishDate: z.string(),
  heroImage: z.object({ url: z.string().url(), width: z.number(), height: z.number(), description: z.string().nullish() }).nullish(),
});
export type Article = z.infer<typeof Article>;

// Compile-time check: every field the schema reads must exist on the generated type.
type _Check = { [K in keyof Article]: K extends keyof GeneratedArticle ? true : never };

export function parseArticle(raw: unknown): Article | null {
  const result = Article.safeParse(raw);
  if (!result.success) {
    console.warn(JSON.stringify({ kind: "invalid_article", issues: result.error.issues.map((i) => i.path.join(".")) }));
    return null;
  }
  return result.data;
}

CI/CD Integration & Schema Drift Prevention

Local generation is half the job; CI has to enforce it or drift still reaches production. The workflow:

  1. Fetch the latest schema from the staging or production CMS environment.
  2. Run the type generation script.
  3. Compare the newly generated file against the committed version using git diff.
  4. Fail the build if discrepancies are detected, requiring developers to run the generator locally and commit the updated types before merging.

No deploy ships without an explicit acknowledgment of the schema change, which forces editors and developers to coordinate field edits through change requests instead of ad-hoc CMS tweaks. The gate works like this:

The CI drift gateCI fetches the latest schema, regenerates types and compares them with the committed file; without differences the build proceeds, with differences it fails until a developer regenerates and commits the types.FetchschemaGeneratetypesDifferences?BuildproceedsFail buildregenerate + commitnoyes
Schema changes become reviewable diffs instead of runtime surprises.

Agency & Multi-Environment Workflows

When projects share a CMS instance or template repo, namespace generated types by environment or project to prevent collisions, and keep a per-environment .env pointing the generator at the right space ID and token. Document the generation step in the README and onboarding checklist, and have content teams validate schema changes in a preview environment before promoting to production. Treated as a code-adjacent discipline, content modeling keeps delivery fast without giving up type safety.

Production errors from content shape problemsRuntime errors per month caused by fields that were missing, renamed or retyped, with hand-written interfaces and after generated types, runtime schemas and the CI drift gate.Hand-written interfaces23 errors per monthGenerated types + runtime schemas1 errors per month
Generated types caught renames in CI; runtime schemas caught bad data at the boundary.

Types for blocks and references

Page builders built from references to several content types produce union types in GraphQL, one member per allowed type, each with its __typename. Generate a fragment per block type from the same list of allowed types, so a new block added to the model shows up as a missing fragment in CI rather than as a block that silently renders with only its type name. On the runtime side, validate the block array with a discriminated union keyed on __typename, drop unknown blocks with a logged warning, and render the rest through a typed registry, as described in the discriminated unions guide.

Gotchas & Edge Cases

  • Nullable everything. Generated GraphQL types for Contentful are mostly nullable. Do not sprinkle non-null assertions; tighten in runtime schemas once, at the boundary.
  • Rich text types. Rich text is JSON; type it with the platform’s document types and validate embedded entry types separately.
  • Preview versus delivery schemas. Preview may expose fields or content types not yet published. Generate from the environment you deploy against, usually through the alias.
  • Large schemas. Generating types for every content type slows editors’ tooling. Generate only for queried documents where possible.

Worked Example

An agency maintained hand-written interfaces for a Contentful space with 40 content types. After an editor made a reference optional, a product page crashed for entries without it. The team introduced GraphQL codegen with the authenticated schema, runtime schemas per page type, and a CI job that regenerated types nightly and on every pull request. The first nightly run found four fields renamed in the CMS since the last release; since then, content model changes surface as failed CI checks instead of production errors.

Rollout Checklist

  • Generate types from the authenticated schema of the deployed environment.
  • Commit generated types and review diffs.
  • Add runtime schemas at the fetch boundary for each page type.
  • Fail CI when regenerated types differ from committed ones.
  • Run the generator nightly to catch model changes made in the UI.

Frequently Asked Questions

Should generated types be committed?

Yes. Committed types make schema changes visible in pull requests and keep builds reproducible without network access to the CMS.

Is runtime validation expensive?

Parsing a typical page’s data takes a fraction of a millisecond on the server. The protection against malformed content is worth far more.

What about REST-only platforms?

Export content type definitions through the management API and convert them to types with a small script or a platform-specific generator.

How do we handle multiple environments?

Generate from the environment production reads through, and regenerate against migration environments while developing model changes, committing the result with the migration.

Can generated types replace documentation of the model?

Partly. They document shapes precisely, but not intent; keep short descriptions of content types and fields in the CMS itself, where editors see them.