Change the content model
The steps for a model change: write an expand/contract migration, curate a fragment and generate types, write the transform.
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-migrationis 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-reviewtriages 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, anddeleteContentTypeship one release after the code that stops depending on them, and must carry a// contract: <reason>marker orlint-migrations.mjsfails the PR. - Exception:
.required(true)chained onto a field born in the samecreateFieldstatement 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:
Clone
developto a temporary environment in Contentful (name ≤ 40 chars).Add it to the pre-production delivery key's environments (
pre-production-env-key; it wasdevelop-env-keybefore 14 Oct 2026).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 generateRemove 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— nevercontentful,contentful-management, or a raw GraphQL endpoint. - A domain may ship a minimal surface.
video'ssrc/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-ownedbasketballdomain's fragments live insrc/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 withpnpm --filter @v4/<x>-app generateafter a fragment changes. - GraphQL type names are not always the field ids. Contentful converts
names, so a field id
under18scan surface as a type or enum spelledUnder18S. 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 anull. - 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
ImageReffrom@v4/image(the header'smediaRef), which the app resolves through the club's image provider (club.config.tsimages— 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 */} />;
Related
- 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.