Chapter 4: Content Architecture
This chapter provides a deep dive into Vibe CMS's content architecture, exploring how collections, fields, content items, and translations work together to create a powerful, flexible content management system.
1. Collections Deep Dive
What is a Collection?
A collection in Vibe CMS is a content type definition - a blueprint that defines the structure and schema for a specific type of content. Think of collections as tables in a database or classes in object-oriented programming.
Collection Properties
Collections are defined in the cms_collections table with the following properties:
- id: UUID primary key
- project_id: Links collection to a specific project
- account_id: Links collection to the owning account
- name: Human-readable display name (e.g., "Blog Posts", "Team Members")
- slug: URL-friendly identifier (e.g., "blog-posts", "team-members")
- description: Optional text describing the collection's purpose
- is_singleton: Boolean flag determining collection type
- created_at / updated_at: Timestamp tracking
Singleton vs Multi-Item Collections
Multi-Item Collections (is_singleton = false)
Most collections are multi-item collections, designed to hold multiple content items:
- Blog Posts: Many blog posts in one collection
- Products: Multiple products in an e-commerce catalog
- Team Members: Multiple team member profiles
- FAQ Items: Many frequently asked questions
Use multi-item collections when you need to create, manage, and query multiple instances of a content type.
Singleton Collections (is_singleton = true)
Singleton collections are designed to hold exactly one content item:
- Homepage: Single homepage content
- Site Settings: Global configuration
- About Page: Single about page content
- Footer Configuration: One footer across the site
Singleton collections are perfect for unique, one-off content that doesn't need multiple instances.
Collection Slugs and Naming
Collection slugs must be:
- Unique within a project: No two collections can share the same slug in a project
- URL-friendly: Use lowercase letters, numbers, and hyphens
- Descriptive: Clearly indicate the content type ("blog-posts", not "bp")
- Stable: Changing slugs affects API calls and queries
The combination of (project_id, slug) has a unique index, ensuring no duplicate slugs per project.
2. Field Schema Design
Field Structure
Fields are defined in the cms_collection_fields table and determine what data each content item can store. Each field has:
- id: UUID primary key
- collection_id: Links field to its collection
- account_id: Account ownership
- field_name: Identifier for the field in stored data
- field_type: Data type (text, markdown, number, boolean, file)
- interface_type: UI widget for editing (input, textarea, markdown, single_file, multiple_files)
- is_required: Whether the field must have a value
- sort_order: Display order in admin UI (lower numbers first)
Available Field Types
Vibe CMS enforces field types at the database level via check constraints:
text
Plain text content without formatting. Use for:
- Titles and headings
- Short descriptions
- Names and labels
- URLs and slugs
markdown
Rich text content with markdown formatting. Use for:
- Blog post bodies
- Long-form content
- Documentation
- Formatted descriptions
number
Numeric values (integers or decimals). Use for:
- Prices and costs
- Quantities and counts
- Rankings and scores
- Order/priority values
boolean
True/false values. Use for:
- Feature flags (published, featured, archived)
- Settings and toggles
- Visibility controls
file
File references (single file or multiple files). Use for:
- Images and media
- Document attachments
- Downloads and assets
- Gallery images
Interface Types
Interface types determine how fields are edited in the admin UI:
input
Single-line text input. Best for:
- Field types: text, number
- Use cases: Titles, names, short text, numbers
textarea
Multi-line text input. Best for:
- Field types: text
- Use cases: Descriptions, summaries, longer text without formatting
markdown
Markdown editor with preview. Best for:
- Field types: markdown
- Use cases: Blog posts, articles, formatted content
single_file
Single file picker. Best for:
- Field types: file
- Use cases: Featured image, profile photo, single document
- Storage: Stores file ID as reference
multiple_files
Multiple file picker. Best for:
- Field types: file
- Use cases: Image galleries, multiple attachments, media collections
- Storage: Stores array of file IDs
Required vs Optional Fields
The is_required boolean flag determines whether a field must be populated:
- Required fields (is_required = true): Must have a non-empty value when creating or updating content
- Optional fields (is_required = false): Can be left empty
Required field validation is enforced by the validate_and_upsert_translation function, which checks that:
- All required fields are present in new translations
- Required fields are not empty (non-null, non-empty string)
- Required fields cannot be deleted in updates
Field Ordering
The sort_order integer determines the display order of fields in the admin UI:
- Lower numbers appear first (0, 1, 2...)
- Fields with the same sort_order are ordered by creation date
- Use gaps (0, 10, 20) to allow easy reordering without renumbering all fields
The reorder_collection_fields function allows bulk reordering by providing a complete ordered list of field names.
Field Naming Conventions
Field names must follow these rules (enforced in application logic):
- Lowercase only: "title", not "Title"
- Letters, numbers, underscores: "published_at", "sort_order_2"
- Must start with a letter: "title", not "1_title"
- No spaces or special characters: "blog_post", not "blog-post" or "blog post"
- Descriptive and clear: "featured_image", not "img1"
The combination of (collection_id, field_name) has a unique index.
3. Content Item Structure
Two-Table Architecture
Vibe CMS separates content metadata from translation data:
cms_content_items (Metadata)
Stores metadata about each content item:
- id: UUID - logical identifier for the content item
- version_id: UUID - links to cms_versions table
- collection_id: UUID - links to cms_collections
- project_id: UUID - project ownership
- account_id: UUID - account ownership
- status: text - draft, published, or archived
- description: text - optional item description
- Primary key: (id, version_id) - composite key for versioning
cms_content_item_data (Translation Data)
Stores locale-specific content data:
- id: UUID - references cms_content_items.id
- version_id: UUID - references cms_content_items.version_id
- locale: text - language code (e.g., "en-US", "fr-FR")
- account_id: UUID - account ownership
- data: JSONB - flexible field storage
- Primary key: (id, version_id, locale) - composite key
JSONB Data Storage
Content field values are stored as JSONB in the data column:
{
"title": "Welcome to Our Blog",
"slug": "welcome-to-our-blog",
"content": "# Hello World\n\nThis is our first post...",
"published_at": "2025-11-03T12:00:00Z",
"featured_image": "file-uuid-here",
"tags": ["announcement", "blog"]
}
JSONB provides:
- Schema flexibility: Add/remove fields without database migrations
- Query capability: PostgreSQL can query inside JSONB fields
- Type preservation: Numbers, booleans, arrays stored with correct types
- Performance: Indexed and optimized for fast queries
Translation Structure
Each content item can have multiple translations (one per locale):
Content Item (id: abc-123, version_id: v1)
├── Translation (locale: en-US)
│ └── data: { "title": "Hello", "content": "..." }
├── Translation (locale: fr-FR)
│ └── data: { "title": "Bonjour", "content": "..." }
└── Translation (locale: de-DE)
└── data: { "title": "Hallo", "content": "..." }
Status Workflow
Content items have a status field with three states:
- draft: Content being created or edited (not visible publicly)
- published: Content ready for public viewing
- archived: Content no longer active but preserved
The status is enforced via check constraint:
CHECK (status = ANY (ARRAY['draft'::text, 'published'::text, 'archived'::text]))
Composite Primary Keys
Both content tables use composite primary keys for versioning support:
- cms_content_items:
(id, version_id) - cms_content_item_data:
(id, version_id, locale)
This allows the same logical content item (same id) to exist across multiple versions, with version-specific data isolated in each version.
4. Database Architecture
Core CMS Tables
cms_collections
- Purpose: Content type definitions
- Primary key: id (UUID)
- Unique constraint: (project_id, slug)
- Key indexes: project_id, slug, account_id
cms_collection_fields
- Purpose: Field definitions for collections
- Primary key: id (UUID)
- Unique constraint: (collection_id, field_name)
- Key indexes: collection_id, (collection_id, field_name), (collection_id, sort_order)
- Check constraints: field_type, interface_type
cms_content_items
- Purpose: Content item metadata (draft content)
- Primary key: (id, version_id) - composite
- Key indexes: id, version_id, collection_id, project_id, status, (project_id, version_id)
- Foreign keys: collection_id, version_id, project_id, account_id
cms_content_item_data
- Purpose: Translation data for content items (draft content)
- Primary key: (id, version_id, locale) - composite
- Key indexes: id, version_id, locale, (id, version_id, locale)
- Foreign keys: (id, version_id) references cms_content_items
cms_content_items_published
- Purpose: Read-optimized table for serving published content
- Primary key: (item_id, version_id, locale) - composite
- Denormalized fields: collection_slug, project_id (eliminates JOINs)
- Key indexes: (collection_slug, locale), (project_id, locale), version_id, item_id
- Foreign keys: (item_id, version_id) references cms_content_items
Draft vs Published Architecture
Vibe CMS maintains separate tables for draft and published content:
Draft Tables (Write-Heavy)
- cms_content_items: All content items with draft edits
- cms_content_item_data: All translations with draft edits
- Access: Authenticated users via SECURITY DEFINER functions
- Use case: Content creation, editing, management
Published Table (Read-Heavy)
- cms_content_items_published: Denormalized, read-optimized published content
- Access: Public via get_published_content_item() and get_published_content_items_for_collection()
- Use case: Fast public content delivery to websites and applications
Trigger-Based Synchronization
Published content is synchronized from draft tables using two strategies:
Bulk Synchronization (Primary)
Publishing operations (publish_draft, rollback_to_version) perform bulk sync:
- DELETE all published content for the project
- INSERT all status='published' items from the target version
- Denormalize collection_slug and project_id for fast queries
Denormalized Field Sync (Secondary)
Triggers maintain denormalized fields when source data changes:
sync_collection_slug_to_published
- Trigger: AFTER UPDATE OF slug ON cms_collections
- Purpose: Updates collection_slug in published table when collection slug changes
- Scope: All published items for the affected collection
sync_locale_to_published
- Trigger: AFTER UPDATE OF locale_code ON cms_locales
- Purpose: Updates locale field when locale code changes
- Scope: All published items with the affected locale
These triggers are SECURITY DEFINER and run with service_role privileges.
Security Model: Function-Only Access
All CMS tables use the "function-only access" security pattern:
- RLS Enabled: Row Level Security is ON for all tables
- No RLS Policies: No policies defined (blocks all direct queries)
- Table Grants: Only service_role has table-level access
- Function Access: All data access through SECURITY DEFINER functions
- Permission Checks: Functions explicitly validate has_project_access()
This provides:
- Defense in depth: Multiple security layers
- Auditable access: All operations through known functions
- Clear authorization: Explicit permission checks in every function
- Protection against SQL injection: Parameterized queries only
5. Schema Validation
Field Type Validation
Field types are enforced at multiple levels:
Database Level
Check constraints on cms_collection_fields:
CHECK (field_type = ANY (ARRAY['text', 'markdown', 'number', 'boolean', 'file']))
CHECK (interface_type = ANY (ARRAY['input', 'textarea', 'markdown', 'single_file', 'multiple_files']))
Function Level
The validate_and_upsert_translation function validates:
- All field names in data exist in collection schema
- All required fields are present and non-empty
- Data structure matches expected types
Required Field Enforcement
Required fields are validated by validate_and_upsert_translation:
For new translations:
- Query all required fields for the collection
- Check each required field exists in provided data
- Check each required field has a non-null, non-empty value
- Raise exception if any required field missing or empty
For updates:
- Allow partial updates (only include changed fields)
- Prevent deletion of required fields (setting to null)
- Preserve existing required field values if not in update
Schema Evolution
The JSONB architecture supports schema evolution:
Adding Fields
- Add field to cms_collection_fields via
create_collection_field() - Existing content items automatically support new field
- New field can be required or optional
- Existing content remains valid (optional fields are absent)
Removing Fields
- Delete field from cms_collection_fields via
delete_collection_field() validate_and_upsert_translationautomatically cleans orphaned fields- Existing field data in JSONB is cleaned on next update
- No data migration required
Changing Field Types
- Update field_type via
update_collection_field() - Application code handles data conversion
- Old data remains until updated
- Validation applies to new/updated content
Data Type Constraints
While JSONB is flexible, Vibe CMS enforces constraints:
- Field existence: Only fields defined in collection schema are allowed
- Required fields: Must be present and non-empty
- Type consistency: Application code enforces type matching (text as string, number as number, etc.)
- File references: File type fields store UUID references to cms_files table
6. Best Practices
When to Use Singletons vs Multi-Item Collections
Use singleton collections when:
- Content is unique and appears once (homepage, site settings)
- You never need multiple instances
- Content represents global configuration
- Examples: Homepage, About Page, Footer, Site Settings
Use multi-item collections when:
- You need multiple instances of the same type
- Content will be listed, filtered, or searched
- Each item is independent
- Examples: Blog Posts, Products, Team Members, FAQs
Field Naming Conventions
Follow these conventions for consistency:
DO:
- Use lowercase:
title,published_at - Use underscores for spaces:
featured_image,author_name - Be descriptive:
hero_background_image, notimg1 - Match common conventions:
slug,status,created_at - Use consistent suffixes:
_atfor timestamps,_idfor references
DON'T:
- Use camelCase:
featuredImage(usefeatured_image) - Use hyphens:
hero-image(usehero_image) - Use spaces:
featured image(usefeatured_image) - Use cryptic abbreviations:
fbg_img(usefeatured_background_image) - Start with numbers:
2nd_title(usetitle_2)
Schema Design Patterns
Blog Post Collection
Collection: blog-posts (multi-item)
Fields:
- title (text, input, required)
- slug (text, input, required)
- excerpt (text, textarea, optional)
- content (markdown, markdown, required)
- published_at (text, input, optional)
- featured_image (file, single_file, optional)
- author_name (text, input, required)
- tags (text, input, optional)
Product Collection
Collection: products (multi-item)
Fields:
- name (text, input, required)
- slug (text, input, required)
- description (markdown, markdown, required)
- price (number, input, required)
- sale_price (number, input, optional)
- in_stock (boolean, input, required)
- featured_image (file, single_file, required)
- gallery_images (file, multiple_files, optional)
- sku (text, input, required)
Homepage Collection
Collection: homepage (singleton)
Fields:
- hero_title (text, input, required)
- hero_subtitle (text, textarea, required)
- hero_cta_text (text, input, required)
- hero_cta_url (text, input, required)
- hero_background (file, single_file, optional)
- features_title (text, input, required)
- features_content (markdown, markdown, required)
Performance Considerations
Indexing Strategy
- Collection slugs: Indexed for fast lookup by slug
- Field names: Composite index (collection_id, field_name) for validation
- Content queries: Indexed by collection_id, version_id, locale
- Published content: Denormalized and indexed for public API performance
JSONB Query Performance
- JSONB supports GIN indexes for fast field queries
- Denormalized published table eliminates JOINs
- Composite primary keys enable efficient version isolation
- Status index accelerates published content filtering
Scaling Recommendations
- Use published table for all public content delivery
- Cache frequently accessed content at application layer
- Minimize field count (aim for < 20 fields per collection)
- Use file references instead of embedding large data
- Consider content pagination for large collections
Content Modeling Strategies
Keep Collections Focused
- One collection per distinct content type
- Avoid "mega collections" with dozens of fields
- Split complex content into multiple collections with relationships
Plan for Localization
- All content is locale-specific by default
- Think about which fields need translation vs which are universal
- File references are locale-agnostic (same file across locales)
Design for Editors
- Order fields logically (title first, metadata last)
- Use required fields sparingly (only truly required)
- Provide clear field names and descriptions
- Choose appropriate interface types for data entry
Future-Proof Your Schema
- Add optional fields for future needs
- Use consistent naming across collections
- Document field purposes and expected values
- Plan for schema evolution (JSONB makes this easy)
What's Next?
Now that you understand Vibe CMS's content architecture, you're ready to explore:
- Chapter 5: Files & Assets - Managing media, images, and file uploads
- Chapter 6: Versioning & Publishing - Content versioning, rollback, and publishing workflows
- Chapter 7: SDK Integration - Using the TypeScript SDK to query content in your application
This chapter covers the database architecture and content modeling patterns in Vibe CMS. For API reference and code examples, see the SDK documentation.