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_projections table
  • 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

FeatureComponent ArrayEntity Array (Relations)
StorageJSONB inlineedges table
Read Performance⚡ Fastest (no joins)Fast (indexed joins)
Reusability❌ No✅ Yes
Independent Queries❌ No✅ Yes
Reverse Lookups❌ No✅ Yes
Type SyntaxComponentClass[]EntityClass[] (auto-detected)
Best ForTightly coupled contentReusable, 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?: Author or tags?: 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