Creating Custom Collections in SonicJS
Build production-ready content models in SonicJS with TypeScript-first collections, 30+ field types, references, validation, and auto-generated REST endpoints.

Creating Custom Collections in SonicJS
TL;DR โ Collections are the heart of any SonicJS project: a single TypeScript file describes your content type, and SonicJS turns it into a typed database table, an admin UI, and a full REST API. Define a CollectionConfig, register it with registerCollections([...]), and let auto-sync handle the rest.
Key Stats:
- 30+ built-in field types โ string, richtext, media, reference, blocks, arrays, objects, and more
- 1 file = 1 schema + 1 admin screen + full CRUD REST API
- Full TypeScript inference from
CollectionConfigโ autocomplete every field - Auto-sync on server start โ no migration scripts to babysit
- Works for both code-based and UI-managed collections in the same project
If you've worked with a traditional headless CMS, you know the dance: click through a UI to define a content type, hope the schema export still works in production, write a migration script, deploy, repeat. SonicJS collections flip that on its head. Your content model lives in TypeScript, in your repo, version-controlled with the rest of your code โ and SonicJS handles the database, the admin UI, and the API automatically.
This tutorial walks through everything you need to ship a real, multi-collection content model: defining fields, setting up references between collections, validating input, and wiring everything up. By the end you'll have a working blog with three related collections (posts, authors, categories), a typed REST API, and an admin UI you didn't have to build.
What Is a Collection in SonicJS?
A collection in SonicJS is a content type โ the schema for one kind of thing your CMS manages. Think "blog posts," "products," "events," "team members." Each collection definition is a CollectionConfig object that tells SonicJS:
- The collection's machine name and display name
- Its field schema (what data each entry holds)
- Validation rules and defaults
- Optional UI hints โ icon, default sort, list view fields, search fields
When SonicJS boots, it reads your registered collections and:
- Creates or updates the underlying database table
- Mounts a full REST API at
/api/collections/{name}/content - Renders an admin screen at
/admin/collections/{name} - Generates types you can import in your frontend
Two flavors are supported in the same project โ see the collections reference for the full breakdown:
- Code-based collections โ defined in TypeScript, auto-synced on startup, version-controlled. Best for production.
- UI-created collections โ built in the admin UI, stored in the database. Best for prototyping.
We're focused on the code-based path here, because that's what you'll use in real apps.
A Minimal Collection
Let's start with the smallest useful collection: a posts content type with a title, slug, and body.
// src/collections/posts.collection.ts
import type { CollectionConfig } from '@sonicjs-cms/core'
export default {
name: 'posts',
displayName: 'Posts',
description: 'Blog articles and news',
icon: '๐',
schema: {
type: 'object',
properties: {
title: {
type: 'string',
title: 'Title',
required: true,
maxLength: 200,
},
slug: {
type: 'slug',
title: 'URL Slug',
required: true,
},
body: {
type: 'richtext',
title: 'Body',
required: true,
},
},
required: ['title', 'slug', 'body'],
},
managed: true,
isActive: true,
} satisfies CollectionConfig
Three things to note:
schema.typeis always'object'โ your collection's properties live underschema.properties. This mirrors JSON Schema.requiredappears twice โ once on each field (form/validation level) and once at the schema level (serialization level). Both are honored.managed: truemarks this as a code-managed collection, which makes it read-only in the admin UI (it shows up with a purple "Config" badge). Edit the file to change the schema.
Registering the Collection
A collection on its own does nothing โ you need to register it with SonicJS:
// src/index.ts
import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core'
import postsCollection from './collections/posts.collection'
registerCollections([postsCollection])
export default createSonicJSApp({
collections: {
autoSync: true, // sync schema changes on startup
},
})
Restart the dev server and SonicJS will:
- Create the
postsstorage if it doesn't exist - Mount
GET/POST/PUT/DELETE /api/collections/posts/content - Add a "Posts" item to the admin sidebar
That's it for the minimum viable collection.
The Field Type Cheat Sheet
SonicJS ships 30+ field types. They fall into seven natural categories. The full list is on the field types reference โ here's the working subset you'll use most often:
| Field type | Use it for | Storage |
|---|---|---|
string | Titles, names, short text | VARCHAR(255) |
textarea | Excerpts, descriptions | TEXT |
slug | URL paths (auto-generated, unique-checked) | VARCHAR(100) |
email | Validated email addresses | VARCHAR(255) |
url | Validated URLs | VARCHAR(500) |
richtext | WYSIWYG body content | TEXT (HTML) |
markdown | Markdown body content | TEXT (Markdown) |
number | Prices, quantities, ratings | DECIMAL(10,2) |
boolean | Toggles, feature flags | BOOLEAN |
date | Calendar dates (no time) | DATE |
datetime | Timestamped events | DATETIME |
select | Single-choice dropdown | VARCHAR(255) |
multiselect | Multi-choice dropdown | JSON (array) |
radio | Single-choice radios | VARCHAR(255) |
checkbox | Boolean checkbox | BOOLEAN |
media | Images, videos, documents | VARCHAR(500) URL |
file | Generic file uploads | VARCHAR(500) URL |
color | Hex color picker | VARCHAR(7) |
reference | Link to another collection | VARCHAR(255) ID |
array | Repeatable list of items | JSON |
object | Grouped/nested fields | JSON |
json | Raw JSON blob | JSON |
Beyond those, the rich-text editor variants (quill, tinymce, mdxeditor, easymde) drop in when you have the matching editor plugin installed. They store the same TEXT content but wire up a different admin editor.
The most powerful primitive isn't on this table: blocks. We'll cover those at the end.
How field types map to API behavior
Every field type also affects what the API accepts and returns:
string/textarea/richtext/markdownโ JSONstringnumberโ JSONnumberboolean/checkboxโ JSONbooleandate/datetimeโ ISO 8601stringmedia/fileโstringURL (or array formultiple: true)referenceโstringID of the referenced contentarray/object/jsonโ typed JSON valueselectโ enum-validatedstring; withmultiple: true,string[]
Validation runs on both the client (HTML5 + plugin checks) and the server (Zod-derived schema). If the request shape doesn't match, the API returns 400 with field-level error messages.
A Real Content Model: Blog with Posts, Authors, and Categories
Let's build something realistic. A blog needs three related collections:
authorsโ people who write postscategoriesโ taxonomy bucketspostsโ the actual articles, referencing one author and many categories
We'll model it the way you'd actually ship it.
The Authors Collection
// src/collections/authors.collection.ts
import type { CollectionConfig } from '@sonicjs-cms/core'
export default {
name: 'authors',
displayName: 'Authors',
description: 'People who write content',
icon: 'โ๏ธ',
schema: {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Full Name',
required: true,
minLength: 2,
maxLength: 120,
},
slug: {
type: 'slug',
title: 'URL Slug',
required: true,
},
email: {
type: 'email',
title: 'Contact Email',
},
avatar: {
type: 'media',
title: 'Avatar',
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
maxFileSize: 2 * 1024 * 1024, // 2 MB
} as any,
bio: {
type: 'textarea',
title: 'Bio',
maxLength: 500,
},
social: {
type: 'object',
title: 'Social Links',
properties: {
twitter: { type: 'url', title: 'Twitter / X' },
github: { type: 'url', title: 'GitHub' },
website: { type: 'url', title: 'Website' },
},
},
},
required: ['name', 'slug'],
},
listFields: ['name', 'email', 'createdAt'],
searchFields: ['name', 'email', 'bio'],
defaultSort: 'name',
defaultSortOrder: 'asc',
managed: true,
isActive: true,
} satisfies CollectionConfig
Highlights:
- The
slugfield gives you SEO-friendly URLs like/authors/jane-doe. Slugs are auto-generated from atitle-like field and uniqueness-checked at write time. - The
socialobject groups three URL fields under a collapsible header in the admin UI. listFieldscontrols which columns show in the admin list view; keep it short for performance (large lists with many fields hit the database harder).
The Categories Collection
A category is small but pulls its weight as a foreign key target.
// src/collections/categories.collection.ts
import type { CollectionConfig } from '@sonicjs-cms/core'
export default {
name: 'categories',
displayName: 'Categories',
icon: '๐๏ธ',
schema: {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Name',
required: true,
maxLength: 80,
},
slug: {
type: 'slug',
title: 'URL Slug',
required: true,
},
color: {
type: 'color',
title: 'Badge Color',
default: '#3B82F6',
},
description: {
type: 'textarea',
title: 'Description',
maxLength: 240,
},
},
required: ['name', 'slug'],
},
managed: true,
isActive: true,
} satisfies CollectionConfig
The Posts Collection (with References)
Now the centerpiece โ a post that references an author and multiple categories.
// src/collections/posts.collection.ts
import type { CollectionConfig } from '@sonicjs-cms/core'
export default {
name: 'posts',
displayName: 'Posts',
description: 'Blog articles and news',
icon: '๐',
schema: {
type: 'object',
properties: {
title: {
type: 'string',
title: 'Title',
required: true,
minLength: 3,
maxLength: 200,
},
slug: {
type: 'slug',
title: 'URL Slug',
required: true,
},
excerpt: {
type: 'textarea',
title: 'Excerpt',
maxLength: 300,
helpText: 'Short summary used in listings and SEO meta tags',
},
body: {
type: 'richtext',
title: 'Body',
required: true,
},
featuredImage: {
type: 'media',
title: 'Featured Image',
},
// Single reference to an author
author: {
type: 'reference',
title: 'Author',
collection: 'authors',
required: true,
},
// Many-to-many style: an array of references to categories
categories: {
type: 'array',
title: 'Categories',
items: {
type: 'reference',
collection: 'categories',
},
},
status: {
type: 'select',
title: 'Status',
enum: ['draft', 'published', 'archived'],
enumLabels: ['Draft', 'Published', 'Archived'],
default: 'draft',
required: true,
},
publishedAt: {
type: 'datetime',
title: 'Publish Date',
},
featured: {
type: 'boolean',
title: 'Featured',
checkboxLabel: 'Show on homepage',
default: false,
},
seo: {
type: 'object',
title: 'SEO',
collapsed: true,
properties: {
metaTitle: {
type: 'string',
title: 'Meta Title',
maxLength: 60,
},
metaDescription: {
type: 'textarea',
title: 'Meta Description',
maxLength: 160,
},
canonicalUrl: { type: 'url', title: 'Canonical URL' },
},
},
},
required: ['title', 'slug', 'body', 'author', 'status'],
},
listFields: ['title', 'author', 'status', 'publishedAt'],
searchFields: ['title', 'excerpt', 'body'],
defaultSort: 'publishedAt',
defaultSortOrder: 'desc',
managed: true,
isActive: true,
} satisfies CollectionConfig
A few things this collection does that pay dividends in production:
authoris a singlereferenceโ pointing at theauthorscollection. The admin UI renders this as a searchable picker; the API stores and returns the author's content ID.categoriesis anarrayofreferenceitems โ SonicJS's idiomatic many-to-many. You get add, remove, and reorder controls in the admin and a JSON array of IDs in the API.statususesenum+enumLabelsโ the enum values are what the API stores; the labels are what the admin displays.seois a collapsedobjectโ keeps the form clean. The collapse state is persisted per user insessionStorage.
Registering All Three
// src/index.ts
import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core'
import postsCollection from './collections/posts.collection'
import authorsCollection from './collections/authors.collection'
import categoriesCollection from './collections/categories.collection'
registerCollections([
authorsCollection,
categoriesCollection,
postsCollection,
])
export default createSonicJSApp({
collections: {
autoSync: true,
},
})
Restart, and you'll see all three collections in the sidebar โ each with its own list view, edit form, and REST endpoints.
Validation in Practice
SonicJS validation is layered. Here's what's enforced at each level for our posts collection:
| Rule | Where it runs | What happens on failure |
|---|---|---|
required: true | Client + server | 400 with "field is required" |
minLength / maxLength | HTML5 + Zod | 400 with length message |
pattern (regex) | HTML5 + Zod | 400 with format message |
min / max (numbers) | HTML5 + Zod | 400 out-of-range |
enum | Client + server | 400 invalid option |
slug uniqueness | Server (live API check) | 409 conflict |
media MIME / size | Browser + Worker | 400 invalid file |
reference exists | Server | 400 invalid reference |
You generally don't need to layer your own validation on top โ define the constraints declaratively in the schema and SonicJS does the rest. For business rules that go beyond field-level validation (e.g. "only admins can publish"), reach for hooks.
Hooks: Reacting to Collection Events
Collections fire lifecycle events you can subscribe to with the hooks system. The most common ones for content:
content:saveโ fires before a content item is written, lets you mutate or reject the payloadcontent:savedโ fires after a successful writecontent:deleteโ fires before deletioncontent:readโ fires when content is fetched (great for transforming output)
Example: auto-stamp publishedAt whenever a post moves to published:
import { HOOKS } from '@sonicjs-cms/core'
hooks.register(HOOKS.CONTENT_SAVE, async (data, context) => {
if (
context.collection === 'posts' &&
data.status === 'published' &&
!data.publishedAt
) {
data.publishedAt = new Date().toISOString()
}
return data
})
Hooks are great for cross-cutting concerns: stamping audit fields, sending webhooks on publish, denormalizing data for search indices, or invalidating cache keys.
Access and Authentication
Collections inherit the SonicJS auth model. Out of the box:
- Read endpoints (
GET /api/collections/{name}/content) are public unless you front them with middleware. - Write endpoints (
POST/PUT/DELETE) require an authenticated user. - The admin UI is gated by authentication and the role-based middleware (
requireRole('admin'),requireRole('editor')).
For collection-specific access rules โ say, "only the post's author can edit it" โ combine content:save hooks with the auth context:
hooks.register(HOOKS.CONTENT_SAVE, async (data, context) => {
if (context.collection !== 'posts') return data
if (context.operation === 'update') {
if (data.author !== context.user?.id && context.user?.role !== 'admin') {
throw new Error('Forbidden: you can only edit your own posts')
}
}
return data
})
Pair that with requireAuth() on your custom routes and you have row-level access control.
Page Builder Pattern: Blocks
For pages that need flexible layouts, blocks turn a single field into a discriminated union of content types.
body: {
type: 'array',
title: 'Page Content',
items: {
type: 'object',
discriminator: 'blockType',
blocks: {
hero: {
label: 'Hero',
properties: {
headline: { type: 'string', title: 'Headline', required: true },
subhead: { type: 'textarea', title: 'Subhead' },
image: { type: 'media', title: 'Background Image' },
},
},
richText: {
label: 'Rich Text',
properties: {
body: { type: 'richtext', title: 'Body', required: true },
},
},
cta: {
label: 'Call To Action',
properties: {
title: { type: 'string', title: 'Title', required: true },
buttonText: { type: 'string', title: 'Button Text', required: true },
buttonUrl: { type: 'url', title: 'Button URL', required: true },
},
},
},
},
},
Editors get a "+ Add block" picker; each block has its own typed fields; the API returns a typed array of { blockType, ...fields } objects. This is the same pattern Notion and modern page builders use.
Tips for Designing Collections
A few patterns that consistently produce healthy schemas:
- Pick
slugoverstringfor URLs. It validates format, auto-generates from the title, and checks uniqueness for free. - Use
select(withenum) for any field with a fixed set of values. It indexes more efficiently thanstringand lets editors pick from a dropdown. - Reach for
referenceinstead of duplicating data. Acategory_namestring drifts; areferencetocategoriesstays correct. - Group SEO and metadata in an
object. Editors can collapse it; the API still returns it inline. - Keep
listFieldsshort. Every column is a column the admin list view has to render. - Index searchable fields explicitly. Add common search fields to
searchFieldsand create database indexes for the most-queried ones.
Working with Collections via the API
Once your collections are registered, the API is automatic:
# List all posts (newest first by default)
curl https://your-cms.example.com/api/collections/posts/content?limit=10
# Get one post by ID
curl https://your-cms.example.com/api/content/cm123
# Filter and paginate
curl "https://your-cms.example.com/api/collections/posts/content?status=published&limit=20&offset=0"
# Create a post (auth required)
curl -X POST https://your-cms.example.com/admin/content \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"collection": "posts",
"data": {
"title": "Hello, world",
"slug": "hello-world",
"body": "<p>It works.</p>",
"author": "auth_abc123",
"categories": ["cat_news"],
"status": "published"
}
}'
Full request/response shapes for every endpoint are in the API reference.
Next Steps
You now have the full mental model: define a CollectionConfig, register it, and let SonicJS produce the database schema, admin UI, and REST API. From here:
- Field types reference โ every field type, every option, every validation rule
- Collections deep dive โ UI vs code-based, blocks, repeatable arrays, and migration patterns
- Database guide โ how D1, schema sync, and migrations actually work under the hood
- Authentication โ protect collection write endpoints with JWT, OAuth, magic links, and OTP
- Hooks reference โ every collection lifecycle event you can plug into
- API reference โ the auto-generated REST endpoints in detail
Key Takeaways
- A SonicJS collection is a single TypeScript file that produces a database schema, an admin UI, and a typed REST API.
- 30+ field types cover every real-world content shape โ text, media, references, arrays, objects, blocks.
- References + arrays of references model one-to-one and many-to-many relationships without writing SQL.
- Validation is declarative:
required,minLength,pattern,enum, andmin/maxare enforced on both client and server. - Hooks plug into
content:save,content:saved,content:delete, and more for business logic and access control. - Use
managed: truefor production collections so their schema lives in git, not in the database.
Have questions or building something interesting with collections? Join us on Discord or GitHub โ we'd love to see what you ship.
Happy modeling!
Related Articles

Building a REST API with SonicJS in 10 Minutes
Build a production-ready REST API on Cloudflare Workers with SonicJS. Define a collection, deploy globally, and query with filters, sort, and pagination.

File Uploads with SonicJS and Cloudflare R2
Upload, validate, and serve images, video, and documents with SonicJS and Cloudflare R2 โ multipart uploads, MIME checks, signed URLs, and image transforms.

Deploying SonicJS to Cloudflare Workers: A Step-by-Step Guide
Ship a SonicJS headless CMS to Cloudflare Workers in minutes โ wrangler config, D1, KV, R2, secrets, custom domains, preview deploys, and rollback in one guide.