BETTER-STACK

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
CMS Plugin DemoCMS Plugin DemoCMS Plugin Demo

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:

lib/cms-schemas.ts
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:

lib/better-stack.ts
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:

lib/better-stack-client.tsx
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:

app/pages/layout.tsx
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:

app/globals.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 TypeDefault HandlerWith 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:

  1. 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
});
  1. Provide uploadImage in 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:

RouteDescription
/cmsDashboard - Grid of content types with item counts
/cms/:typeSlugContent list - Paginated table of items
/cms/:typeSlug/newCreate new item
/cms/:typeSlug/:idEdit 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

HookDescriptionReturns
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:

HookDescriptionReturns
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

HookDescriptionReturns
useCreateContent(typeSlug)Create mutationReact Query mutation
useUpdateContent(typeSlug)Update mutationReact Query mutation
useDeleteContent(typeSlug)Delete mutationReact 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>
}

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:

EndpointMethodDescription
/content-typesGETList all content types with item counts
/content-types/:slugGETGet single content type by slug
/content/:typeSlugGETList items (query: slug, limit, offset)
/content/:typeSlugPOSTCreate item
/content/:typeSlug/:idGETGet single item
/content/:typeSlug/:idPUTUpdate item
/content/:typeSlug/:idDELETEDelete item
/content/:typeSlug/:id/populatedGETGet item with relations populated
/content/:typeSlug/populatedGETList items with relations populated
/content/:typeSlug/by-relationGETFilter 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:

lib/better-stack-client.tsx
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):

app/pages/layout.tsx
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:

PropTypeDescription
fieldControllerRenderPropsReact Hook Form field controller with value and onChange
labelstringThe field label (derived from schema key)
isRequiredbooleanWhether the field is required
fieldConfigItemFieldConfigItemField config including description, inputProps, etc.
fieldPropsobjectAdditional props from inputProps in fieldConfig
zodItemZodAnyThe 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:

lib/cms-schemas.ts
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

TypeDescriptionSchema FormatUse Case
belongsToSingle reference to another itemz.object({ id: z.string() }).optional()Comment → Resource (one-to-many inverse)
hasManyMultiple referencesz.array(z.object({ id: z.string() }))Author → Posts
manyToManyMany-to-many via junction tablez.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:

lib/cms-schemas.ts
// 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

PropertyTypeDescription
type"belongsTo" | "hasMany" | "manyToMany"The relationship type
targetTypestringSlug of the target content type
displayFieldstringField to show in the selector (e.g., "name", "title")
creatablebooleanAllow 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 belongsTo relations 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

EndpointMethodDescription
/content/:typeSlug/:id/populatedGETGet item with relations populated
/content/:typeSlug/populatedGETList items with relations populated
/content/:typeSlug/by-relationGETFilter by relation (query: field, targetId)
/content-types/:slug/inverse-relationsGETGet content types that reference this type (query: itemId optional)
/content-types/:slug/inverse-relations/:sourceTypeGETGet 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=resourceId

Creating 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:

app/directory/page.tsx
"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:

lib/better-stack-client.tsx
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