Change the content model

The steps for a model change: write an expand/contract migration, curate a fragment and generate types, write the transform.

2026-11-07

Migrations

The content model's source of truth is packages/model — never the Contentful web app. Every schema change is a migration script, committed and gated. The spine runs: domain-sliced migrations → vendored schema.graphql (introspected) → generated types → curated fragments → transforms → app — how and why is on Content model, and the types themselves are in the content models reference. This guide is the steps: this page is the migration; the two that follow curate the fragment and write the transform.

The authoritative rules are in .claude/rules/model/contentful-model.md. Read them before touching the package — the expand/contract and protected-environment rules below are load-bearing.

⚡ Do it with Claude — /model-migration is the one way content-model changes happen. It scaffolds the script at the next free number in the right directory, checks the expand/contract rules, applies to an ephemeral sandbox clone, regenerates the types against it, and walks the fragment and transform updates — committing the migration, schema, generated types, fragment and transform as one change. /model-review triages drift between a live environment and the code. This guide covers the manual path underneath.

1. Scope the change

Confirm which content types and fields move, and whether any composed fragment or transform already consumes them:

grep -rn "<fieldName>" packages/model/src/

2. Scaffold the script

Migrations are numbered NNNN-kebab-name.cjs, per-directory sequence. The runner fails on a duplicate number. Every migration this project writes goes in migrations/club/ — the domain directories are the template's — see Migrations in this repo:

packages/model/migrations/club/NNNN-<kebab>.cjs

A domain's migrations create or delete only the types that domain owns (per domains.json) — lint-migrations.mjs enforces it. Never edit core/. The script is a contentful-migration DSL function; editor-interface changes (changeFieldControl) belong in the same script as their fields.

// packages/model/migrations/club/0064-article-reading-time.cjs
module.exports = function articleReadingTime(migration) {
  const articlePage = migration.editContentType("articlePage");
  articlePage.createField("readingTimeMinutes").name("Reading time (minutes)").type("Integer");
};

A cross-domain edit (touching a type another domain owns) needs a // cross-domain: marker and must stay expand-only. Here is the video domain adding an optional pointer to the core-owned siteSettings:

// packages/model/migrations/video/0002-site-settings-video-landing.cjs
// cross-domain: siteSettings is CORE-owned; this is the expand-only field the
// /video route resolves its landing through. Optional, so every existing
// composition and entry stays valid.
module.exports = function siteSettingsVideoLanding(migration) {
  const siteSettings = migration.editContentType("siteSettings");
  siteSettings
    .createField("defaultVideoLandingPage")
    .name("Default Video Landing Page")
    .type("Link")
    .linkType("Entry")
    .validations([{ linkContentType: ["videoLandingPage"] }]);
};

3. Expand/contract

Every migration must be backwards-compatible with the currently deployed code:

  • Add optional first. New fields are optional and additive.
  • Destructive ops ship one release later. deleteField, changeFieldId, required(true) on an existing field, and deleteContentType ship one release after the code that stops depending on them, and must carry a // contract: <reason> marker or lint-migrations.mjs fails the PR.
  • Exception: .required(true) chained onto a field born in the same createField statement is expand-safe (a brand-new type has no entries) and needs no marker.

Run the linter:

node packages/model/scripts/lint-migrations.mjs

4. Sandbox-apply

Never apply to a protected environment by hand — the runner refuses master, production, staging and staging-release unless BOTH CI=true and ALLOW_PROTECTED_ENV=1 are set, and --env is always explicit. Never apply a migration to develop ahead of its PR either: develop's schema would move under every other open PR and redden their drift gates. model-apply-develop.yml applies it on merge.

Apply to a throwaway environment instead. migrate.mjs needs an environment that EXISTS, and sync-schema introspects through a delivery key scoped to the environment (it fails with No delivery API key is scoped to environment … otherwise), so the flow is:

  1. Clone develop to a temporary environment in Contentful (name ≤ 40 chars).

  2. Add it to the pre-production delivery key's environments (pre-production-env-key; it was develop-env-key before 14 Oct 2026).

  3. Migrate and regenerate against it:

    # dry run first — prints the plan (migrations and apps) without mutating
    node packages/model/scripts/migrate.mjs --env=model-dev-reading-time --dry
    node packages/model/scripts/migrate.mjs --env=model-dev-reading-time
    
    CONTENTFUL_MODEL_ENV=model-dev-reading-time pnpm --filter @v4/model sync-schema
    pnpm --filter @v4/model generate
    
  4. Remove the environment from the key, then delete it.

migrate.mjs also takes --until=<migration> (apply up to and including that migration and hold the rest back — how a deploy runs a data step between two migrations), --assume-baseline (record the baseline as applied on an environment that already has it) and --skip-apps (by default the runner syncs the Contentful apps after the migrations).

5. Regenerate and resync

Back on your branch, the local pre-push check syncs the schema from develop, regenerates the model's types and diffs them, then regenerates the app codegen for @v4/web, @v4/content-app and @v4/video-app and diffs those:

pnpm --filter @v4/model drift

It does NOT pull the dictionary, and src/__generated__/dictionary is excluded from the diff — labels belong to editors (Dictionary keys). Run it after merge, once develop has the migration; before that, use the throwaway flow above.

Generated files are never hand-edited — schema.graphql, src/__generated__/, and the composed baselines (migrations/core/0001-baseline-core.cjs, migrations/content/0001-baseline-content.cjs, migrations/video/0000-baseline-video.cjs). The CI drift gate and the model PR gate (model-pr-gate.yml, which re-proves the migration on an ephemeral model-gate-pr<N> clone) both enforce this.

6. Commit as one change

The migration, the vendored schema.graphql, the regenerated src/__generated__/, and any fragment/transform updates are one commit. The PR gate re-proves the migration → regenerate pairing serially on a clean clone.

Schema-changing edits

Editing a reference field's linkContentType changes the GraphQL schema. The allowed types ARE the field's union: a type removed from it leaves the union, and any fragment spreading that type fails codegen once the migration has run. In the same PR: fix the fragments, re-vendor schema.graphql from an environment the migration has run on, regenerate (generate for @v4/model, @v4/web, @v4/content-app, @v4/video-app), and make sure model-pr-gate is green before merging — it is not a required check. size validations are schema-neutral.

A migration that restates a polymorphic allow-list (a contentBlocks field's linkContentType) passes it through composedLinkTypes() (packages/model/scripts/composed-link-types.cjs), so the list never names a type from a domain the environment did not compose.

Who applies migrations where

Actions validate; the deploy pipeline mutates. GitHub Actions mutate only the develop environment (model-apply-develop.yml, on merge) and ephemeral model-gate-pr<N> clones. Production-facing environments are migrated by the deploy itself: this site's staging web service runs migrate.mjs inside its BUILD_COMMAND before the app build (The model deploy pipeline). Never weaken the protected-environment guard.

Next

Fragments and types — expose the new field to the app.

Fragments and types

A migration adds a field to the schema, but nothing reads it until a fragment declares that a surface consumes it. A fragment is the reviewed statement "this component surface consumes exactly these fields, no more" — a complexity budget, not a convenience. Its types are generated; the app spreads it into page-level queries. This page curates one.

⚡ Do it with Claude — /model-fragment <ContentType> [SurfaceName] (e.g. /model-fragment Video VideoCard) drafts an all-fields fragment from the vendored schema, walks the pruning review with you, validates through codegen, and pairs it with a transform and a test. This guide is the manual path.

1. Draft from the schema, never from memory

Read the content type's full field list from the vendored, deterministic SDL — packages/model/schema.graphql — and write an all-fields draft so pruning starts from a complete inventory:

grep -n "type ArticlePage " packages/model/schema.graphql   # then read the block

2. Prune in review

Keep only the fields the surface actually consumes — every kept field must have a consumer in the paired transform or the component props. Delete the rest; do not comment them out ("future-proofing" is the anti-pattern, and a field nobody reads is a review error). Fragments are domain-sliced, one per component surface, named {Surface}Fields:

packages/model/src/<domain>/fragments/{surface-kebab}.graphql

Always select sys { id }. Collections always carry an explicit limit sized to the surface (the API default is 100 and counts against complexity). Linked entries spread an existing fragment when one covers the child surface; otherwise select the minimal inline set.

# packages/model/src/content/fragments/article-card.graphql (abridged — the real
# fragment also selects isFeatured, competition, featuredVideo, featuredGallery and
# the image's Scoreplay reference)
# The card-sized article surface (CardNewsArticle / HeaderHero slides /
# NewsCarousel in @v4/ghost-content). Deliberately smaller than
# ArticlePageFields — listings fetch 12 at a time, so every field is a budget
# line. Every field has a consumer in the articleCard transform.
fragment ArticleCardFields on ArticlePage {
  sys {
    id
  }
  slug
  articleTitle
  summary
  publishDate
  format
  isRestrictedToMembersOnly
  category {
    sys {
      id
    }
    category
    slug
  }
  image {
    focalPoint
    image {
      url
      description
      width
      height
    }
  }
}

3. Validate through codegen

Types are generated, never hand-written. After any .graphql edit:

pnpm --filter @v4/model generate

Codegen validates every field name against schema.graphql and refuses guesses — a refusal means the schema moved or you guessed a name, so re-read the SDL rather than iterating blind. Import the generated types from @v4/model, never from a codegen path:

import type { ArticleCardFieldsFragment } from "@v4/model";

4. The app spreads the fragment

Page-level queries live in the app (or the domain's *-app package), never in @v4/model. They are composed by spreading fragments — an app never writes raw field selections for a model-owned surface:

# packages/content-app/src/graphql/get-news-listing.graphql (abridged)
query GetNewsListing($limit: Int!, $skip: Int!, $preview: Boolean!) {
  articlePageCollection(limit: $limit, skip: $skip, preview: $preview, order: publishDate_DESC) {
    items {
      ...ArticleCardFields
    }
  }
}

If the app needs a field no fragment carries, extend the fragment here, in review — do not inline a selection. The new-field flow is one reviewed change: migration → sync-schema + generate → fragment + transform together. Never a fragment for a field no migration has shipped.

5. Gates

pnpm --filter @v4/model generate
pnpm --filter @v4/model check-types
pnpm --filter @v4/model lint

Commit the fragment and its regenerated types together with the transform (next page) as one change.

Rules worth keeping in view

  • Apps import only from @v4/model — never contentful, contentful-management, or a raw GraphQL endpoint.
  • A domain may ship a minimal surface. video's src/video/ holds two fragments and one transform (exported as @v4/model/video); its data reads come from the platform API through @v4/media. The club-owned basketball domain's fragments live in src/basketball/fragments/ (exported as @v4/model/basketball) over types the club's migrations created.
  • Each route package owns its page queries and their codegen — packages/{content,video,basketball}-app/src/graphql/, regenerated with pnpm --filter @v4/<x>-app generate after a fragment changes.
  • GraphQL type names are not always the field ids. Contentful converts names, so a field id under18s can surface as a type or enum spelled Under18S. When auditing whether a field is still read, match case-insensitively, include query filters, and prove it with codegen against a migrated clone rather than a grep.
  • No per-locale fragment variants by hand — locale-variant generation is a planned generator; raise it rather than hand-writing locale forks.

Next

Transforms — reshape the fragment into a normalised view the app renders.

Transforms

A fragment gives the app a typed but Contentful-shaped object — nested sys, nullable everything, editor field names. A transform reshapes it into a normalised view model in the vocabulary of the component that renders it: flat, non-null where the design guarantees a value, and named for the surface rather than the CMS. This is the last step of the spine before the app (the pattern is explained in Transforms & normalised views).

The transform is pure data. The ReactNode slots a page renders (header media, body blocks) are consumer-owned, so a transform returns the values the app renders into them — never JSX. Dates stay ISO; display formatting is locale work the app does with Intl.

Where a transform lives

Domain-sliced, beside the fragment it pairs with, named for the surface:

packages/model/src/<domain>/transforms/{surface-kebab}.ts

The exported function is {surfaceCamel}FromFragment and takes the generated fragment type:

// packages/model/src/content/transforms/article-page.ts (abridged — the real view also
// carries the video, gallery, competition, author, embeds and the header's sponsor)
import type { ArticlePageFieldsFragment } from "../../__generated__/graphql";
import { objectPositionFromFocalPoint } from "./focal-point";
import { normaliseRichText, type NormalisedRichText } from "./rich-text";

/**
 * The article-page view model: `ArticlePageFieldsFragment` reshaped into the
 * vocabulary of `BasePageNewsArticle` (@v4/ghost-content). DATA only — the
 * ReactNode slots are consumer-owned, so this returns the values the app
 * renders into them, never JSX.
 */
export interface ArticlePageView {
  id: string;
  slug: string;
  /** The authored format discriminator: "News" | "Video" | "Gallery". */
  format?: string | undefined;
  header: {
    title: string;
    publishedDateTime?: string | undefined;
    category?: { label: string; slug: string } | undefined;
    membersOnly: boolean;
    media?: ArticleImageView | undefined;
  };
  readingTimeMinutes?: number | undefined;
  body: NormalisedRichText | null;
  tags: string[];
  meta: { title: string; description?: string | undefined; imageUrl?: string | undefined };
}

export const articlePageFromFragment = (fragment: ArticlePageFieldsFragment): ArticlePageView => ({
  id: fragment.sys.id,
  slug: fragment.slug ?? "",
  format: fragment.format ?? undefined,
  header: {
    title: fragment.articleTitle ?? "",
    publishedDateTime: fragment.publishDate ?? undefined,
    category: fragment.category
      ? { label: fragment.category.category ?? "", slug: fragment.category.slug ?? "" }
      : undefined,
    membersOnly: fragment.isRestrictedToMembersOnly ?? false,
    media: imageView(fragment.image),
  },
  readingTimeMinutes: fragment.readingTimeMinutes ?? undefined,
  body: fragment.body ? normaliseRichText(fragment.body) : null,
  tags: fragment.tags ?? [],
  meta: {
    title: fragment.seoTitle ?? fragment.articleTitle ?? "",
    description: fragment.seoDescription ?? undefined,
    imageUrl: fragment.image?.image?.url ?? undefined,
  },
});

What a transform is responsible for

  • Null-collapsing. Contentful fields are nullable unless required. The transform decides the app-facing default (?? "", ?? false, ?? undefined) so the component never handles a null.
  • Renaming to the component vocabulary. articleTitle → header.title, isRestrictedToMembersOnly → membersOnly. The app reads the design's words, not the CMS's.
  • Derived values. An image becomes a provider-agnostic ImageRef from @v4/image (the header's mediaRef), which the app resolves through the club's image provider (club.config.ts images — Scoreplay by default here, images.defaultProvider: "scoreplay"; see Images and focal points); an ISO date stays ISO (the app formats it).
  • Rich text goes through the central helper. Always normaliseRichText — never a bespoke walker. It is shared, tested, and the single place rich-text quirks are handled.

Pair it with a test

Add a vitest unit test with a representative fixture that exercises the nulls — Contentful fields are nullable, so a transform that assumes a value is the bug:

// packages/model/src/content/transforms/article-page.test.ts
import { describe, expect, it } from "vitest";
import { articlePageFromFragment } from "./article-page";

describe("articlePageFromFragment", () => {
  it("collapses a null category to undefined", () => {
    const view = articlePageFromFragment({
      sys: { id: "1" },
      articleTitle: "Match report",
      category: null,
      // … minimal fragment
    } as never);
    expect(view.header.category).toBeUndefined();
    expect(view.header.title).toBe("Match report");
  });
});

Gates

pnpm --filter @v4/model test
pnpm --filter @v4/model check-types
pnpm --filter @v4/model lint

Commit the transform and its test together with the fragment and generated types from the previous page — the migration → schema → types → fragment → transform chain is one reviewed change.

How the app consumes it

The route factory calls the transform after the query and passes the flat view to the component — this is the boundary between @v4/model (data) and the app (rendering):

const item = result.articlePageCollection?.items[0];
const view = item ? articlePageFromFragment(item) : null;
if (!view) notFound();
return <ArticlePageView header={/* view.header + media slot */} body={/* view.body */} />;
  • The Content model architecture reference — the full spine and its rationale.
  • .claude/rules/model/contentful-model.md — the binding fragment, transform and query authoring rules.
  • Add a page or route — where a transform is called.

Migrations in this repo

This repo was generated from the Publishing Hub template, and the template keeps shipping migrations for the domains it owns (core, content, video) through /sync-upstream. This project's own migrations live beside them. Four rules keep the two sequences from colliding.

Every new migration goes in migrations/club/

packages/model/migrations/
├── core/      the template's — never edited here
├── content/   the template's sequence (synced)
├── video/     the template's sequence (synced)
├── club/      THIS project's sequence — 0001 … 0063 at the time of writing
└── renamed.json

The domain directories hold the TEMPLATE's numbering. A file of ours in one of them collides with the template's next migration of the same number, and the sync stops: sync-upstream runs lint-migrations as a blocking gate before it opens a PR. That happened here on 6 Oct 2026, when two Euro League migrations sat in content/ as 0003 and 0004 and the template then shipped its own.

The runner applies the composition's domains in canonical order, then club/, each in filename order. Take the next free number in club/ — the highest is 0063-season-sponsors.cjs (0039 and 0062 are unused). basketball has no migration directory: its sports types and every later change to them are club migrations (club/0030-video-platform-sports-types.cjs, club/0038-basketball-types.cjs, …).

Moving an applied migration: renamed.json

The ledger (modelMigrationState, one entry per environment) records a migration by its path (<dir>/<file>), so a file that moves gets a new id and would run again — and a migration that creates a field fails the second time. Declare the move instead:

// packages/model/migrations/renamed.json
{
  "renames": {
    "content/0003-scroller-block-theme.cjs": "club/0015-scroller-block-theme.cjs",
    "content/0004-video-mux-and-youtube.cjs": "club/0016-video-mux-and-youtube.cjs",
  },
}

The runner reads a ledger holding the OLD id as having applied the NEW one and rewrites the entry, so every environment carries the move on its next migrate — protected ones through the deploy. lint-migrations requires the old file gone and the new one present. The file is this project's history: sync-upstream never offers it, and the template's is empty.

The baselines share a number, and go first

This repo's model starts from COMPOSED baselines, one per composed domain: core/0001-baseline-core.cjs, content/0001-baseline-content.cjs and video/0000-baseline-video.cjs. content/0001-baseline-content.cjs shares its number with content/0001-news-listing-content-blocks-limit.cjs because this repo was generated before bolt-on baselines were numbered 0000; renaming it would change its ledger id and re-run it, so the rule bends instead — a generated baseline (NNNN-baseline-*.cjs) may share its number and always runs first (scripts/migration-order.mjs). Never hand-edit a baseline.

Develop is migrated on merge, never before

Applying a migration to the develop environment ahead of its PR moves develop's schema under every other open PR, and their drift gates go red on a change they do not contain. Prove a migration on a throwaway clone of develop (Sandbox-apply), merge, and let model-apply-develop.yml apply it — it runs on a push to develop touching packages/model/migrations/**, packages/model/apps/** or packages/model/src/dictionary/keys.ts.

The model is code first here too: a field someone changed by hand on develop is drift for a migration to restate, never something to adopt by regenerating over the top. /model-review triages it.