CMS Plugin
Headless CMS with code-defined content types, dynamic forms, and agency-friendly workflows
The CMS plugin provides a headless content management system where developers define content types as Zod schemas in code. This "agency workflow" approach means:
- Developers define the content model (schemas, validation rules, field descriptions)
- Clients manage content items through a friendly admin UI
- TypeScript provides end-to-end type safety when schema shapes change
Installation
Ensure you followed the general framework installation guide first.
1. Define Content Types
Create your content types as Zod schemas in a shared file. This allows you to use the schemas on both server (for validation) and client (for type-safe hooks). Use .meta() to add descriptions and placeholders that appear in the admin UI:
import { z } from "zod";
// ========== Product Schema ==========
// Use .meta({ fieldType: "..." }) to customize how fields render in the admin UI
export const ProductSchema = z.object({
name: z.string().min(1).meta({
description: "Product display name",
placeholder: "Enter product name..."
}),
description: z.string().meta({
description: "Full product description",
placeholder: "Describe this product...",
fieldType: "textarea", // Renders as a textarea
}),
price: z.coerce.number().min(0).meta({ placeholder: "0.00" }),
featured: z.boolean().default(false).meta({
description: "Show on homepage featured section",
fieldType: "switch", // Renders as a toggle switch
}),
category: z.enum(["Electronics", "Clothing", "Home", "Sports"]),
image: z.string().optional().meta({
description: "Product image",
fieldType: "file", // Renders as file upload (uses uploadImage override)
}),
});
// ========== Testimonial Schema ==========
export const TestimonialSchema = z.object({
author: z.string().min(1).meta({ placeholder: "Customer name" }),
company: z.string().optional().meta({ placeholder: "Company (optional)" }),
quote: z.string().meta({
description: "Customer testimonial text",
placeholder: "What did they say?",
fieldType: "textarea",
}),
rating: z.coerce.number().min(1).max(5).meta({
description: "Rating out of 5 stars"
}),
});
// ========== Type Exports for Client Hooks ==========
/** Inferred type for Product data */
export type ProductData = z.infer<typeof ProductSchema>;
/** Inferred type for Testimonial data */
export type TestimonialData = z.infer<typeof TestimonialSchema>;
/**
* Type map for all CMS content types.
* Use this with CMS hooks for type-safe parsedData.
*/
export type CMSTypes = {
product: ProductData;
testimonial: TestimonialData;
};2. Add Plugin to Backend API
Register the CMS backend plugin with your content types:
import { betterStack } from "@btst/stack"
import { cmsBackendPlugin } from "@btst/stack/plugins/cms/api"
import { ProductSchema, TestimonialSchema } from "./cms-schemas"
const { handler, dbSchema } = betterStack({
basePath: "/api/data",
plugins: {
cms: cmsBackendPlugin({
contentTypes: [
{
name: "Product",
slug: "product",
description: "Products for the store",
schema: ProductSchema,
// Field types are defined in the schema via .meta({ fieldType: "..." })
},
{
name: "Testimonial",
slug: "testimonial",
description: "Customer testimonials",
schema: TestimonialSchema,
},
],
})
},
adapter: (db) => createMemoryAdapter(db)({})
})
export { handler, dbSchema }3. Add Plugin to Client
Register the CMS client plugin:
import { createStackClient } from "@btst/stack/client"
import { cmsClientPlugin } from "@btst/stack/plugins/cms/client"
import { QueryClient } from "@tanstack/react-query"
const getBaseURL = () =>
process.env.BASE_URL || "http://localhost:3000"
export const getStackClient = (queryClient: QueryClient, options?: { headers?: Headers }) => {
const baseURL = getBaseURL()
return createStackClient({
plugins: {
cms: cmsClientPlugin({
apiBaseURL: baseURL,
apiBasePath: "/api/data",
siteBaseURL: baseURL,
siteBasePath: "/pages",
queryClient: queryClient,
headers: options?.headers,
})
}
})
}4. Configure Provider Overrides
Add CMS overrides to your layout:
import type { CMSPluginOverrides } from "@btst/stack/plugins/cms/client"
type PluginOverrides = {
cms: CMSPluginOverrides,
}
<BetterStackProvider<PluginOverrides>
basePath="/pages"
overrides={{
cms: {
apiBaseURL: baseURL,
apiBasePath: "/api/data",
navigate: (path) => router.push(path),
refresh: () => router.refresh(),
uploadImage: async (file) => {
// Your image upload logic
return "https://example.com/image.png"
},
Link: ({ href, ...props }) => <Link href={href || "#"} {...props} />,
}
}}
>
{children}
</BetterStackProvider>5. Import CSS
Add the CMS styles to your global CSS:
@import "@btst/stack/plugins/cms/css";Supported Field Types
The CMS uses AutoForm to automatically render forms from Zod schemas. Use .meta({ fieldType: "..." }) on any field to customize its rendering:
| Zod Type | Default Handler | With fieldType Override |
|---|---|---|
z.string() | Input (text) | "textarea", "file" |
z.coerce.number() | Number input | - |
z.boolean() | Checkbox | "switch" |
z.coerce.date() | Date picker | - |
z.enum([...]) | Select dropdown | "radio" |
Adding UI Customization
Use .meta() to customize how fields appear and render. All field configuration is done directly in the Zod schema:
const ProductSchema = z.object({
name: z.string().min(1).meta({
description: "Product display name", // Shows as help text
placeholder: "Enter name..." // Input placeholder
}),
bio: z.string().meta({
description: "About this product",
fieldType: "textarea", // Renders as a multi-line textarea
}),
featured: z.boolean().default(false).meta({
fieldType: "switch", // Renders as a toggle switch instead of checkbox
}),
category: z.enum(["A", "B", "C"]).meta({
fieldType: "radio", // Renders as radio buttons instead of select
}),
});Image Upload Fields
To add an image upload field to your content type:
- Add an optional string field with
fieldType: "file"in your schema:
const ProductSchema = z.object({
name: z.string().min(1),
image: z.string().optional().meta({
description: "Product image URL",
fieldType: "file", // Renders as file upload
}),
// ...other fields
});- Provide
uploadImagein your BetterStackProvider overrides:
// In your BetterStackProvider overrides
cms: {
uploadImage: async (file: File) => {
// Upload to S3, Cloudinary, etc. and return the URL
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: formData });
const { url } = await res.json();
return url;
},
// ...other overrides
}The built-in file component will use your uploadImage function to upload files and store the returned URL.
Admin Routes
The CMS plugin provides these admin routes:
| Route | Description |
|---|---|
/cms | Dashboard - Grid of content types with item counts |
/cms/:typeSlug | Content list - Paginated table of items |
/cms/:typeSlug/new | Create new item |
/cms/:typeSlug/:id | Edit existing item |
Admin routes are automatically set to noindex for SEO. Don't include them in your public sitemap.
Client Hooks
Fetch content data in your frontend pages using the provided hooks. All hooks support optional type generics for full type safety on parsedData.
Available Hooks
Query Hooks
| Hook | Description | Returns |
|---|---|---|
useContentTypes() | List all content types | { contentTypes, isLoading, error, refetch } |
useContentType(slug) | Get single content type by slug | { contentType, isLoading, error, refetch } |
useContent(typeSlug, options?) | List paginated items with infinite loading | { items, total, hasMore, loadMore, isLoadingMore, isLoading, error, refetch } |
useContentItem(typeSlug, id) | Get item by ID | { item, isLoading, error, refetch } |
useContentItemBySlug(typeSlug, slug) | Get item by slug | { item, isLoading, error, refetch } |
useContentItemPopulated(typeSlug, id) | Get item with relations populated | { item, isLoading, error, refetch } |
useContentByRelation(typeSlug, field, targetId) | Filter items by relation | { items, total, hasMore, loadMore, isLoadingMore, isLoading, error, refetch } |
Suspense Hooks
All query hooks have suspense variants for use with React Suspense:
| Hook | Description | Returns |
|---|---|---|
useSuspenseContentTypes() | List all content types (suspense) | { contentTypes, refetch } |
useSuspenseContent(typeSlug, options?) | List paginated items (suspense) | { items, total, hasMore, loadMore, isLoadingMore, refetch } |
useSuspenseContentItem(typeSlug, id) | Get item by ID (suspense) | { item, refetch } |
useSuspenseContentItemPopulated(typeSlug, id) | Get item with relations (suspense) | { item, refetch } |
useSuspenseContentByRelation(typeSlug, field, targetId) | Filter by relation (suspense) | { items, total, hasMore, loadMore, isLoadingMore, refetch } |
Mutation Hooks
| Hook | Description | Returns |
|---|---|---|
useCreateContent(typeSlug) | Create mutation | React Query mutation |
useUpdateContent(typeSlug) | Update mutation | React Query mutation |
useDeleteContent(typeSlug) | Delete mutation | React Query mutation |
Basic Usage (Without Type Safety)
import {
useContentTypes,
useContent,
useContentItem,
useContentItemBySlug
} from "@btst/stack/plugins/cms/client/hooks"
// List all content types
function ContentTypesGrid() {
const { contentTypes, isLoading } = useContentTypes()
// ...
}
// List paginated content items
function ProductList() {
const { items, total, hasMore } = useContent("product", { limit: 20 })
// items[0].parsedData is Record<string, unknown>
}Type-Safe Usage (Recommended)
Import your CMSTypes type map and pass it to the hooks for full type inference on parsedData:
import { useContent, useContentItem, useContentItemBySlug } from "@btst/stack/plugins/cms/client/hooks"
import type { CMSTypes } from "@/lib/cms-schemas"
// List products with type-safe parsedData
function ProductList() {
const { items, total, hasMore } = useContent<CMSTypes, "product">("product", {
limit: 20
})
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{/* All fields are fully typed! */}
<h3>{item.parsedData.name}</h3>
<p>${item.parsedData.price}</p>
<span>{item.parsedData.category}</span>
{item.parsedData.featured && <Badge>Featured</Badge>}
</li>
))}
</ul>
)
}
// Get single item by ID with type safety
function ProductDetail({ id }: { id: string }) {
const { item, isLoading } = useContentItem<CMSTypes, "product">("product", id)
if (isLoading || !item) return <Skeleton />
return (
<div>
<h1>{item.parsedData.name}</h1>
<p>{item.parsedData.description}</p>
</div>
)
}
// Get single item by slug with type safety
function ProductPage({ slug }: { slug: string }) {
const { item } = useContentItemBySlug<CMSTypes, "product">("product", slug)
// item.parsedData.price is typed as number
}The type generics are optional for backward compatibility. Without them, parsedData defaults to Record<string, unknown>.
Mutations
Mutation hooks also support type generics for type-safe input data:
import {
useCreateContent,
useUpdateContent,
useDeleteContent
} from "@btst/stack/plugins/cms/client/hooks"
import type { ProductData } from "@/lib/cms-schemas"
function CreateProductForm() {
// Type-safe mutation - TypeScript enforces correct data shape
const createProduct = useCreateContent<ProductData>("product")
const handleSubmit = async () => {
await createProduct.mutateAsync({
slug: "my-product",
data: {
name: "New Product",
description: "A great product",
price: 29.99,
featured: false,
category: "Electronics", // TypeScript autocompletes enum values!
}
})
}
}
function UpdateProductForm({ id }: { id: string }) {
const updateProduct = useUpdateContent<ProductData>("product")
const handleUpdate = async () => {
await updateProduct.mutateAsync({
id,
data: { data: { name: "Updated Name", price: 39.99 } }
})
}
}Backend Hooks
Customize CMS behavior with backend hooks:
cmsBackendPlugin({
contentTypes: [...],
hooks: {
onBeforeCreate: async (data, context) => {
console.log("Creating item in", context.typeSlug)
// Return false to deny, or modified data
return data
},
onAfterCreate: async (item, context) => {
console.log("Created:", item.slug)
// Trigger webhooks, notifications, etc.
},
onBeforeUpdate: async (id, data, context) => {
return data // or false to deny
},
onAfterUpdate: async (item, context) => {
// ...
},
onBeforeDelete: async (id, context) => {
return true // or false to deny
},
onAfterDelete: async (id, context) => {
// ...
},
onError: async (error, operation, context) => {
console.error(`CMS ${operation} error:`, error.message)
},
},
})Type Safety
The CMS plugin provides end-to-end type safety from schema definition to frontend rendering:
1. Schema Definition → Backend Validation
Zod schemas defined in cms-schemas.ts are used by the backend to validate all content operations:
// lib/cms-schemas.ts
export const ProductSchema = z.object({
name: z.string().min(1),
price: z.coerce.number().min(0),
});2. Type Map → Client Hooks
Export inferred types and a type map for client-side type safety:
// lib/cms-schemas.ts
export type ProductData = z.infer<typeof ProductSchema>;
export type CMSTypes = { product: ProductData };3. Type-Safe Data Access
Use the type map with hooks to get fully typed parsedData:
import { useContent } from "@btst/stack/plugins/cms/client/hooks"
import type { CMSTypes } from "@/lib/cms-schemas"
function ProductList() {
const { items } = useContent<CMSTypes, "product">("product")
// ✅ TypeScript knows all field types
items[0].parsedData.name // string
items[0].parsedData.price // number
// ❌ TypeScript error: Property 'invalid' does not exist
items[0].parsedData.invalid
}4. Schema Changes Trigger Compile Errors
When you update a schema, TypeScript shows errors everywhere the types are used:
// Adding a new required field to ProductSchema...
const ProductSchema = z.object({
name: z.string(),
price: z.number(),
sku: z.string(), // New field
});
// ...triggers TypeScript errors in components
<span>{item.parsedData.sku}</span> // ✅ Now works
createProduct.mutate({
slug: "x",
data: { name: "X", price: 10 } // ❌ Error: missing 'sku'
})This ensures developers catch schema changes at compile time rather than in production.
API Endpoints
The CMS plugin exposes these REST endpoints:
| Endpoint | Method | Description |
|---|---|---|
/content-types | GET | List all content types with item counts |
/content-types/:slug | GET | Get single content type by slug |
/content/:typeSlug | GET | List items (query: slug, limit, offset) |
/content/:typeSlug | POST | Create item |
/content/:typeSlug/:id | GET | Get single item |
/content/:typeSlug/:id | PUT | Update item |
/content/:typeSlug/:id | DELETE | Delete item |
/content/:typeSlug/:id/populated | GET | Get item with relations populated |
/content/:typeSlug/populated | GET | List items with relations populated |
/content/:typeSlug/by-relation | GET | Filter by relation (query: field, targetId) |
Authorization & Lifecycle Hooks
The CMS plugin provides two levels of hooks for authorization:
Client Hooks (SSR Authorization)
Use hooks in the client plugin config for async authorization during SSR. These run in loaders before pages render, supporting async session checks and redirects:
import { redirect } from "next/navigation" // or your framework's redirect
cms: cmsClientPlugin({
apiBaseURL: baseURL,
apiBasePath: "/api/data",
siteBaseURL: baseURL,
siteBasePath: "/pages",
queryClient: queryClient,
headers: options?.headers,
hooks: {
beforeLoadDashboard: async (context) => {
const session = await getSession(context.headers)
return session?.user?.isAdmin === true
},
beforeLoadContentList: async (typeSlug, context) => {
const session = await getSession(context.headers)
return session?.user?.isAdmin === true
},
beforeLoadContentEditor: async (typeSlug, id, context) => {
const session = await getSession(context.headers)
return session?.user?.isAdmin === true
},
onLoadError: (error, context) => {
// Redirect to login on authorization failure
redirect("/auth/sign-in")
},
},
})Use client hooks for SSR. These hooks run during server-side data loading and support async operations like session checks. The onLoadError hook is called when any beforeLoad* hook returns false, allowing you to redirect unauthorized users.
Override Hooks (Client-Side)
Use lifecycle hooks in BetterStackProvider overrides for synchronous client-side checks (SPA navigation):
cms: {
// ...required overrides
onBeforeDashboardRendered: (context) => {
// Sync check - runs during component render
return user?.isAdmin === true
},
onBeforeListRendered: (typeSlug, context) => {
return true
},
onBeforeEditorRendered: (typeSlug, id, context) => {
// id is null for new items
return true
},
onRouteRender: (routeName, context) => {
// Track page views
},
onRouteError: (routeName, error, context) => {
// Log errors
},
}Override hooks are synchronous. They run during component render and cannot await async operations. For SSR authorization with session checks, use the client hooks above.
Custom Field Components
You can provide custom field components via the fieldComponents override. This allows you to:
- Override built-in types (like "file") with custom implementations
- Add custom field types for specialized inputs (rich text editors, color pickers, etc.)
Using fieldComponents Override
The fieldComponents property maps field type names to React components:
import type { CMSPluginOverrides, AutoFormInputComponentProps } from "@btst/stack/plugins/cms/client"
// Define a custom component
function MyColorPicker({ field, label, isRequired, fieldConfigItem }: AutoFormInputComponentProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium">
{label}
{isRequired && <span className="text-destructive"> *</span>}
</label>
<input
type="color"
value={field.value || "#000000"}
onChange={(e) => field.onChange(e.target.value)}
className="h-10 w-full cursor-pointer"
/>
{fieldConfigItem?.description && (
<p className="text-sm text-muted-foreground">{String(fieldConfigItem.description)}</p>
)}
</div>
)
}
// In your BetterStackProvider overrides:
cms: {
fieldComponents: {
// Override the built-in "file" type
file: ({ field, label, isRequired }) => (
<MyCustomFileUpload
value={field.value}
onChange={field.onChange}
label={label}
required={isRequired}
/>
),
// Add a custom "color" type
color: MyColorPicker,
// Add a custom "richText" type
richText: ({ field, label }) => (
<MyRichTextEditor value={field.value} onChange={field.onChange} label={label} />
),
},
// ...other overrides
}Registering Custom Field Types
To use a custom field type, add it to your Zod schema with .meta({ fieldType: "..." }):
// In your schema definition
const ProductSchema = z.object({
name: z.string().min(1),
primaryColor: z.string().optional().meta({
description: "Brand color",
fieldType: "color", // Uses custom "color" component from fieldComponents
}),
longDescription: z.string().optional().meta({
description: "Rich text content",
fieldType: "richText", // Uses custom "richText" component from fieldComponents
}),
});AutoFormInputComponentProps
Custom components receive these props:
| Prop | Type | Description |
|---|---|---|
field | ControllerRenderProps | React Hook Form field controller with value and onChange |
label | string | The field label (derived from schema key) |
isRequired | boolean | Whether the field is required |
fieldConfigItem | FieldConfigItem | Field config including description, inputProps, etc. |
fieldProps | object | Additional props from inputProps in fieldConfig |
zodItem | ZodAny | The Zod schema for this field |
Using the Built-in CMSFileUpload
The plugin exports CMSFileUpload for consumers who want to use or extend the default file upload:
import { CMSFileUpload } from "@btst/stack/plugins/cms/client"
// In your fieldComponents override
cms: {
fieldComponents: {
// Use the built-in component with your upload function
file: (props) => (
<CMSFileUpload {...props} uploadImage={myUploadFn} />
),
// Or create a wrapper with custom styling
customImage: (props) => (
<div className="my-custom-wrapper">
<CMSFileUpload {...props} uploadImage={myUploadFn} />
</div>
),
},
}When a custom component is provided for a field type via fieldComponents, it takes precedence over the built-in component. This allows you to completely customize how any field type is rendered.
Data Relationships
The CMS plugin supports relationships between content types, enabling you to build directories, blogs with tags, or any relational data structure. Relationships are defined in your Zod schemas using .meta({ fieldType: "relation", relation: {...} }).
Defining Relationships
Add a relation field to your schema:
import { z } from "zod";
// Category schema (the target of the relation)
export const CategorySchema = z.object({
name: z.string().min(1).meta({
description: "Category name",
placeholder: "Enter category name...",
}),
description: z.string().optional().meta({
description: "Optional category description",
fieldType: "textarea",
}),
color: z.string().optional().meta({
description: "Category color (hex code)",
placeholder: "#3b82f6",
}),
});
// Resource schema with a manyToMany relation to categories
export const ResourceSchema = z.object({
name: z.string().min(1).meta({
description: "Resource name",
placeholder: "Enter resource name...",
}),
description: z.string().meta({
description: "Full resource description",
fieldType: "textarea",
}),
website: z.string().url().optional().meta({
description: "Website URL",
placeholder: "https://example.com",
}),
// Relation field - manyToMany with categories
categoryIds: z
.array(z.object({ id: z.string() }))
.default([])
.meta({
fieldType: "relation",
relation: {
type: "manyToMany",
targetType: "category", // Slug of the target content type
displayField: "name", // Field to display in the selector
creatable: true, // Allow creating new categories inline
},
}),
});
export type CategoryData = z.infer<typeof CategorySchema>;
export type ResourceData = z.infer<typeof ResourceSchema>;
export type CMSTypes = {
category: CategoryData;
resource: ResourceData;
};Relationship Types
| Type | Description | Schema Format | Use Case |
|---|---|---|---|
belongsTo | Single reference to another item | z.object({ id: z.string() }).optional() | Comment → Resource (one-to-many inverse) |
hasMany | Multiple references | z.array(z.object({ id: z.string() })) | Author → Posts |
manyToMany | Many-to-many via junction table | z.array(z.object({ id: z.string() })) | Resource ↔ Categories |
belongsTo vs manyToMany: Use belongsTo when an item references a single parent (e.g., a Comment belongs to one Resource). Use manyToMany when items can have multiple relationships (e.g., a Resource can have many Categories).
belongsTo Example (One-to-Many)
For one-to-many relationships, the "many" side uses belongsTo to reference the "one" side:
// Resource Schema - the "one" side
export const ResourceSchema = z.object({
name: z.string().min(1),
description: z.string(),
// ... other fields
});
// Comment Schema - the "many" side (belongs to Resource)
export const CommentSchema = z.object({
author: z.string().min(1).meta({
description: "Comment author name",
placeholder: "Your name...",
}),
content: z.string().min(1).meta({
description: "Comment content",
placeholder: "Write your comment...",
fieldType: "textarea",
}),
// belongsTo relation - links to a single Resource
// Unlike manyToMany (array), belongsTo stores a single { id: string }
resourceId: z.object({ id: z.string() }).optional().meta({
fieldType: "relation",
relation: {
type: "belongsTo",
targetType: "resource",
displayField: "name",
},
}),
});The admin UI renders belongsTo fields as a single-select dropdown instead of a multi-select.
RelationConfig Properties
| Property | Type | Description |
|---|---|---|
type | "belongsTo" | "hasMany" | "manyToMany" | The relationship type |
targetType | string | Slug of the target content type |
displayField | string | Field to show in the selector (e.g., "name", "title") |
creatable | boolean | Allow creating new related items inline (optional, default: false) |
Relation Hooks
Use these hooks to fetch content with populated relations:
import {
useContentItemPopulated,
useContentByRelation
} from "@btst/stack/plugins/cms/client/hooks"
import type { CMSTypes } from "@/lib/cms-schemas"
// Get a single resource with its related categories populated
function ResourceDetail({ id }: { id: string }) {
const { item, isLoading } = useContentItemPopulated<CMSTypes, "resource">(
"resource",
id
)
if (isLoading || !item) return <Skeleton />
return (
<div>
<h1>{item.parsedData.name}</h1>
<p>{item.parsedData.description}</p>
{/* Related categories are populated in _relations */}
<div className="flex gap-2">
{item._relations?.categoryIds?.map((category) => (
<span key={category.id} className="badge">
{category.parsedData.name}
</span>
))}
</div>
</div>
)
}
// Get resources filtered by a specific category
function CategoryResources({ categoryId }: { categoryId: string }) {
const { items, isLoading } = useContentByRelation<CMSTypes, "resource">(
"resource",
"categoryIds", // Field name containing the relation
categoryId // ID of the related category
)
return (
<ul>
{items.map((resource) => (
<li key={resource.id}>{resource.parsedData.name}</li>
))}
</ul>
)
}Inline Creation
When creatable: true is set in the relation config, users can create new related items directly from the relation selector. A modal form will appear allowing them to create a new item (e.g., a new category) without leaving the current form.
categoryIds: z
.array(z.object({ id: z.string() }))
.default([])
.meta({
fieldType: "relation",
relation: {
type: "manyToMany",
targetType: "category",
displayField: "name",
creatable: true, // Shows "Create new..." option in selector
},
}),Inverse Relations Panel
When editing content in the CMS admin, an Inverse Relations Panel automatically appears below the form. This panel shows all items that reference the current item via belongsTo relations.
For example, when editing a Resource, the panel displays all Comments that belong to that Resource:
┌─────────────────────────────────────────────┐
│ 📝 Comments (3) [▼] │
├─────────────────────────────────────────────┤
│ • "Great resource!" by John [Edit] [🗑] │
│ • "Very helpful" by Jane [Edit] [🗑] │
│ • "Thanks!" by Bob [Edit] [🗑] │
│ │
│ [+ Add Comment] │
└─────────────────────────────────────────────┘The panel:
- Auto-discovers content types with
belongsTorelations pointing to the current type - Shows a count and list of related items with edit/delete links
- Provides an "Add" button to create new related items with the relation pre-filled
Relation API Endpoints
| Endpoint | Method | Description |
|---|---|---|
/content/:typeSlug/:id/populated | GET | Get item with relations populated |
/content/:typeSlug/populated | GET | List items with relations populated |
/content/:typeSlug/by-relation | GET | Filter by relation (query: field, targetId) |
/content-types/:slug/inverse-relations | GET | Get content types that reference this type (query: itemId optional) |
/content-types/:slug/inverse-relations/:sourceType | GET | Get items referencing this item (query: itemId, fieldName) |
Example API calls:
# Get resource with populated categories
curl /api/data/content/resource/abc123/populated
# Get all resources linked to a specific category
curl /api/data/content/resource/by-relation?field=categoryIds&targetId=cat456
# Get inverse relations for a resource (what types reference it)
curl /api/data/content-types/resource/inverse-relations?itemId=abc123
# Get all comments for a specific resource
curl /api/data/content-types/resource/inverse-relations/comment?itemId=abc123&fieldName=resourceIdCreating Items with Relations via API
When creating content items via API, pass relation values based on the relation type:
manyToMany / hasMany Relations (Array)
// Link to existing categories (array format)
await fetch("/api/data/content/resource", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slug: "my-resource",
data: {
name: "My Resource",
description: "A great resource",
categoryIds: [
{ id: "existing-category-id-1" },
{ id: "existing-category-id-2" },
],
},
}),
});
// Create new categories inline using _new flag
await fetch("/api/data/content/resource", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slug: "my-resource",
data: {
name: "My Resource",
description: "A great resource",
categoryIds: [
{ id: "existing-category-id" },
{ _new: true, data: { name: "New Category", color: "#10b981" } },
],
},
}),
});belongsTo Relations (Single Object)
// Create comment linked to a resource (single object format)
await fetch("/api/data/content/comment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slug: "my-comment",
data: {
author: "John Doe",
content: "Great resource!",
resourceId: { id: "existing-resource-id" }, // Single object, not array
},
}),
});Building a Directory
Here's a complete example of building a resource directory with categories:
"use client"
import { useContent } from "@btst/stack/plugins/cms/client/hooks"
import type { CMSTypes } from "@/lib/cms-schemas"
export default function DirectoryPage() {
const { items: resources } = useContent<CMSTypes, "resource">("resource")
const { items: categories } = useContent<CMSTypes, "category">("category")
const [search, setSearch] = useState("")
const filteredResources = resources.filter((r) =>
r.parsedData.name.toLowerCase().includes(search.toLowerCase())
)
return (
<div className="flex gap-8">
{/* Sidebar with categories */}
<aside className="w-64">
<h3>Categories</h3>
<ul>
{categories.map((cat) => (
<li key={cat.id}>
<Link href={`/directory/category/${cat.id}`}>
{cat.parsedData.name}
</Link>
</li>
))}
</ul>
</aside>
{/* Main content */}
<main className="flex-1">
<input
type="text"
placeholder="Search resources..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="grid grid-cols-3 gap-4">
{filteredResources.map((resource) => (
<Link key={resource.id} href={`/directory/${resource.id}`}>
<h3>{resource.parsedData.name}</h3>
<p>{resource.parsedData.description}</p>
</Link>
))}
</div>
</main>
</div>
)
}API Reference
Backend (@btst/stack/plugins/cms/api)
CMSBackendConfig
Prop
Type
CMSBackendHooks
Prop
Type
CMSHookContext
Prop
Type
Client (@btst/stack/plugins/cms/client)
cmsClientPlugin
Prop
Type
CMSClientConfig
Prop
Type
CMSClientHooks
Customize client-side behavior with lifecycle hooks. These hooks run during SSR data loading and support async authorization:
Prop
Type
Example usage:
cms: cmsClientPlugin({
// ... rest of the config
headers: options?.headers,
hooks: {
beforeLoadDashboard: async (context) => {
const session = await getSession(context.headers)
return session?.user?.isAdmin === true
},
beforeLoadContentList: async (typeSlug, context) => {
// Check per-content-type permissions
return isAdmin(context.headers)
},
beforeLoadContentEditor: async (typeSlug, id, context) => {
return isAdmin(context.headers)
},
onLoadError(error, context) {
// Redirect on auth failure
redirect("/auth/sign-in")
},
}
})LoaderContext
Prop
Type
CMSPluginOverrides
Configure framework-specific overrides and route lifecycle hooks:
Prop
Type
Schema Converter Utilities (@btst/stack/plugins/cms/client)
The CMS plugin re-exports schema converter utilities for converting between Zod schemas and JSON Schema. These are useful when working with content types programmatically:
zodToFormSchema
Convert a Zod schema to JSON Schema with proper handling for dates, steps metadata, and date constraints:
Prop
Type
Example:
import { zodToFormSchema } from "@btst/stack/plugins/cms/client"
const jsonSchema = zodToFormSchema(ProductSchema, {
steps: [
{ id: "basic", title: "Basic Info" },
{ id: "details", title: "Details" }
],
stepGroupMap: {
name: 0,
price: 0,
description: 1
}
})formSchemaToZod
Convert JSON Schema back to a Zod schema with proper handling for date fields, constraints, and steps metadata:
Prop
Type
Example:
import { formSchemaToZod } from "@btst/stack/plugins/cms/client"
// Convert JSON Schema from database to Zod for validation
const zodSchema = formSchemaToZod(jsonSchema)
const result = zodSchema.safeParse(data)Utility Functions
Prop
Type
Prop
Type
Prop
Type
Types
Prop
Type
Prop
Type


