Entities & Components
Master Helix's flexible content modeling system
Overview
Helix provides two fundamental building blocks for content modeling: Entities and Components. Understanding when to use each is key to building efficient, maintainable content architectures.
Entities
Entities are top-level, reusable content types with a global identity. They can be created, queried, and referenced independently.
Characteristics
- Have a unique global ID (UUID)
- Can be queried independently via the API
- Can be referenced by other Entities via
@Relation() - Support reverse relationships (backlinks)
- Appear in the Admin Studio sidebar
- Stored in the
entity_projectionstable - Defined as exported classes
import { Slug, Asset } from '@helix/sdk';
// Exported classes are automatically Entities
export class Page {
@Slug()
slug!: Slug;
@Localized()
title!: string;
featuredImage?: Asset;
}
export class Author {
name!: string;
avatar?: Asset;
bio?: string;
}
export class Article {
@Slug()
slug!: Slug;
@Localized()
title!: string;
author?: Author; // Relation - inferred from Entity type
}
When to Use Entities
- Content that needs to be reusable across multiple pages
- Content that needs independent querying or filtering
- Content with many-to-many relationships
- Content that has its own lifecycle
Components
Components are embeddable content blocks with no global identity. They live inside their parent Entity and are stored as JSONB.
Characteristics
- No global ID (identified by block ID within parent)
- Cannot be queried independently
- Stored inline within parent's JSONB column
- Lifecycle tied to parent Entity
- Extremely fast to read (no joins required)
- Do NOT appear in the Admin Studio sidebar
- Defined as non-exported classes
// Non-exported classes are automatically Components
class SEO {
@Localized()
metaTitle?: string;
@Localized()
metaDescription?: string;
@Widget('textarea')
@Localized()
ogDescription?: string;
}
class Hero {
@Localized()
title!: string;
@Localized()
@Widget('textarea')
subtitle?: string;
background?: Asset;
}
// Exported class - this is an Entity
export class LandingPage {
@Slug()
slug!: Slug;
@Localized()
title!: string;
// Embedded component (stored as JSONB)
seo?: SEO;
// Array of embedded components (stored as JSONB array)
heroes?: Hero[];
}
When to Use Components
- Content tightly coupled to a single parent
- Content that doesn't need independent querying
- Structured data blocks (SEO, Hero sections, etc.)
- Performance-critical paths
Entity Relations
Entities can reference other Entities simply by using their type. Relations are automatically inferred from your TypeScript types and stored in the dedicated edges table for efficient querying and reverse lookups.
export class Article {
@Localized()
title!: string;
// One-to-one relation - inferred from Entity type
author?: Author;
// One-to-many relation - inferred from Entity array type
categories?: Category[];
// Many-to-many (self-referential)
relatedArticles?: Article[];
}
export class Author {
name!: string;
avatar?: Asset;
}
export class Category {
name!: string;
slug!: Slug;
}
Helix automatically detects that Author, Category, and Article are exported classes (Entities) and creates relations in the edges table. Arrays automatically become many-to-many relations.
Localized Relations
Relations can be localized, allowing different related entities per locale:
export class Product {
@Localized()
name!: string;
// Same author across all locales (non-localized)
author?: Author;
// Different featured products per locale (localized)
@Localized()
relatedProducts?: Product[];
}
Dynamic Zones
Dynamic Zones are polymorphic arrays that can contain a mix of Components and/or Entity references. They're automatically inferred from union type arrays. Perfect for page builders and flexible content layouts.
// Components (non-exported)
class Hero {
@Localized()
title!: string;
@Localized()
@Widget('textarea')
subtitle?: string;
background?: Asset;
}
class TextBlock {
@Localized()
@Widget('richtext')
content!: string;
}
class Cta {
@Localized()
text!: string;
@Require('url')
link!: string;
@Widget('color')
backgroundColor?: string;
}
// Entities (exported)
export class Feature {
name!: string;
icon?: Asset;
description?: string;
}
export class Testimonial {
@Localized()
quote!: string;
author!: string;
avatar?: Asset;
}
export class LandingPage {
@Slug()
slug!: Slug;
@Localized()
title!: string;
// Dynamic Zone - automatically inferred from union type array
@Localized()
sections!: (Hero | TextBlock | Cta | Feature | Testimonial)[];
}
No @Zone() decorator needed! Helix automatically detects union type arrays like (Hero | TextBlock | Feature)[] and creates a Dynamic Zone. The Admin Studio provides a builder UI where editors can add, remove, and reorder any of the allowed types.
Dynamic Zones preserve item order. The Admin Studio uses fractional indexing for efficient, conflict-free reordering across branches.
Comparing Storage Approaches
Component Arrays vs Entity Arrays
| Feature | Component Array | Entity Array (Relations) |
|---|---|---|
| Storage | JSONB inline | edges table |
| Read Performance | ⚡ Fastest (no joins) | Fast (indexed joins) |
| Reusability | ❌ No | ✅ Yes |
| Independent Queries | ❌ No | ✅ Yes |
| Reverse Lookups | ❌ No | ✅ Yes |
| Type Syntax | ComponentClass[] | EntityClass[] (auto-detected) |
| Best For | Tightly coupled content | Reusable, shareable content |
Example: When to Use Each
// ❌ Bad: Using Entities for tightly-coupled content
export class Page {
slug!: Slug;
title!: string;
// DON'T: SEO is tightly coupled to the page, shouldn't be an Entity
seo?: SEOMetadata;
}
export class SEOMetadata { // DON'T export this
metaTitle?: string;
metaDescription?: string;
}
// ✅ Good: Using Components for tightly-coupled content
class SEO { // Non-exported = Component
@Localized()
metaTitle?: string;
@Localized()
metaDescription?: string;
}
export class Page {
@Slug()
slug!: Slug;
@Localized()
title!: string;
// DO: SEO is embedded as a component
seo?: SEO;
}
// ✅ Good: Using Entities for reusable content
export class Article {
@Slug()
slug!: Slug;
@Localized()
title!: string;
// DO: Authors are reusable across many articles (relation inferred)
author?: Author;
// DO: Tags are reusable and need independent querying (many relation inferred)
tags?: Tag[];
}
export class Author {
name!: string;
bio?: string;
}
export class Tag {
name!: string;
@Widget('color')
color?: string;
}
Real-World Example
Here's a complete, realistic content model combining Entities, Components, Relations, and Dynamic Zones:
import { Slug, Asset, Localized, Relation, Zone, Widget, Require, Description } from '@helix/sdk';
// Components (embedded, non-reusable)
class SEO {
@Localized()
@Description("Appears in browser tabs and search results")
metaTitle?: string;
@Localized()
@Widget('textarea')
@Require({ maxLength: 160 })
metaDescription?: string;
}
class Hero {
@Localized()
title!: string;
@Localized()
@Widget('textarea')
subtitle?: string;
background?: Asset;
@Widget('color')
overlayColor?: string;
}
class FeatureGrid {
@Localized()
heading?: string;
features!: FeatureItem[];
}
class FeatureItem {
icon?: Asset;
@Localized()
title!: string;
@Localized()
description?: string;
}
// Entities (top-level, reusable)
export class Author {
name!: string;
avatar?: Asset;
@Widget('textarea')
bio?: string;
@Require('url')
website?: string;
}
export class Category {
name!: string;
@Slug()
slug!: Slug;
@Widget('color')
color?: string;
}
export class Article {
@Slug()
slug!: Slug;
@Localized()
@Require({ minLength: 3, maxLength: 100 })
title!: string;
@Localized()
@Widget('textarea')
excerpt?: string;
@Localized()
@Widget('richtext')
@Require({ minLength: 100 })
content!: string;
featuredImage?: Asset;
// Relations - automatically inferred from Entity types
author?: Author;
categories?: Category[];
@Localized()
relatedArticles?: Article[];
@Widget('datetime')
publishedAt?: Date;
seo?: SEO;
}
export class LandingPage {
@Slug()
slug!: Slug;
@Localized()
title!: string;
// Dynamic Zone - automatically inferred from union type array
@Localized()
sections!: (Hero | FeatureGrid | Article)[];
seo?: SEO;
}
Best Practices
- Default to Components for embedded, non-reusable content blocks (SEO, Hero sections, etc.)
- Use Entities (exported classes) when content needs reusability, independent querying, or reverse lookups
- Relations are automatic - just use Entity types like
author?: Authorortags?: Tag[] - Dynamic Zones are automatic - just use union type arrays like
sections!: (Hero | TextBlock | Feature)[] - Consider read patterns: Components are faster to read but less flexible than Entity relations
- Keep Component nesting depth reasonable - avoid deeply nested structures for performance
- Localize relations by adding
@Localized()when different locales need different related content - Use meaningful names - they appear directly in the Admin Studio UI