CMS
Most production SaaS need content-heavy surfaces — marketing landing, blog, product catalog, knowledge base, in-app help. Bolting on Contentful or Sanity means a new vendor, a new bill, and content that lives apart from the rest of your data. Atelier folds CMS in: collections become Postgres tables, assets land in Storage, editorial roles ride RLS, AI assistance comes from atelier.llm. One shell.
Define a collection
// .atelier/cms/articles.ts
import { atelier, z } from '@atelier/sdk';
export const articles = atelier.cms.collection('articles', {
fields: {
title: z.string(),
slug: z.string(),
excerpt: z.string().max(280),
body: atelier.cms.richText(),
cover: atelier.cms.image({ aspectRatio: '16/9' }),
author: atelier.cms.ref('users'),
tags: z.array(z.string()),
publishedAt: z.date().optional(),
},
i18n: ['en', 'ko', 'ja'],
workflow: { draft: true, review: ['editor'], publish: ['editor', 'admin'] },
});
The next migration creates the table. The next deploy ships:
/admin/cms/articles— list + WYSIWYG editor + asset picker + ref picker (auto-generated, Design Cascade-toned)/api/cms/articles/...— REST endpoints (RLS-filtered)- TypeScript types for
atelier.cms.list/get/create/update/publish - Per-locale slots in the editor
- Workflow buttons (Submit for review / Approve / Publish / Schedule)
Read content
const posts = await atelier.cms.list('articles', {
filter: { status: 'published', tags: { contains: 'launch' } },
locale: 'ko',
orderBy: { publishedAt: 'desc' },
limit: 10,
});
const post = await atelier.cms.get('articles', { slug: 'production-from-minute-1' });
Reads are RLS-aware — public callers see published content; logged-in editors see their drafts; admins see everything.
Write content
// Programmatic (admin role required by default)
await atelier.cms.create('articles', { title, slug, body, author });
await atelier.cms.update('articles', id, { body: revised });
await atelier.cms.publish('articles', id);
await atelier.cms.schedule('articles', id, { at: '2026-07-01T09:00:00Z' });
Most teams won’t hand-write these — the Authoring Console covers everything above with a click.
Field types
{
title: z.string(),
body: atelier.cms.richText(), // TipTap/Lexical block doc
body_md: atelier.cms.markdown(),
cover: atelier.cms.image(), // Storage-backed, image transforms
attachments: z.array(atelier.cms.file()),
author: atelier.cms.ref('users'), // typed reference
related: z.array(atelier.cms.ref('articles')),
location: atelier.cms.geo(),
meta: z.object({ ogTitle: z.string(), ogImage: atelier.cms.image() }),
visible: z.boolean(),
count: z.number().int(),
}
All field types render in the Console with appropriate widgets. richText carries Design Cascade tokens — inline buttons render with your Button, callouts with your Callout.
Editorial workflow
States: Draft → In review → Approved → Scheduled → Published → Archived.
// .atelier/cms/articles.ts
workflow: {
draft: true,
review: ['editor'], // who can move from draft → in-review and approve
publish: ['editor', 'admin'],
schedule: ['editor', 'admin'],
}
Roles map to your existing auth roles — there’s no separate user pool. Activity log captures every transition with comments and attribution; the audit trail rides Atelier’s audit logs.
Per-locale workflow is supported: publish English now, keep Korean in draft, schedule Japanese for next week.
Preview
Every draft has a unique preview URL:
https://your-app.apps.atelier.style/preview/<token>
The token encodes the previewer’s identity, so RLS still applies. The page renders with Design Cascade applied — what editors see is exactly what readers will see, in mobile, tablet, and desktop.
Side-by-side preview is built into the Console:
┌──────────────────────┬────────────────────────┐
│ Editor │ Live preview │
│ (rich text, fields) │ (Design Cascade, ko) │
└──────────────────────┴────────────────────────┘
Share the preview link with a non-author for review; the link expires automatically.
Realtime collaboration
Multiple editors open the same document, multiple cursors visible. Atelier’s Realtime channel handles presence; field-level locking prevents the awkward two-people-typing-the-same-paragraph case.
Inline comments and suggestions work the way Google Docs trained everyone to expect:
- Highlight text, leave a comment,
@mentionanother editor. - “Suggestion mode” tracks proposed changes for approve / reject.
- All comments are RLS-scoped to the people who can see the document.
i18n + versioning
i18n: ['en', 'ko', 'ja'],
fallback: 'en',
The Console shows translation status per field: translated / outdated / missing. Translation memory stores prior translations so the same phrase doesn’t get retranslated.
atelier cms translate articles --to ko --from en
Translates every untranslated field using atelier.llm, brand-aware (your glossary.yaml keeps product names in English, for example).
Every publish creates a version. Diff any two versions inline; revert any field, any locale, any version. Schedule a revert if you’re A/B testing copy.
AI content
atelier.llm is wired into the editor and the SDK. The model knows your brand voice (brand.yaml), your schema (the field’s purpose), and your audience (the segment the variant targets).
// In the Console
[ Generate ▾ ]
Write a title from this body
Write an excerpt from this body
Write SEO meta tags
Translate all fields to ko
Suggest 3 variant titles for A/B test
Generate alt text for cover image
Or programmatically:
const titles = await atelier.cms.suggest('articles', {
field: 'title',
context: existing,
count: 5,
audience: 'free-tier',
});
Audience-personalized variants
Hook into segments (from Feature flags / Edge Config) and serve different content per audience:
const post = await atelier.cms.get('articles', {
slug: 'pricing',
variant: { segment: 'enterprise' },
});
Variants are versioned, A/B-tested through the same Console, and respect RLS so you never accidentally show one tenant’s variant to another.
Multi-tenant CMS
For SaaS sold to teams that run their own content, every tenant gets its own scoped CMS namespace automatically. RLS handles isolation — tenant A’s editors never see tenant B’s drafts. The Console UI scopes the same way.
// In a Function called by a tenant admin
await ctx.cms.create('announcements', { title, body }); // implicitly scoped to ctx.user.tenantId
In Functions
ctx.cms is the server mirror of the SDK, identity-scoped:
// .atelier/functions/cron/republish-stale.ts
export const schedule = '0 3 * * *'; // every 3 AM
export default async function (_req, ctx) {
const stale = await ctx.cms.list('articles', {
filter: { status: 'published', publishedAt: { lt: weeksAgo(8) } },
limit: 50,
});
for (const a of stale) {
await ctx.cms.suggest('articles', { id: a.id, field: 'body', mode: 'refresh' });
}
}
Your blog is one collection. Your docs site is another. Your product catalog is a third. One platform.