Skip to content

Collections

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

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.

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 admin block. Its collections — or no collections at all, since BaaS mode introspects the database — describe data and authorization, nothing else. @rebasepro/types carries 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.

Before 0.11 these fields sat at the top level. To move them:

node scripts/codemod/collections-admin-block.mjs config/collections

It 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
}
});
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.
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.

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

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

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.

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.

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.

When display.title is not set, the property used as the entity’s display title (previews, headers) is resolved automatically:

  1. If propertiesOrder is explicitly defined, the first non-ID property that is either a relation or string type is chosen as the title.
  2. If no propertiesOrder is defined, the framework searches the properties in order and picks the first string type property.

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).

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.

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;
};

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.

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.