Use when working with Stacks CMS posts, authors, block-document pages, revisions, menus, redirects, scheduled publishing, blog feeds, or public page serving. Covers @stacksjs/cms, site-scoped CMS models, routes, and actions.
Resources
1Install
npx skillscat add stacksjs/stacks/stacks-cms Install via the SkillsCat registry.
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
├── blog.ts # Markdown posts in content/blog: loadPosts, loadPost, renderPostBody, bylines
├── 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 generationCMS Namespace
Enable/install the CMS feature through its supported lifecycle, then generate
and review migrations for changed models. The legacy posts/authors/pages
namespace below coexists with the document-page exports described in
PAGES.md. For a real page editor or public CMS fallback, start with
the validated document APIs rather than raw pages column writes.
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>The commentable trait
A model with traits: { commentable: true } (singular; commentables is inert) gains instance methods
keyed to the record's own id:
await post.addComment({ body, author_name?, author_email?, user_id?, title? }, { status: 'pending' | 'approved' })
await post.comments() / post.approvedComments() / post.pendingComments() / post.rejectedComments() // oldest first
await post.commentCount()Only those five fields are written, so a validated form body can go straight in; status and owner come
from the trait. author_name / author_email are for guest commenters (no account); never render the
email. Moderate without a dashboard with buddy comments:list [--status] [--type],comments:approve <id>, comments:reject <id>, comments:delete <id>.
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 |
The site-wide /sitemap.xml merges the published posts in, so crawlers find them from the root sitemap too (see stacks-server, "Sitemap and robots.txt").
Markdown Blog (content/blog, @stacksjs/cms/blog)
The simplest blog has no tables: each post is content/blog/<slug>.md, shipped with the commit that adds it. Add resources/views/blog.stx (index) and resources/views/blog/[slug].stx (post); once blog.stx exists the views server serves only /blog/feed.xml and /blog/sitemap.xml itself and leaves the pages to stx, and /sitemap.xml lists the posts. Pages, feed and sitemap all read posts through @stacksjs/cms/blog, so they agree on what is published.
import { articleHead } from '@stacksjs/cms/article'
import { bylineOf, createAuthorResolver, formatPostDate, loadPost, loadPosts, renderPostBody } from '@stacksjs/cms/blog'
const posts = loadPosts() // published, featured first then newest; no bodies
const post = loadPost(useRoute().params?.slug) // null for a draft, unknown or path-like slug -> notFound()
const html = renderPostBody(post.body) // Bun markdown + renderPostHtml; {!! html !!} plus {!! articleHead() !!}
// Bylines: create once at module scope (it caches lookups for ten minutes).
export const resolveAuthors = createAuthorResolver({
lookup: async username => /* the app's profile */ ({ name, avatar, bio, url: `/${username}` }),
})
const byline = bylineOf(await resolveAuthors(post)) // [{ name, initials, avatar, bio, url, joiner, avatarStyle }]Frontmatter is one key: value per line: title and date (YYYY-MM-DD) are required, draft: true hides a post everywhere, featured: true lists it first, description is the excerpt and the feed text, author is the feed's plain-text byline, image/imageAlt the preview picture. Several authors: authors: ada, grace plus flat fallbacks author.ada.name, author.ada.avatar, author.ada.bio (a frontmatter bio wins over the profile's). One author may use authorUsername/authorAvatar/authorBio. Every key is also on post.frontmatter. Also exported: parseFrontmatter, isPublished, authorsOf, readingMinutes (post.minutes), formatPostDate, joinNames, initialsOf, sortPosts, isPostSlug, blogContentDir.
Post Bodies: Images, Grids, Zoom and Embeds
Blog post HTML goes through renderPostHtml from @stacksjs/cms/article, built on ts-medium-editor's article renderer. The BunPress blog (core/actions/src/blog.ts) does this already; an stx-native blog calls it from its post view:
const { renderPostHtml, articleHead, articleScript } = require('@stacksjs/cms/article')
post.html = renderPostHtml(markdownHtml) // then {!! articleHead() !!} in @push('head'), {!! articleScript() !!} in @push('scripts')What authors get from plain markdown:
alone in a paragraph becomes a figure with a caption.- Images in consecutive paragraphs become a grid: two side by side, three with the tallest on the left and two stacked on the right, four as 2x2, more in rows of three and two. Sizes are read from the files in
public/, so columns are proportioned and nothing crops or shifts. - Lazy loading after the first image, click-to-zoom on every unlinked image.
- A bare YouTube, Vimeo or
https://hq.training/a/<id>link on its own line becomes an embed.
Tune the look with custom properties (--me-radius, --me-gap, --me-block-space, --me-caption-color, --me-zoom-backdrop) on the post body. imageSize(bytes) and publicImageResolver(dir) are exported for other uses.
Blog Configuration (config/blog.ts)
{
subdomain: 'blog',
title: 'Stacks Blog',
description: 'The official Stacks.js blog',
postsPerPage: 10,
enableComments: true, // declared, NOT yet honoured by the static builder
enableRss: true, // generates feed.xml
enableSitemap: true, // generates sitemap.xml
enableSearch: true, // declared, NOT yet honoured by the static builder
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
- CMS document pages are scoped to an explicit site id. Pair them with
stacks-sites; background work has no implicit request site. - CMS post categories use
categorizables. The Post model'sbelongsToMany(Category)currently resolves commercecategories, a separate
id space (tracked in #2584). Use the CMS categorization module for CMS ids;
do not treat that relationship as an interchangeable alias. - Polymorphic relations - categories, tags, and comments use
*_typefields to support multiple model types - Post
contentmaps frombody- the model attribute iscontentbut the DB column mapping comes frombody - Author uses findOrCreate -
PostStoreActionauto-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}/viewsincrements 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