Collections
What is a Collection?
Section titled “What is a Collection?”A collection is a TypeScript object that describes a database table and how it should appear in Rebase CMS. It defines:
- Schema — Properties (columns), their types, and validation rules
- Relations — Foreign keys, junction tables, and join paths
- Security — Row Level Security policies
- Lifecycle hooks — Callbacks for create, update, delete operations
- CMS behavior — View modes, inline editing, entity views, actions — all under
admin
Declaring one: defineCollection
Section titled “Declaring one: defineCollection”Wrap the literal in defineCollection. At runtime it is the identity function — it
returns the object unchanged — so it costs nothing. What it buys is inference: a const
type parameter captures your properties keys as literal types, and the key-shaped
fields of the admin block are then checked against them. A name that is not one of
your properties is a compile error, not just a missing suggestion.
import { defineCollection } from "@rebasepro/cms-types";
const products = defineCollection({ name: "Products", slug: "products", table: "products", properties: { name: { name: "Name", type: "string" }, price: { name: "Price", type: "number" } }, admin: { display: { title: "name" }, // completion: "name" | "price" sort: ["price", "asc"], // completion on the first element propertiesOrder: ["name", "price"] }}); admin: { display: { title: "nmae" } // ~~~~~~ Type '"nmae"' is not assignable to type // 'PropertyPath<…>'. Did you mean '"name"'? }The checked fields are display, sort, propertiesOrder and listProperties.
Three forms are accepted besides a plain property key:
| Form | Example | Notes |
|---|---|---|
Dotted path into a map |
"profile.displayName" |
The root must be a real property; the path below it is not checked. |
| Child-collection column | "subcollection:orders" |
propertiesOrder / listProperties only. |
An additionalFields key |
"score" as AdditionalFieldKey |
Needs the cast — see below. |
AdditionalFieldDelegate.key is a plain string, so the type system has no way to know
which extra keys a collection declares. Rather than reopen these fields to every string,
the cast makes the exception explicit:
import type { AdditionalFieldKey } from "@rebasepro/cms-types";
propertiesOrder: ["title", "score" as AdditionalFieldKey]Import it from @rebasepro/cms-types in a project that has an admin panel — that is
the copy that also typechecks the admin block. A headless BaaS project, which has no
admin block, imports the same function from @rebasepro/common instead.
Annotating the type directly still works and is still checked:
import type { PostgresCollectionConfig } from "@rebasepro/types";
const products: PostgresCollectionConfig = { name: "Products", slug: "products", table: "products", properties: { name: { name: "Name", type: "string" } }};but an annotation only validates the shape — it cannot see your property names, so the
admin key fields fall back to accepting any string. Prefer defineCollection unless you
need to name the type.
Anatomy: the contract, and the panel
Section titled “Anatomy: the contract, and the panel”One file, two audiences. Everything the database and the API care about sits at the
top level; everything the admin panel renders sits inside admin.
const posts = { // ── The backend reads these ────────────────────────────── slug: "posts", table: "posts", properties: { /* … */ }, relations: [ /* … */ ], securityRules: [ /* … */ ], callbacks: { /* … */ }, history: true,
// ── The admin panel reads these ────────────────────────── admin: { icon: "FileText", listProperties: ["title", "status"], defaultViewMode: "table", entityViews: ["preview"] }};The split is not cosmetic. It is what lets Rebase be a backend on its own:
- A BaaS or headless project never writes an
adminblock. Its collections — or no collections at all, since BaaS mode introspects the database — describe data and authorization, nothing else.@rebasepro/typescarries no UI code, so a headless project’s dependency tree stays server-only. - The backend never reads inside the block. It is dropped before a collection is serialized to the contract endpoint or into a build bundle, and it is excluded from the schema version — so changing an icon does not report every generated SDK as stale.
The admin block exists only if you install the admin types
Section titled “The admin block exists only if you install the admin types”@rebasepro/types declares no admin field — not on a collection, not on a property. In
a BaaS project, writing one is a type error. @rebasepro/cms-types adds it back by
declaration merging, so one line per project turns it on:
// config/cms.d.ts/// <reference types="@rebasepro/cms-types" />After that, plain core types carry a fully typed block — a typo like icoon is an error,
and you get completion:
import { defineCollection } from "@rebasepro/cms-types";
const posts = defineCollection({ slug: "posts", name: "Posts", table: "posts", properties: { title: { name: "Title", type: "string", admin: { multiline: true } } }, admin: { icon: "FileText" }});An augmentation applies to the whole TypeScript program, and config/ and frontend/
are separate programs — which is why the reference belongs in the config package. There
is no AdminCollectionConfig wrapper type: with the field merged in, CollectionConfig
is the authoring type.
Migrating from a flat collection
Section titled “Migrating from a flat collection”Before 0.11 these fields sat at the top level. To move them:
node scripts/codemod/collections-admin-block.mjs config/collectionsIt reports anything it cannot move safely — notably presentation inside
relations[].overrides, which needs overrides: { admin: { … } } by hand.
import { defineCollection } from "@rebasepro/cms-types";
export const productsCollection = defineCollection({ slug: "products", // URL path and API endpoint name: "Products", // Display name (plural) singularName: "Product", // Display name (singular) table: "products", // PostgreSQL table name
properties: { name: { type: "string", name: "Product Name", validation: { required: true } }, price: { type: "number", name: "Price", validation: { required: true, min: 0 } }, category: { type: "string", name: "Category", enum: [ { id: "electronics", label: "Electronics", color: "blue" }, { id: "clothing", label: "Clothing", color: "pink" }, { id: "books", label: "Books", color: "orange" } ] }, description: { type: "string", name: "Description", admin: { multiline: true } }, active: { type: "boolean", name: "Active", defaultValue: true }, createdAt: { type: "date", name: "Created At", autoValue: "on_create", admin: { readOnly: true } } }, admin: { icon: "Package" // Lucide icon name }});Key Properties
Section titled “Key Properties”Identification
Section titled “Identification”| Property | Type | Description |
|---|---|---|
slug |
string |
Required. URL-safe identifier. Used in the admin UI URL and REST API path (/api/data/{slug}). |
name |
string |
Required. Display name (plural). Shown in navigation and page headers. |
singularName |
string |
Display name for a single entity. Used in “New Product”, “Edit Product”, etc. |
description |
string |
A sentence about what this collection holds, shown above the list. Markdown. |
table |
string |
PostgreSQL table name. Defaults to toSnakeCase(slug) — set it only to decouple the URL from the table, e.g. an existing blog_posts table served at /posts. |
admin.icon |
string |
A Lucide icon name, e.g. "FileText", "ShoppingCart". A rendered element also works, but the name survives serialization, so it is what the schema editor writes back. |
Schema
Section titled “Schema”| Property | Type | Description |
|---|---|---|
properties |
Properties |
Required. Map of property key → property definition. Each key becomes a database column. |
relations |
Relation[] |
SQL relations — foreign keys, junction tables. See Relations. |
securityRules |
SecurityRule[] |
Row Level Security policies. See Security Rules. |
indexes |
CollectionIndex[] |
Postgres indexes this table needs. See Indexes. |
search |
SearchConfig |
Ranked full-text search over the fields you name, including JSONB and array content. Postgres only. See Search. |
auth |
boolean | AuthCollectionConfig |
Mark collection as authentication collection (user management, reset password, etc.) |
schema |
string |
Postgres schema the table lives in — "public", "rebase", "auth". Defaults to "public". |
disableDefaultPolicies |
boolean |
Remove the baseline policies the generator injects — an admin/server SELECT, and on an auth collection a self-read plus an admin-only write gate — and take full responsibility for this collection’s RLS. false by default. See Security Rules. |
softDelete |
boolean | { field?: string } |
Turn delete into a timestamp and hide stamped rows from every read. true uses deletedAt; the object form renames the field. The collection must declare that date property itself. Postgres only — see Soft delete |
strictWrites |
boolean |
Reject a write that names a field this collection does not declare, with a 400. true by default. Set it to false only where the column genuinely exists and is not declared — filled by a trigger, or introspected rather than written down. |
UI Configuration
Section titled “UI Configuration”All of the following go inside admin.
| Property | Type | Default | Description |
|---|---|---|---|
defaultViewMode |
"list" | "table" | "cards" | "kanban" |
"list" |
Default view mode |
enabledViews |
ViewMode[] |
The four built-ins plus every declared custom view (kanban only when an enum property exists) |
Which view modes are available |
kanban |
KanbanConfig |
— | Kanban configuration (column property). Always pair with orderProperty — see View Modes |
orderProperty |
string |
— | Key of the string property holding the drag-and-drop order key. Required for a working Kanban board |
openEntityMode |
"side_panel" | "full_screen" | "split" | "dialog" |
From the view: "split" for list, "full_screen" for table and cards, "side_panel" for kanban and custom views |
How entities open for editing |
sideDialogWidth |
number | string |
— | Width of the side dialog |
inlineEditing |
boolean |
true |
Enable inline editing in the spreadsheet view |
defaultSize |
"xs" | "s" | "m" | "l" | "xl" |
"m" |
Default density of the list, table and card views (the table’s row height) |
pagination |
boolean | number |
true (50) |
Enable pagination and/or set page size. false loads every row; in development the console warns once when that is more than 10,000 rows |
listProperties |
string[] |
— | Properties to display in the list view |
propertiesOrder |
string[] |
— | Column order in the table view |
selectionEnabled |
boolean |
true |
Enable row selection |
hideFromNavigation |
boolean |
false |
Hide from the sidebar navigation |
defaultSelectedView |
string | function |
— | Default view or subcollection to open |
Entity Options
Section titled “Entity Options”Inside admin, except history, which is a backend feature and stays at the top level.
| Property | Type | Default | Description |
|---|---|---|---|
formAutoSave |
boolean |
false |
Auto-save two seconds after an edit, once the form validates. A New form is not saved until something is entered. |
localChangesBackup |
"manual_apply" | "auto_apply" | false |
"manual_apply" |
Backup unsaved changes |
hideIdFromForm |
boolean |
false |
Hide the entity ID from the form |
hideIdFromCollection |
boolean |
false |
Hide the ID column from the table |
includeJsonView |
boolean |
true |
Offer the raw values in the record inspector |
history |
boolean |
false |
Track changes in entity history |
alwaysApplyDefaultValues |
boolean |
false |
When an existing record opens in the form, fill each property’s declared defaultValue into a value the record leaves empty (null or missing), so the next save writes it |
previewProperties |
string[] |
— | Properties to display in reference previews |
display |
EntityDisplay |
— | What fills each display role — see Entity display |
Advanced
Section titled “Advanced”At the top level, because the backend reads them:
| Property | Type | Description |
|---|---|---|
callbacks |
CollectionCallbacks |
Lifecycle hooks (beforeSave, afterSave, beforeDelete, etc.) |
childCollections |
() => CollectionConfig[] |
The collections nested under an entity of this one. Filled in during normalization from whatever the driver expresses them with — a Firestore subcollections, a Postgres hasMany relation — so a custom driver is the only reason to set it by hand |
dataSource |
string |
Which registered data source backs this collection (default: the unnamed one) |
engine |
string |
The engine behind it — "postgres", "firestore", "mongodb". Resolved from dataSource; set it only to override |
databaseId |
string |
Database or schema within the engine |
metadata |
Record<string, unknown> |
Anything your own code needs to hang off a collection. Rebase does not read it; it survives serialization unchanged |
ownerId |
string |
Admin form only — not enforced by the API or the database. The user id the collection editor stamps on a collection it creates, and shows beside its name. Nothing on the request path consults it |
subcollections and path are on the document-database configs only —
FirebaseCollectionConfig and, for path, MongoDBCollectionConfig:
| Property | Type | Description |
|---|---|---|
subcollections |
() => CollectionConfig[] |
Firestore only. Collections nested under each document. A Postgres collection says the same thing with a hasMany relation, which is what fills childCollections |
path |
string |
Firestore and MongoDB only. The path or collection name at the engine, when it differs from the slug |
And inside admin, because only the panel draws them:
| Property | Type | Description |
|---|---|---|
admin.entityActions |
EntityAction[] |
Custom actions on entities (archive, publish, etc.) |
admin.Actions |
React.ComponentType |
Custom toolbar actions component |
admin.entityViews |
EntityCustomView[] |
Custom tabs in the entity detail view |
admin.additionalFields |
AdditionalFieldDelegate[] |
Computed/virtual columns |
admin.exportable |
boolean | ExportConfig |
Enable data export |
admin.components |
CollectionComponentOverrideMap |
Collection-scoped UI component overrides |
Writing any of those six at the top level is a boot-time error, with a message naming the key and where it moved to.
Entity display
Section titled “Entity display”Every surface that draws a record draws some subset of six roles: title, subtitle, image, status, date and tags. A list row is image + title + subtitle + status + date, a card is the same with the image on top, a reference picker is title + subtitle, and a page heading is the title alone.
Each role is derived from your properties, and each can be stated instead — as a property path, or as a function:
const exercises = defineCollection({ name: "Exercises", slug: "exercises", table: "exercises", properties: { name: { name: "Name", type: "string" }, cover: { name: "Cover", type: "string", storage: { storagePath: "covers/" } }, city: { name: "City", type: "string" } }, admin: { display: { title: "name", // a property path image: "cover", subtitle: ({ entity }) => `in ${entity.values.city}` // computed } }});Anything you leave out keeps its derived value, so stating one role does not mean stating all six.
Computed and async roles
Section titled “Computed and async roles”A resolver may be async, which is what lets a role read something the record
does not carry — a document in a subcollection, a value behind an API:
admin: { display: { // The exercise's name lives one document down, per locale. title: async ({ entity, context }) => { const locale = await context.data.exercise_locales.get(`${entity.id}/de-DE`); return locale?.exercise_title; } }}While the promise is in flight the surface shows the derived value and swaps the resolved one in when it lands — a title is never a spinner. Results are cached per record and per role, and concurrent asks for the same pair share one call, so a list of fifty rows resolves each row once rather than once per render.
Return undefined when a record has nothing for the role; the surface’s own
fallback is better informed about what belongs there instead (a heading uses the
singular collection name, a link uses the id). A resolver that throws is treated
as undefined and logged once — a title that cannot be fetched must not take
down the row that shows it.
Prefer a path whenever the value is on the record: a path keeps the property’s own rendering, so an enum status stays a coloured chip and a date stays formatted, which a resolver returning a bare string cannot express.
Title Property Selection
Section titled “Title Property Selection”When display.title is not set, the property used as the entity’s display title (previews, headers) is resolved automatically:
- If
propertiesOrderis explicitly defined, the first non-ID property that is either arelationorstringtype is chosen as the title. - If no
propertiesOrderis defined, the framework searches the properties in order and picks the first string type property.
Relation Previews in Tables
Section titled “Relation Previews in Tables”When propertiesOrder is explicitly set, relation properties are not automatically filtered out of the default preview columns (whereas they are excluded from unordered defaults to avoid slow join operations).
What a title value renders as
Section titled “What a title value renders as”Whatever the title property holds, the panel renders a string. A date is formatted, an array is joined, and a relation — which arrives as { id, data: { values } } rather than as text — is looked through for the first of name, title, label or displayName on the related row, falling back to its id. So a title may name a relation property and still read as a name rather than a uuid.
This is not an exported helper: it is what every surface that draws a record already does. Nothing to call, and nothing to import.
Collection Builder
Section titled “Collection Builder”For dynamic collections that change based on the user or external data, use a builder function:
const collectionsBuilder: CollectionConfigsBuilder = ({ user, authController }) => { const collections = [productsCollection];
// `extra` is whatever your auth provider put there, so name its shape here. const extra = authController.extra as { role?: string }; if (extra.role === "admin") { collections.push(adminSettingsCollection); }
return collections;};Filtering and Sorting
Section titled “Filtering and Sorting”You can set default or forced filters. All three are presentation — what the
panel opens with — so they live in admin:
import { defineCollection } from "@rebasepro/cms-types";
const invoices = defineCollection({ slug: "invoices", name: "Invoices", table: "invoices", properties: { active: { name: "Active", type: "boolean" }, tenantId: { name: "Tenant", type: "string" }, createdAt: { name: "Created", type: "date" } }, admin: { // Default filter — users can change it defaultFilter: { active: ["==", true] },
// Fixed filter — cannot be changed; filters the user applies // on other fields combine with it fixedFilter: { tenantId: ["==", currentTenantId] },
// Default sort sort: ["createdAt", "desc"] }});The user can still filter a collection that has a fixedFilter: whatever they
apply on other fields is combined with it (AND), and the fixed fields stay locked.
A fixedFilter narrows what the panel asks for; it is not a boundary. What a
caller is allowed to read is a security rule,
which the database enforces for every caller, panel or not.
Many collections
Section titled “Many collections”Nothing in Rebase caps the number of collections, but two costs grow with it:
- The admin’s first download. The panel needs its navigation the moment someone signs in, so every collection’s config is bundled into what the browser fetches for the login screen — about 2.4 KB minified each, so 200 collections add roughly 480 KB, around 80 KB gzipped. What opening a collection needs (the table, the record views, the form) loads only then.
rebase db push. Atlas plans a push by diffing the whole schema, and that grows faster than the table count: at 400 or more tables a push takes tens of seconds — see how long a push takes.
Next Steps
Section titled “Next Steps”- Entity Callbacks — Lifecycle hooks for syncing data between collections, validation, side effects
- Properties — All property types and options
- Relations — Foreign keys, junction tables, joins
- Security Rules — Row Level Security
- View Modes — List, Table, Cards, Kanban