Skip to main content

Content Modeling Patterns

Bad content modeling decisions are the hardest thing to fix later. This page covers the trade-offs between Strapi's modeling primitives and patterns that scale.

Content modeling overview


Collection types vs single types

UseCollection TypeSingle Type
EntriesMany (articles, products, users)Exactly one (homepage, site settings, footer)
API/api/articles, /api/articles/:id/api/homepage
Create new entriesYes, unlimitedNo, only update the single entry
Use whenYou have multiple items of the same shapeThere is only ever one instance

Common single types

Site Settings      -- logo, site title, social links, analytics ID
Homepage -- hero, featured articles, CTA blocks
Footer -- columns, links, copyright text
Navigation -- menu items (as a repeatable component)
Global SEO -- default meta tags, OG image fallback

When NOT to use a single type

If you ever think "what if we need a second one?", use a collection type. Converting a single type to a collection type later is painful.


Components

Components are reusable groups of fields that can be embedded in any content type. They do not have their own API endpoint -- they only exist as part of a parent document.

When to use components

  • Repeated field groups: SEO metadata (title, description, ogImage) used across multiple types
  • Structured sub-objects: Address (street, city, zip, country) embedded in Company and Author
  • Repeatable items: A list of FAQ entries (question + answer) inside a Page

Defining a component

// src/components/shared/seo.json
{
"collectionName": "components_shared_seos",
"info": {
"displayName": "SEO",
"icon": "search",
"description": "Search engine optimization metadata"
},
"attributes": {
"metaTitle": {
"type": "string",
"maxLength": 60
},
"metaDescription": {
"type": "text",
"maxLength": 160
},
"ogImage": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"canonicalURL": {
"type": "string"
},
"noIndex": {
"type": "boolean",
"default": false
}
}
}

Using in a content type

{
"attributes": {
"seo": {
"type": "component",
"component": "shared.seo"
},
"faqs": {
"type": "component",
"component": "shared.faq",
"repeatable": true
}
}
}

Component categories

Organize components into categories for clarity:

src/components/
├── shared/ # Cross-cutting: SEO, breadcrumbs, social links
│ ├── seo.json
│ ├── social-link.json
│ └── breadcrumb.json
├── blocks/ # Page builder blocks
│ ├── hero.json
│ ├── text-with-image.json
│ ├── gallery.json
│ ├── cta.json
│ └── testimonial.json
├── layout/ # Structural: header, footer, navigation
│ ├── menu-item.json
│ └── footer-column.json
└── form/ # Form-related
├── text-input.json
├── select-option.json
└── form-field.json

Dynamic zones

Dynamic zones let editors choose from a set of components in any order -- the classic "page builder" pattern.

Defining a dynamic zone

{
"attributes": {
"blocks": {
"type": "dynamiczone",
"components": [
"blocks.hero",
"blocks.text-with-image",
"blocks.gallery",
"blocks.cta",
"blocks.testimonial",
"blocks.video-embed",
"blocks.accordion"
]
}
}
}

Querying dynamic zones

const page = await strapi.documents('api::page.page').findOne(documentId, {
populate: {
blocks: {
on: {
'blocks.hero': { populate: ['backgroundImage', 'cta'] },
'blocks.text-with-image': { populate: ['image'] },
'blocks.gallery': { populate: ['images'] },
'blocks.cta': true,
'blocks.testimonial': { populate: ['avatar'] },
'blocks.video-embed': true,
'blocks.accordion': true,
},
},
},
});

Frontend rendering pattern

// React component for rendering dynamic zones
function DynamicZone({ blocks }) {
const componentMap = {
'blocks.hero': HeroBlock,
'blocks.text-with-image': TextWithImageBlock,
'blocks.gallery': GalleryBlock,
'blocks.cta': CtaBlock,
'blocks.testimonial': TestimonialBlock,
'blocks.video-embed': VideoEmbedBlock,
'blocks.accordion': AccordionBlock,
};

return (
<div>
{blocks?.map((block, index) => {
const Component = componentMap[block.__component];
if (!Component) {
console.warn(`Unknown block type: ${block.__component}`);
return null;
}
return <Component key={`${block.__component}-${index}`} {...block} />;
})}
</div>
);
}

Components vs relations: decision guide

FactorComponentRelation
Own API endpointNoYes
Reusable across entriesEmbedded (duplicated per entry)Referenced (single source of truth)
Editable independentlyNo, only via parentYes, has its own edit page
PerformanceFetched with parent (no join)Requires populate (join query)
Use forSEO metadata, address, FAQ itemsAuthors, Categories, Tags

When to use a component

  • The data only makes sense in the context of its parent
  • Each entry has its own copy (e.g., each page has its own SEO fields)
  • You don't need to query the data independently

When to use a relation

  • The data is shared across multiple entries (one Author, many Articles)
  • You need to query it independently (list all Categories)
  • You need referential integrity (changing the Author name updates everywhere)

Anti-pattern: component when you need a relation

❌ Article has a "author" component with name, bio, avatar
→ Changing the author's bio means updating every article

✅ Article has a relation to Author collection type
→ Change the bio once, it's reflected everywhere

Anti-pattern: relation when you need a component

❌ Article has a relation to "SEO" collection type
→ Creates orphaned SEO entries, confusing admin UI, unnecessary joins

✅ Article has an "SEO" component embedded
→ SEO data lives with the article, no orphans, faster queries

Modeling patterns

Pattern: polymorphic content (dynamic zone)

A page that can contain any combination of blocks:

Page
├── title (string)
├── slug (string)
├── seo (component: shared.seo)
└── blocks (dynamic zone)
├── blocks.hero
├── blocks.rich-text
├── blocks.image-grid
└── blocks.call-to-action

Pattern: taxonomy with tags

Article
├── title
├── content
├── category (relation: manyToOne → Category)
└── tags (relation: manyToMany → Tag)

Category (collection type)
├── name
├── slug
└── articles (relation: oneToMany → Article)

Tag (collection type)
├── name
├── slug
└── articles (relation: manyToMany → Article)

Pattern: settings hierarchy

SiteSettings (single type)
├── siteName
├── logo (media)
├── defaultLocale
├── socialLinks (repeatable component: shared.social-link)
├── navigation (repeatable component: layout.menu-item)
└── footer (component: layout.footer)

Pattern: product with variants

Product (collection type)
├── name
├── description
├── basePrice
├── images (media, multiple)
├── category (relation → Category)
├── variants (repeatable component: product.variant)
│ ├── size
│ ├── color
│ ├── sku
│ ├── priceModifier
│ └── stock
└── specifications (repeatable component: product.spec)
├── key
└── value

Pattern: nested navigation

NavigationItem (component: layout.nav-item)
├── label (string)
├── url (string)
├── target (enum: _self, _blank)
├── icon (media)
└── children (repeatable component: layout.nav-item) ← self-referencing

Strapi allows components to reference themselves for tree-like structures, but limit the nesting depth to avoid performance issues.


Naming conventions

ConventionExampleRationale
Singular collection namesarticle, not articlesStrapi auto-pluralizes for API endpoints
Kebab-case for slugsblog-post, not blogPostConsistent URL-friendly identifiers
Category prefix for componentsshared.seo, blocks.heroGroups related components in the admin
Descriptive field namespublishedDate, not dateUnambiguous when used alongside other dates
Boolean prefixisActive, hasFeaturedClear intent

Schema evolution tips

SituationApproach
Adding a new fieldNon-breaking. Add with a sensible default.
Renaming a fieldBreaking. Create new field, migrate data, remove old field.
Changing a component to a relationBreaking. Requires a data migration script.
Adding a new block to a dynamic zoneNon-breaking. Just add the component UID to the array.
Removing a block from a dynamic zoneBreaking if existing entries use it. Migrate first.

Common pitfalls

PitfallProblemFix
Too many relations on one typeSlow queries, complex populationLimit to 5-7 relations max; denormalize if needed
Deep component nestingHard to query, hard to populateKeep nesting to 2-3 levels max
Using JSON fields instead of componentsNo admin UI, no validation, no populationUse components for structured data
One giant "Page" type for everythingBloated schema, confusing editor UXCreate separate types: LandingPage, BlogPost, ProductPage
Not planning for i18nRetrofit is painfulDecide localization per field upfront

See also