Stacks CMS
Key Paths
- Core package:
storage/framework/core/cms/src/
- Models:
storage/framework/defaults/app/Models/Content/ (Post, Author, Page)
- Models:
storage/framework/defaults/app/Models/ (Comment, Tag)
- Actions:
storage/framework/defaults/app/Actions/Cms/
- Routes:
routes/api.ts (CMS and blog endpoints)
- Config:
config/blog.ts
Source Files
cms/src/
├── index.ts # CmsNamespace — top-level API
├── posts/
│ ├── fetch.ts # fetchById, fetchAll, fetchByStatus, fetchByCategory, fetchByAuthor
│ ├── store.ts # store, attach, detach, sync (pivot tables)
│ ├── update.ts # update
│ └── destroy.ts # destroy, bulkDestroy
├── authors/
│ ├── fetch.ts # fetchAll, fetchById, findByEmail, findByName, findByUserId
│ ├── store.ts # store, findOrCreate
│ ├── update.ts # update
│ └── destroy.ts # destroy
├── pages/
│ ├── fetch.ts # fetchById, fetchAll, fetchByTemplate, fetchByAuthor
│ └── store.ts # store
├── categorizables/ # Categories with polymorphic pivot
├── taggables/ # Tags with polymorphic pivot
├── commentables/ # Comments with polymorphic support
└── build.ts # Blog static site generation
CMS Namespace
interface CmsNamespace {
posts: PostsModule
postCategories: PostCategoriesModule
tags: TagsModule
comments: CommentsModule
authors: AuthorsModule
pages: PagesModule
}
Models
Post
- Table:
posts
- Relationships:
belongsTo(['Author'])
- Traits: UUID, Timestamps, Search, Seeder (20), Categorizable, Taggable, Commentable, API routes
| Field |
Type |
Validation |
Default |
| title |
string |
required, 3-255 chars |
— |
| content |
string |
required, 10-1000 chars |
— |
| excerpt |
string |
optional, 10-500 chars |
— |
| poster |
string |
optional, valid URL |
— |
| views |
number |
— |
0 |
| publishedAt |
timestamp |
optional |
— |
| status |
enum |
published/draft/archived |
draft |
| isFeatured |
number |
0 or 1 |
— |
Author
- Table:
authors, Relationships: hasMany(['Post']), belongsTo(['User'])
- Attributes:
name (5-255), email (unique, valid email)
- Indexes: Composite on
(email, name)
Page
- Table:
pages, Relationships: belongsTo(['Author'])
- Attributes:
title, template (enum: default/landing/blog/contact), views, conversions, publishedAt
Comment
- Table:
comments, Relationships: belongsTo(['Post', 'User'])
- Attributes:
authorName, authorEmail, content (1-2000), status (pending/approved/spam/trash), ipAddress, userAgent, isApproved
Tag
- Table:
tags
- Attributes:
name (unique, 2-50), slug (unique), description, postCount, color
Posts API
// Fetch
cms.posts.fetchById(id: number): Promise<PostJsonResponse | undefined>
cms.posts.fetchAll(): Promise<PostJsonResponse[]>
cms.posts.fetchByStatus(status: 'published' | 'draft' | 'archived'): Promise<PostJsonResponse[]>
cms.posts.fetchByCategory(category: string): Promise<PostJsonResponse[]>
cms.posts.fetchByAuthor(author: string): Promise<PostJsonResponse[]>
cms.posts.fetchByMinViews(minViews: number): Promise<PostJsonResponse[]>
cms.posts.fetchPublishedAfter(timestamp: number): Promise<PostJsonResponse[]>
// Store with pivot auto-attach
cms.posts.store(data: NewPost & { body?: string, category?: string }): Promise<PostJsonResponse>
cms.posts.attach(postId, tableName: 'categorizable_models' | 'taggable_models', ids: number[]): Promise<void>
cms.posts.detach(postId, tableName, ids?: number[]): Promise<void>
cms.posts.sync(postId, tableName, ids: number[]): Promise<void>
// Update & Destroy
cms.posts.update(id: number, data: Partial<PostUpdate>): Promise<PostJsonResponse>
cms.posts.destroy(id: number): Promise<boolean>
cms.posts.bulkDestroy(ids: number[]): Promise<number>
Authors API
cms.authors.findOrCreate(data: AuthorData): Promise<AuthorJsonResponse>
cms.authors.store(data: NewAuthor): Promise<AuthorJsonResponse>
cms.authors.findByEmail(email: string): Promise<AuthorJsonResponse | undefined>
cms.authors.findByName(name: string): Promise<AuthorJsonResponse | undefined>
cms.authors.findByUserId(userId: number): Promise<AuthorJsonResponse | undefined>
cms.authors.fetchAll(): Promise<AuthorJsonResponse[]>
cms.authors.fetchById(id: number): Promise<AuthorJsonResponse | undefined>
cms.authors.update(id, data): Promise<AuthorJsonResponse>
cms.authors.destroy(id): Promise<boolean>
Tags API
cms.tags.findOrCreateMany(names: string[], taggableType: string): Promise<number[]>
cms.tags.firstOrCreate(name: string, taggableType: string): Promise<TaggableTable>
cms.tags.fetchTags(): Promise<TaggableTable[]>
cms.tags.fetchTagById(id: number): Promise<TaggableTable | undefined>
cms.tags.countTaggedPosts(taggableType: string): Promise<number>
cms.tags.findMostUsedTag(taggableType?: string): Promise<{ name: string, count: number } | null>
cms.tags.fetchTagsWithPostCounts(): Promise<Array<{ name: string, postCount: number }>>
cms.tags.fetchTagDistribution(): Promise<Array<{ name: string, count: number, percentage: number }>>
Comments API
cms.comments.createComment(data: CreateCommentInput): Promise<CommentablesTable>
cms.comments.updateComment(id: number, input: UpdateCommentInput): Promise<CommentablesTable>
cms.comments.approveComment(id: number): Promise<CommentablesTable>
cms.comments.rejectComment(id: number): Promise<CommentablesTable>
cms.comments.deleteComment(id: number): Promise<void>
Routes
CMS Admin Routes
| Method |
Path |
Action |
| GET |
/cms/posts |
PostIndexAction |
| GET |
/cms/posts/{id} |
PostShowAction |
| POST |
/cms/posts |
PostStoreAction |
| PATCH |
/cms/posts/{id} |
PostUpdateAction |
| DELETE |
/cms/posts/{id} |
PostDestroyAction |
| PATCH |
/cms/posts/{id}/views |
PostViewsUpdateAction |
| CRUD |
/cms/authors/* |
Author CRUD |
| CRUD |
/cms/categories/* |
Categorizable CRUD |
| CRUD |
/cms/tags/* |
Taggable CRUD |
| CRUD |
/cms/comments/* |
Comment CRUD |
| CRUD |
/cms/pages/* |
Page CRUD |
Public Blog Routes
| Method |
Path |
Description |
| GET |
/blog/posts |
Public post listing |
| GET |
/blog/posts/{id} |
Public post detail |
| GET |
/blog/categories |
Public categories |
| GET |
/blog/tags |
Public tags |
| GET |
/blog/feed.xml |
RSS 2.0 feed (20 recent) |
| GET |
/blog/sitemap.xml |
XML sitemap for SEO |
Blog Configuration (config/blog.ts)
{
subdomain: 'blog',
title: 'Stacks Blog',
description: 'The official Stacks.js blog',
postsPerPage: 10,
enableComments: true,
enableRss: true,
enableSitemap: true,
enableSearch: true,
social: { twitter: '@stacksjs', github: 'stacksjs/stacks' },
theme: { primaryColor: '#3451b2', logo: '/images/logos/logo-transparent.svg' },
}
Database Tables
| Table |
Purpose |
posts |
Blog posts |
authors |
Content creators |
pages |
Static pages |
comments |
Comments (polymorphic) |
tags |
Tag definitions |
taggable_models |
Polymorphic pivot: tag ↔ model |
categorizables |
Category definitions |
categorizable_models |
Polymorphic pivot: category ↔ model |
Gotchas
- Polymorphic relations — categories, tags, and comments use
*_type fields to support multiple model types
- Post
content maps from body — the model attribute is content but the DB column mapping comes from body
- Author uses findOrCreate —
PostStoreAction auto-creates authors if they don't exist
- Post store auto-attaches — creating a post with category/tag data automatically calls
attach() on pivot tables
- Post update uses sync — updating categories/tags uses
sync() (detaches removed, attaches new)
- Views increment is atomic —
PATCH /cms/posts/{id}/views increments by 1
- RSS returns 20 items — hardcoded to 20 most recent published posts
- Sitemap priorities — posts 0.8, categories 0.6, blog homepage 0.9
- Comment status flow — starts
pending, can be approved, rejected (→ spam), or trashed
1---2name: stacks-cms3description: Use when working with the CMS in a Stacks application - posts, authors, pages, categories, tags, comments, blog configuration, RSS feeds, or sitemaps. Covers @stacksjs/cms, CMS models, routes, and actions.4license: MIT5---67# Stacks CMS89## Key Paths10- Core package: `storage/framework/core/cms/src/`11- Models: `storage/framework/defaults/app/Models/Content/` (Post, Author, Page)12- Models: `storage/framework/defaults/app/Models/` (Comment, Tag)13- Actions: `storage/framework/defaults/app/Actions/Cms/`14- Routes: `routes/api.ts` (CMS and blog endpoints)15- Config: `config/blog.ts`1617## Source Files18```19cms/src/20├── index.ts # CmsNamespace — top-level API21├── posts/22│ ├── fetch.ts # fetchById, fetchAll, fetchByStatus, fetchByCategory, fetchByAuthor23│ ├── store.ts # store, attach, detach, sync (pivot tables)24│ ├── update.ts # update25│ └── destroy.ts # destroy, bulkDestroy26├── authors/27│ ├── fetch.ts # fetchAll, fetchById, findByEmail, findByName, findByUserId28│ ├── store.ts # store, findOrCreate29│ ├── update.ts # update30│ └── destroy.ts # destroy31├── pages/32│ ├── fetch.ts # fetchById, fetchAll, fetchByTemplate, fetchByAuthor33│ └── store.ts # store34├── categorizables/ # Categories with polymorphic pivot35├── taggables/ # Tags with polymorphic pivot36├── commentables/ # Comments with polymorphic support37└── build.ts # Blog static site generation38```3940## CMS Namespace4142```typescript43interface CmsNamespace {44 posts: PostsModule45 postCategories: PostCategoriesModule46 tags: TagsModule47 comments: CommentsModule48 authors: AuthorsModule49 pages: PagesModule50}51```5253## Models5455### Post56- **Table**: `posts`57- **Relationships**: `belongsTo(['Author'])`58- **Traits**: UUID, Timestamps, Search, Seeder (20), Categorizable, Taggable, Commentable, API routes5960| Field | Type | Validation | Default |61|-------|------|------------|---------|62| title | string | required, 3-255 chars | — |63| content | string | required, 10-1000 chars | — |64| excerpt | string | optional, 10-500 chars | — |65| poster | string | optional, valid URL | — |66| views | number | — | 0 |67| publishedAt | timestamp | optional | — |68| status | enum | published/draft/archived | draft |69| isFeatured | number | 0 or 1 | — |7071### Author72- **Table**: `authors`, **Relationships**: `hasMany(['Post'])`, `belongsTo(['User'])`73- **Attributes**: `name` (5-255), `email` (unique, valid email)74- **Indexes**: Composite on `(email, name)`7576### Page77- **Table**: `pages`, **Relationships**: `belongsTo(['Author'])`78- **Attributes**: `title`, `template` (enum: default/landing/blog/contact), `views`, `conversions`, `publishedAt`7980### Comment81- **Table**: `comments`, **Relationships**: `belongsTo(['Post', 'User'])`82- **Attributes**: `authorName`, `authorEmail`, `content` (1-2000), `status` (pending/approved/spam/trash), `ipAddress`, `userAgent`, `isApproved`8384### Tag85- **Table**: `tags`86- **Attributes**: `name` (unique, 2-50), `slug` (unique), `description`, `postCount`, `color`8788## Posts API8990```typescript91// Fetch92cms.posts.fetchById(id: number): Promise<PostJsonResponse | undefined>93cms.posts.fetchAll(): Promise<PostJsonResponse[]>94cms.posts.fetchByStatus(status: 'published' | 'draft' | 'archived'): Promise<PostJsonResponse[]>95cms.posts.fetchByCategory(category: string): Promise<PostJsonResponse[]>96cms.posts.fetchByAuthor(author: string): Promise<PostJsonResponse[]>97cms.posts.fetchByMinViews(minViews: number): Promise<PostJsonResponse[]>98cms.posts.fetchPublishedAfter(timestamp: number): Promise<PostJsonResponse[]>99100// Store with pivot auto-attach101cms.posts.store(data: NewPost & { body?: string, category?: string }): Promise<PostJsonResponse>102cms.posts.attach(postId, tableName: 'categorizable_models' | 'taggable_models', ids: number[]): Promise<void>103cms.posts.detach(postId, tableName, ids?: number[]): Promise<void>104cms.posts.sync(postId, tableName, ids: number[]): Promise<void>105106// Update & Destroy107cms.posts.update(id: number, data: Partial<PostUpdate>): Promise<PostJsonResponse>108cms.posts.destroy(id: number): Promise<boolean>109cms.posts.bulkDestroy(ids: number[]): Promise<number>110```111112## Authors API113114```typescript115cms.authors.findOrCreate(data: AuthorData): Promise<AuthorJsonResponse>116cms.authors.store(data: NewAuthor): Promise<AuthorJsonResponse>117cms.authors.findByEmail(email: string): Promise<AuthorJsonResponse | undefined>118cms.authors.findByName(name: string): Promise<AuthorJsonResponse | undefined>119cms.authors.findByUserId(userId: number): Promise<AuthorJsonResponse | undefined>120cms.authors.fetchAll(): Promise<AuthorJsonResponse[]>121cms.authors.fetchById(id: number): Promise<AuthorJsonResponse | undefined>122cms.authors.update(id, data): Promise<AuthorJsonResponse>123cms.authors.destroy(id): Promise<boolean>124```125126## Tags API127128```typescript129cms.tags.findOrCreateMany(names: string[], taggableType: string): Promise<number[]>130cms.tags.firstOrCreate(name: string, taggableType: string): Promise<TaggableTable>131cms.tags.fetchTags(): Promise<TaggableTable[]>132cms.tags.fetchTagById(id: number): Promise<TaggableTable | undefined>133cms.tags.countTaggedPosts(taggableType: string): Promise<number>134cms.tags.findMostUsedTag(taggableType?: string): Promise<{ name: string, count: number } | null>135cms.tags.fetchTagsWithPostCounts(): Promise<Array<{ name: string, postCount: number }>>136cms.tags.fetchTagDistribution(): Promise<Array<{ name: string, count: number, percentage: number }>>137```138139## Comments API140141```typescript142cms.comments.createComment(data: CreateCommentInput): Promise<CommentablesTable>143cms.comments.updateComment(id: number, input: UpdateCommentInput): Promise<CommentablesTable>144cms.comments.approveComment(id: number): Promise<CommentablesTable>145cms.comments.rejectComment(id: number): Promise<CommentablesTable>146cms.comments.deleteComment(id: number): Promise<void>147```148149## Routes150151### CMS Admin Routes152| Method | Path | Action |153|--------|------|--------|154| GET | `/cms/posts` | PostIndexAction |155| GET | `/cms/posts/{id}` | PostShowAction |156| POST | `/cms/posts` | PostStoreAction |157| PATCH | `/cms/posts/{id}` | PostUpdateAction |158| DELETE | `/cms/posts/{id}` | PostDestroyAction |159| PATCH | `/cms/posts/{id}/views` | PostViewsUpdateAction |160| CRUD | `/cms/authors/*` | Author CRUD |161| CRUD | `/cms/categories/*` | Categorizable CRUD |162| CRUD | `/cms/tags/*` | Taggable CRUD |163| CRUD | `/cms/comments/*` | Comment CRUD |164| CRUD | `/cms/pages/*` | Page CRUD |165166### Public Blog Routes167| Method | Path | Description |168|--------|------|-------------|169| GET | `/blog/posts` | Public post listing |170| GET | `/blog/posts/{id}` | Public post detail |171| GET | `/blog/categories` | Public categories |172| GET | `/blog/tags` | Public tags |173| GET | `/blog/feed.xml` | RSS 2.0 feed (20 recent) |174| GET | `/blog/sitemap.xml` | XML sitemap for SEO |175176## Blog Configuration (config/blog.ts)177178```typescript179{180 subdomain: 'blog',181 title: 'Stacks Blog',182 description: 'The official Stacks.js blog',183 postsPerPage: 10,184 enableComments: true,185 enableRss: true,186 enableSitemap: true,187 enableSearch: true,188 social: { twitter: '@stacksjs', github: 'stacksjs/stacks' },189 theme: { primaryColor: '#3451b2', logo: '/images/logos/logo-transparent.svg' },190}191```192193## Database Tables194195| Table | Purpose |196|-------|---------|197| `posts` | Blog posts |198| `authors` | Content creators |199| `pages` | Static pages |200| `comments` | Comments (polymorphic) |201| `tags` | Tag definitions |202| `taggable_models` | Polymorphic pivot: tag ↔ model |203| `categorizables` | Category definitions |204| `categorizable_models` | Polymorphic pivot: category ↔ model |205206## Gotchas207- **Polymorphic relations** — categories, tags, and comments use `*_type` fields to support multiple model types208- **Post `content` maps from `body`** — the model attribute is `content` but the DB column mapping comes from `body`209- **Author uses findOrCreate** — `PostStoreAction` auto-creates authors if they don't exist210- **Post store auto-attaches** — creating a post with category/tag data automatically calls `attach()` on pivot tables211- **Post update uses sync** — updating categories/tags uses `sync()` (detaches removed, attaches new)212- **Views increment is atomic** — `PATCH /cms/posts/{id}/views` increments by 1213- **RSS returns 20 items** — hardcoded to 20 most recent published posts214- **Sitemap priorities** — posts 0.8, categories 0.6, blog homepage 0.9215- **Comment status flow** — starts `pending`, can be approved, rejected (→ spam), or trashed