Designing One Content Model for Many Teams and Products: Shared Types, Local Extensions and Ownership
To design one content model for many teams and products, split it into a small set of shared core types (product, person, legal, and brand) that every team references, then let each team add local extensions on top instead of forking the…
To design one content model for many teams and products, split it into a small set of shared core types (product, person, legal, and brand) that every team references, then let each team add local extensions on top instead of forking the schema. In Sanity, the schema is code in sanity.config.ts, so several workspaces can share one schema.types array while each team gets its own Studio view. GROQ's -> operator then joins shared and local content in one query.
The failure usually starts small. A regional team needs one extra field on product, so it clones the type. Six months later there are four product schemas and three copies of the warranty text, and a legal update reaches only two of five markets. Every fork is a migration you'll have to pay for later. Every copied paragraph raises a compliance question that nobody can answer with a query.
This guide treats the problem as ownership design, not field naming. It covers what is shared and what is local, why reuse should use references and never copies, how regional variance is layered on, who approves changes to shared types, how those types evolve without breaking consumers, and how each team sees only what it edits while everything stays queryable in one place.
What belongs in a shared core type and what belongs in a local extension?
A shared core type is a document type that means the same thing to every team that uses it: a product, a person, a legal clause, or a brand. A local extension is anything only one team or product needs, such as a campaign hero, a market-specific promo block, or a channel-specific SEO object. The test is simple. If two teams would argue about what a field means, it isn't ready to be shared.
Keep the shared core small and boring. A shared product type should hold identity and facts (SKU, name, specifications, and canonical media) and nothing about presentation. Presentation belongs to the team that renders it. Once a core type starts collecting fields like heroLayout or usPromoBadge, it has stopped being shared. It has become one team's type that everyone else has to scroll past.
Local extensions should point at the core instead of changing it. A productPage owned by the web team references product and adds its own layout fields. A retailListing owned by the commerce team references the same product and adds channel data. Neither team edits the other's fields, and neither needs its own copy of the specifications.
The anti-pattern is forking, for example copying the product type into productUS and productEU because one market needs an extra field. A fork is cheap on day one and costs you from then on. Every later change to the core has to be applied once per fork, and every query has to know which fork it is reading.
In Sanity, this split between shared core types and local extensions lives in code. Types are defined with defineType and exported as arrays. A core module (product, person, legal, and brand) can be imported by every workspace, and team modules sit next to it. The Sanity multi-tenancy guide passes an array of workspaces to defineConfig, each with its own name, basePath, and dataset, all sharing the same schema.types, so market teams create content with a consistent structure across datasets. The same guide shows how to hide or lock fields by role, market, or workspace. That gives you local variation without a second copy of the type.
Should products and regions reference shared content or copy it?
Products and regions should reference shared content, not copy it. A reference points to one document, so a change to a legal clause or a product spec appears everywhere that document is used. A copy is a snapshot that starts drifting from the original as soon as it is pasted. Sanity's documentation describes the usual enterprise shape: one team manages product data, several brand and market teams each run their own digital experience, and a central legal team supplies Terms of Service and warranty copy. In that doc's words, getting all of them to refer to the same single source of truth is a challenge in most CMSes and often leads to duplication of effort and content debt.
A separate document is sometimes the right call. If a market needs a legally distinct warranty, that is a separate document with its own owner, not a variant of the global one. Ask whether the two versions should change together. If they should, use a reference. If they must differ by design, create a new document and record which one it replaces.
In Sanity, a standard reference field creates a binding relationship between documents in the same dataset. GROQ follows it with the -> operator, so a page query can project product->{name, sku, specs} without a second request. When shared content lives in its own dataset, such as a global legal dataset used by several market datasets, the crossDatasetReference type links documents across datasets in the same project. Plan for its constraints. Each field can target only one referenced dataset, and the dataset name, target types, and preview fields must be known when you define the field.
The multi-tenancy guide also explains why separate projects rarely work for shared content. Separate projects fully separate members and project configuration, and they keep content from colliding. But they prevent referenced content, and any common settings must be duplicated by hand. For a model built around shared core types, that trade-off usually rules them out.
Check plan tiers before you design
How do you layer regional variance without duplicating documents?
Regional variance should sit on top of the shared core as overrides that reference it, never as duplicated documents. The global product document holds the facts that are true everywhere. A regional document, owned by the market team, references that product and stores only what differs, such as a localized name, a regional price note, or a market-specific legal reference. Any value the regional document doesn't override falls through to the global one.
This keeps the differences visible. A regional override is an explicit document with an owner and a history. Where no override exists, the global value applies, and nobody has to remember to update twelve copies of the same paragraph.
Two layouts work for regional variance. The default is one dataset shared by many teams, with regional documents next to global ones, separated by Structure tool filters and role permissions. The step up is one dataset per market or environment. The Sanity multi-tenancy guide uses names like hotels_us_production and hotels_no_development, plus a shared global dataset that market datasets reach through cross-dataset references. Choose this layout when markets need stronger isolation of content and members, and accept that each cross-dataset field points at exactly one target dataset.
At read time, GROQ resolves the layers in the query instead of in application code. A projection can use the regional value where it exists and fall back to the global value otherwise, for example "name": coalesce(regionalName, product->name). The frontend then gets one merged shape per market and never has to know how the layers are stored.
Sanity has announced Content Variants, in closed beta. It keeps variant versions in the same document and returns the right one based on what you pass about the visitor. Until it is generally available, model regional variance with references and overrides. That pattern works today and keeps every override explicit, owned, and queryable.
Who should own and approve changes to shared content types?
A small platform or content-engineering group should own shared content types and approve changes to them through code review. Team-specific extensions belong to the teams that use them. Ownership has two layers that people often mix up: who can change the shape of a type, and who can change the content inside it. Mixing them up is how a market editor ends up able to remove a field that six products depend on.
Ownership of a type's shape belongs in version control. When the schema is code, a change to the shared product type is a pull request. The reviewers are the consumers who will feel the change: the web, app, and commerce teams. Assigning the core schema directory to the platform group in your repository's code-owner rules makes that review mandatory. Sanity's docs recommend deploying the Studio from a git-based platform with PR or branch deployments, so reviewers can try the changed Studio before merging.
With schema as code, your content model lives in version control alongside your application code, so what your CMS stores can't drift from what your frontend expects. Blueprints (GA) bring the same discipline to project infrastructure. sanity.blueprint.ts is kept in the repository beside your application code, and if it is not in the blueprint, it is not deployed. Deploying a project-scoped blueprint requires an admin role or the sanity.project.blueprints.deploy permission, which is an ownership boundary in its own right.
Ownership of content belongs in roles. Sanity's built-in roles are Administrator, Editor, Viewer, Contributor (drafts only), and Developer. On Enterprise, you define custom roles through the Access API with permissions backed by GROQ filters (sanity.document.filter). That lets a role create and update only documents in an articles-only resource. The multi-tenancy guide's example is a role that can publish only article documents for the Norway team. One caution from Sanity's docs: in a single project shared by many teams, admin-level members can see all project settings, including tokens and webhooks, so keep people who only edit content out of admin roles.
Audits become queries
How do you change a shared type without breaking the teams that use it?
You change a shared type without breaking its consumers by adding the new shape first, deprecating the old one, migrating content, and removing anything only at the end. Shared types have many readers you don't control: a mobile app on an old release, a partner feed, a search index. A rename that is trivial in one codebase can cause outages in three others.
The safe sequence is expand, migrate, contract. Add the new field next to the old one. Mark the old field as deprecated and read-only so editors stop writing to it. Migrate existing documents. Give consumers time to switch. Remove the old field last, and announce the removal through the same pull-request process you used for the change.
Sanity supports each step of evolving a shared type. A field or type can be marked deprecated: { reason: '...' }, which shows in the Studio and maps to @deprecated in GraphQL. Pair it with readOnly. Next, run sanity documents validate to see which documents fail against the new schema. Scaffold a migration with sanity migrations create, then dry run it with sanity migrations run <ID>. Once the patches look right, apply them with --no-dry-run. Write migrations with defineMigration from sanity/migrate and make them idempotent with a filter precondition such as defined(category) && !defined(categories), so a second run does nothing. For production, test first on a staging copy of the dataset with PR or branch deployments.
The query layer protects consumers that haven't shipped their changes yet. Sanity's migration guidance suggests aliasing the new shape to the old name with GROQ's coalesce function, written as "oldFieldName": coalesce(newFieldName, oldFieldName). The frontend keeps reading the field name it knows while the content underneath moves to the new structure. When the last consumer switches, the alias and the deprecated field go away together.
Additions are cheap, renames break things
How do you give each team a focused editing view while keeping one content store?
Give each team a focused editing view by filtering the same content store for each team, not by giving each team its own store. Editors don't need to see every document type in the model. They need their own products, pages, and legal references, plus a way to pick from shared content. Splitting the store to get there swaps a UI problem for a data problem, because the shared core then has to be kept in sync across stores.
In Sanity, the Structure tool and its Structure Builder API customize document lists with GROQ filters. A StructureResolver receives context that includes currentUser, so it can return a different structure for each role. The commerce team sees products and listings. The Norway market team sees documents filtered to its market. The legal team sees clauses and the pages that reference them. Sanity's recipe for this pattern is a structure with custom roles. Sanity Learn has examples of showing, hiding, and filtering types per role, along with role-specific initial values and document actions. Workspaces add a broader split, with a separate name and basePath for each team or region, each with its own navigation and all sharing one schema.
A focused editing view hides other teams' content from the editor without walling it off. Every document still lives in one Content Lake, so a question that spans products is still a single GROQ query. A brand team can ask which products in every market reference a given legal clause. A platform team can find every page still writing to a deprecated field before it removes that field.
This is the Model your business pillar in practice. Sanity is the Content Operating System for the AI era, and the differentiator that matters here is that legacy CMSes create silos while Sanity provides a shared foundation: one structured content layer that connects teams, systems, surfaces, and agents. Legacy CMSes also make you work their way, while Sanity adapts to yours. Here, that means each team's Studio is built in code to match how that team works, on top of a model everyone shares.
Sharing one content model across teams: Sanity vs Contentful, Strapi, and Payload
| Feature | Sanity | Contentful | Strapi | Payload |
|---|---|---|---|---|
| Where the shared schema lives | Schema as code: defineType modules in sanity.config.ts, shared as one schema.types array across workspaces and reviewed in git pull requests | Content models are managed in the platform through the web app or CLI. Templated spaces let a central team manage one content model and reuse it across spaces | Content-types and Components are built in the Content-type Builder and saved as schema files in the project, so they can be versioned with the application | Collections, field configs, and Blocks are defined in code in the Payload config, close to schema as code, with an admin UI generated from that config |
| Reusing shared types without forking | Core types (product, person, legal, and brand) are imported as modules into every workspace. Local extension types reference the core and can hide or lock fields by role, market, or workspace | Templated spaces push a centrally managed model to many spaces. Within a space, entries link to other entries for reuse | Components are reusable field sets added to content-types or dynamic zones, or nested in other components. This works well for reusing fields within one application | Field configs and Blocks are plain code, so they can be imported and reused across collections like any other module in the project |
| References across products and regions | reference plus GROQ -> resolves shared content in one query. crossDatasetReference links to a global dataset in the same project (Enterprise, one target dataset per field) | Cross-space references use the ResourceLink field and Contentful Resource Names on Premium plans, with limits on how many spaces and environments each field can target | Relations link entries between content-types within one Strapi application. Sharing content across separate deployments needs an integration you build yourself | Relationship fields link documents across collections within one Payload application. Sharing across separate deployments needs custom sync code |
| Focused editing views per team | Structure Builder resolvers read currentUser and return role-specific lists filtered with GROQ. Workspaces give each team its own basePath on a shared schema | Fixed editor layout with UI extensions available. Teams are usually separated with spaces, environments, and role permissions | The admin panel uses role-based permissions to control which content-types each role can access. Going beyond that requires customizing the admin | The admin UI is generated from the config. Access control functions written in code and custom components can tailor what each user sees |
| Changing a shared type safely | Mark fields deprecated and readOnly, run sanity documents validate, dry run with sanity migrations run, then use GROQ coalesce() aliases for consumers that haven't updated yet | Content model changes are made in the web app or scripted with the CLI and migration tooling, and environments let you test changes before promoting them | Schema file changes ship with application code. Data migrations for renamed or restructured fields are handled in scripts you maintain | Config changes ship with application code and go through your normal review. Reshaping content for renamed fields is migration code you maintain |