Content Management System
Stacks.js includes a flexible, headless CMS for building content-driven applications. It provides content modeling, media management, and a powerful API for content delivery.
Configuration
CMS Configuration
// config/cms.ts
export default {
// Enable CMS functionality
enabled: true,
// Admin panel path
adminPath: '/admin',
// API path for content
apiPath: '/api/content',
// Content storage
storage: {
driver: 'database', // 'database' | 'file' | 'git'
connection: 'default',
},
// Media configuration
media: {
disk: 'public',
path: 'media',
allowedTypes: ['image/*', 'video/*', 'application/pdf'],
maxSize: 10 * 1024 * 1024, // 10MB
transforms: {
thumbnail: { width: 150, height: 150, fit: 'cover' },
medium: { width: 600, height: 400, fit: 'inside' },
large: { width: 1200, height: 800, fit: 'inside' },
},
},
// Content versioning
versioning: {
enabled: true,
maxVersions: 10,
},
// Draft/publish workflow
workflow: {
enabled: true,
states: ['draft', 'review', 'published', 'archived'],
},
// Localization
localization: {
enabled: true,
defaultLocale: 'en',
locales: ['en', 'es', 'fr'],
},
}
Content Models
Defining Collections
// content/collections/posts.ts
import { defineCollection } from '@stacksjs/cms'
export default defineCollection({
name: 'posts',
label: 'Blog Posts',
icon: 'document-text',
// Slugs for URL generation
slug: {
field: 'title',
prefix: '/blog/',
},
// Fields definition
fields: [
{
name: 'title',
type: 'string',
label: 'Title',
required: true,
validation: 'required|min:3|max:200',
},
{
name: 'slug',
type: 'slug',
label: 'URL Slug',
from: 'title',
},
{
name: 'excerpt',
type: 'textarea',
label: 'Excerpt',
maxLength: 300,
},
{
name: 'content',
type: 'richtext',
label: 'Content',
required: true,
},
{
name: 'featured_image',
type: 'media',
label: 'Featured Image',
accept: 'image/*',
},
{
name: 'author',
type: 'relation',
label: 'Author',
collection: 'authors',
displayField: 'name',
},
{
name: 'categories',
type: 'relation',
label: 'Categories',
collection: 'categories',
multiple: true,
},
{
name: 'tags',
type: 'tags',
label: 'Tags',
},
{
name: 'published_at',
type: 'datetime',
label: 'Publish Date',
},
{
name: 'seo',
type: 'group',
label: 'SEO',
fields: [
{ name: 'meta_title', type: 'string', label: 'Meta Title' },
{ name: 'meta_description', type: 'textarea', label: 'Meta Description' },
{ name: 'og_image', type: 'media', label: 'OG Image' },
],
},
],
// Default sorting
defaultSort: { field: 'published_at', direction: 'desc' },
// Enable features
timestamps: true,
softDeletes: true,
versioning: true,
localization: true,
})
Field Types
// Available field types
const fieldTypes = {
// Text fields
string: { maxLength: 255 },
text: { rows: 3 },
textarea: { rows: 5 },
richtext: { toolbar: ['bold', 'italic', 'link', 'image'] },
markdown: { preview: true },
code: { language: 'typescript' },
// Number fields
number: { min: 0, max: 100, step: 1 },
decimal: { precision: 2 },
// Selection fields
select: { options: ['option1', 'option2'] },
radio: { options: ['option1', 'option2'] },
checkbox: { },
toggle: { },
// Date/Time
date: { format: 'YYYY-MM-DD' },
datetime: { format: 'YYYY-MM-DD HH:mm' },
time: { format: 'HH:mm' },
// Media
media: { accept: 'image/*', multiple: false },
file: { accept: '*/*' },
gallery: { accept: 'image/*' },
// Relations
relation: { collection: 'posts', multiple: false },
// Special
slug: { from: 'title' },
tags: { suggestions: true },
color: { format: 'hex' },
json: { },
blocks: { types: ['text', 'image', 'video'] },
// Layout
group: { fields: [] },
repeater: { fields: [], min: 0, max: 10 },
}
Singletons (Global Content)
// content/singletons/settings.ts
import { defineSingleton } from '@stacksjs/cms'
export default defineSingleton({
name: 'settings',
label: 'Site Settings',
fields: [
{
name: 'site_name',
type: 'string',
label: 'Site Name',
required: true,
},
{
name: 'logo',
type: 'media',
label: 'Logo',
},
{
name: 'social',
type: 'group',
label: 'Social Links',
fields: [
{ name: 'twitter', type: 'string', label: 'Twitter URL' },
{ name: 'facebook', type: 'string', label: 'Facebook URL' },
{ name: 'instagram', type: 'string', label: 'Instagram URL' },
],
},
{
name: 'footer_text',
type: 'richtext',
label: 'Footer Text',
},
],
})
Content API
Fetching Content
import { Content } from '@stacksjs/cms'
// Get all posts
const posts = await Content.collection('posts').get()
// Get single post by slug
const post = await Content.collection('posts')
.where('slug', 'my-post')
.first()
// Get with relations
const post = await Content.collection('posts')
.with('author', 'categories')
.find(id)
// Filter and sort
const recentPosts = await Content.collection('posts')
.where('status', 'published')
.where('published_at', '<=', new Date())
.orderBy('published_at', 'desc')
.limit(10)
.get()
// Pagination
const paginatedPosts = await Content.collection('posts')
.where('status', 'published')
.paginate(20, page)
// Search
const results = await Content.collection('posts')
.search('typescript tutorial')
.get()
Fetching Singletons
// Get singleton data
const settings = await Content.singleton('settings')
// Access fields
console.log(settings.site_name)
console.log(settings.social.twitter)
Creating & Updating Content
// Create new content
const post = await Content.collection('posts').create({
title: 'My New Post',
content: 'Post content here...',
status: 'draft',
})
// Update content
await Content.collection('posts').update(id, {
title: 'Updated Title',
})
// Publish content
await Content.collection('posts').publish(id)
// Unpublish
await Content.collection('posts').unpublish(id)
// Delete (soft delete if enabled)
await Content.collection('posts').delete(id)
// Restore
await Content.collection('posts').restore(id)
// Force delete
await Content.collection('posts').forceDelete(id)
Versioning
// Get version history
const versions = await Content.collection('posts')
.versions(id)
// Restore specific version
await Content.collection('posts')
.restoreVersion(id, versionId)
// Compare versions
const diff = await Content.collection('posts')
.compareVersions(id, versionId1, versionId2)
Localized Content
// Get content in specific locale
const post = await Content.collection('posts')
.locale('es')
.find(id)
// Get all locales for content
const translations = await Content.collection('posts')
.allLocales(id)
// Update specific locale
await Content.collection('posts')
.locale('es')
.update(id, { title: 'Título en español' })
Media Management
Uploading Media
import { Media } from '@stacksjs/cms'
// Upload file
const media = await Media.upload(file, {
folder: 'posts',
alt: 'Image description',
})
// Upload with transforms
const media = await Media.upload(file, {
transforms: ['thumbnail', 'medium', 'large'],
})
// Get transformed URL
media.url('thumbnail') // /media/posts/image-thumbnail.jpg
media.url('medium') // /media/posts/image-medium.jpg
media.url() // /media/posts/image.jpg (original)
Media Library
// Browse media
const files = await Media.browse({
folder: 'posts',
type: 'image',
page: 1,
limit: 50,
})
// Search media
const files = await Media.search('hero image')
// Get media by ID
const file = await Media.find(mediaId)
// Update media metadata
await Media.update(mediaId, {
alt: 'Updated alt text',
title: 'Image title',
})
// Delete media
await Media.delete(mediaId)
// Create folder
await Media.createFolder('products/2024')
// Move media
await Media.move(mediaId, 'new-folder')
Admin Panel
Accessing Admin
// routes/web.ts
import { cmsRoutes } from '@stacksjs/cms'
// Mount CMS admin routes
router.group({ prefix: '/admin', middleware: ['auth', 'admin'] }, () => {
cmsRoutes()
})
Customizing Admin
// config/cms.ts
export default {
admin: {
// Custom branding
branding: {
logo: '/images/admin-logo.svg',
name: 'My CMS',
colors: {
primary: '#3B82F6',
secondary: '#10B981',
},
},
// Dashboard widgets
dashboard: {
widgets: [
'recent-content',
'content-stats',
'media-usage',
'activity-log',
],
},
// Navigation customization
navigation: [
{ label: 'Dashboard', icon: 'home', link: '/admin' },
{ label: 'Content', icon: 'document', children: [
{ label: 'Posts', link: '/admin/posts' },
{ label: 'Pages', link: '/admin/pages' },
]},
{ label: 'Media', icon: 'photo', link: '/admin/media' },
{ label: 'Settings', icon: 'cog', link: '/admin/settings' },
],
},
}
Blocks Editor
Defining Blocks
// content/blocks/hero.ts
import { defineBlock } from '@stacksjs/cms'
export default defineBlock({
name: 'hero',
label: 'Hero Section',
icon: 'sparkles',
fields: [
{ name: 'heading', type: 'string', label: 'Heading' },
{ name: 'subheading', type: 'text', label: 'Subheading' },
{ name: 'image', type: 'media', label: 'Background Image' },
{ name: 'cta', type: 'group', label: 'Call to Action', fields: [
{ name: 'text', type: 'string', label: 'Button Text' },
{ name: 'url', type: 'string', label: 'Button URL' },
]},
],
// Preview template
preview: ({ heading, image }) => ({
title: heading,
image: image?.url,
}),
})
Using Blocks in Collections
// content/collections/pages.ts
export default defineCollection({
name: 'pages',
label: 'Pages',
fields: [
{ name: 'title', type: 'string', required: true },
{
name: 'content',
type: 'blocks',
label: 'Page Content',
blocks: ['hero', 'text', 'image', 'gallery', 'cta', 'faq'],
},
],
})
Rendering Blocks
<!-- pages/[slug].stx -->
<template>
<x-layout
<h1{{ page.title }}</h1
@foreach(pagecontentasblock
<x-block:type="blocktype:data="block
</x-layout
</template
<!-- components/blocks/hero.stx -->
<template>
<section
class="hero"
:style="{ backgroundImage: `url(${data.image?.url})` }"
>
<h1{{ data.heading }}</h1
<p{{ data.subheading }}</p
@if(datactatext
<a:href="dataclass="{{ data.cta.text }}</a
</section
</template
Webhooks & Events
Content Events
// app/Listeners/ContentPublished.ts
import { ContentEvent } from '@stacksjs/cms'
export default {
async handle(event: ContentEvent) {
const { collection, entry, action } = event
if (action === 'published') {
// Trigger rebuild, notify subscribers, etc.
await triggerStaticBuild()
await notifySubscribers(entry)
}
}
}
// Register listener
Content.on('published', ContentPublished)
Content.on('updated', ContentUpdated)
Content.on('deleted', ContentDeleted)
Webhooks
// config/cms.ts
export default {
webhooks: {
endpoints: [
{
url: 'https://api.example.com/webhook',
events: ['content.published', 'content.updated'],
secret: process.env.WEBHOOK_SECRET,
},
],
},
}
Static Generation
Pre-rendering Content
// Generate static pages from content
export async function generateStaticPaths() {
const posts = await Content.collection('posts')
.where('status', 'published')
.get()
return posts.map(post => ({
params: { slug: post.slug },
}))
}
export async function getStaticProps({ params }) {
const post = await Content.collection('posts')
.where('slug', params.slug)
.with('author', 'categories')
.first()
return { post }
}
Incremental Regeneration
// config/cms.ts
export default {
staticGeneration: {
enabled: true,
revalidate: 60, // Revalidate every 60 seconds
onDemand: true, // Enable on-demand revalidation
},
}
// Trigger revalidation
await Content.revalidate('/blog/my-post')
Access Control
Permissions
// config/cms.ts
export default {
roles: {
admin: {
permissions: ['*'],
},
editor: {
permissions: [
'content.read',
'content.create',
'content.update',
'media.read',
'media.upload',
],
},
author: {
permissions: [
'content.read',
'content.create:own',
'content.update:own',
],
},
},
}
Collection-Level Permissions
export default defineCollection({
name: 'posts',
access: {
read: () => true, // Anyone can read
create: ({ user }) => user.hasRole(['admin', 'editor', 'author']),
update: ({ user, entry }) => {
if (user.hasRole('admin')) return true
return entry.author_id === user.id
},
delete: ({ user }) => user.hasRole('admin'),
publish: ({ user }) => user.hasRole(['admin', 'editor']),
},
})