Record Layouts
Customize how fields are grouped and displayed on the CMS record detail page with code-defined and user-created layouts.
Record Layouts
The CMS record detail page groups fields into collapsible sections. By default, fields are auto-grouped by naming heuristics (Identity, Contact Info, Financial, etc.). With record layouts, you control the exact grouping.
There are two layers:
- Code-defined layouts — developers configure named layouts in the table config
- Custom layouts — end-users create and save record layouts through the CMS table settings
Code-Defined Layouts
Add a layouts property to your table config:
// quickback/features/contacts/contacts.ts
import { feature, q } from "@quickback/compiler";
export default feature("contacts", {
columns: {
id: q.id(),
name: q.text().required(),
status: q.text().required(),
email: q.text().optional(),
phone: q.text().optional(),
mobile: q.text().optional(),
address1: q.text().optional(),
address2: q.text().optional(),
city: q.text().optional(),
state: q.text().optional(),
zip: q.text().optional(),
notes: q.text().optional(),
internalNotes: q.text().optional(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
},
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
// Contact details are detected as sensitive; on a CRM contact record they
// are the point of the record, so this one is reviewed and left visible.
masking: { email: false, phone: false, mobile: false },
create: { /* ... */ },
update: { /* ... */ },
delete: { /* ... */ },
layouts: {
default: {
sections: [
{ label: "Contact Info", columns: 2, fields: ["name", "email", "phone", "mobile"] },
{ label: "Address", columns: 2, fields: ["address1", "address2", "city", "state", "zip"] },
{ label: "Internal Notes", collapsed: true, fields: ["notes", "internalNotes"] }
]
},
compact: {
sections: [
{ label: "Summary", fields: ["name", "status", "email"] }
]
}
}
});Each layout has an ordered list of sections. Each section specifies:
| Property | Type | Default | Description |
|---|---|---|---|
label | string | required | Section header text |
fields | string[] | required | Column names to display |
columns | 1 | 2 | 1 | Number of columns for field layout |
collapsed | boolean | false | Whether the section starts collapsed |
Fields not assigned to any section are collected into an "Other Fields" section at the bottom.
Layout Switcher
When a table has multiple named layouts, a dropdown appears in the record detail header. Selections persist per table using localStorage.
If only one layout is defined, it's used automatically without showing a dropdown.
Custom Record Layouts
End-users can create their own record layouts via the CMS UI. These are stored in the database and can be shared with other organization members.
Creating a Layout
- Open the table
- Click the settings cog in the table header
- Open Record layouts and click Create
- In the record layout dialog:
- Name your layout
- Add sections and assign fields from a dropdown
- Set columns (1 or 2) and collapsed state per section
- Optionally share with your organization
- Click Create layout
The new layout appears in the record-page layout dropdown alongside code-defined layouts.
Editing and Deleting Layouts
- Open Record layouts from the table header to edit or delete a custom layout
- Open a record and click Customize layout to edit the active custom layout against that record
- Code-defined layouts cannot be edited or deleted from the CMS
Access Control
| Operation | Who Can Do It |
|---|---|
| Create a layout | Any member, admin, or owner |
| Edit/delete own layouts | The creator |
| Edit/delete any layout | Admins and owners |
| View shared layouts | All organization members |
Setting Up Custom Layout Storage
To enable custom record layouts, add a customView table to your Quickback project:
// quickback/features/cms/custom-view.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { defineTable } from "@quickback/compiler";
export const customView = sqliteTable("custom_view", {
id: text("id").primaryKey(),
organizationId: text("organization_id").notNull(),
tableName: text("table_name").notNull(),
name: text("name").notNull(),
description: text("description"),
layoutConfig: text("layout_config").notNull(),
isShared: integer("is_shared", { mode: "boolean" }).default(false),
// ── quickback:audit (compiler-managed — edits are validated, not merged) ──
// No deletedAt/deletedBy: this resource hard-deletes (see `delete.mode` below),
// and declaring the soft-delete pair on a hard-delete table is a compile error.
createdAt: text("created_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
modifiedAt: text("modified_at").notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
createdBy: text("created_by"),
modifiedBy: text("modified_by"),
});
export default defineTable(customView, {
displayColumn: "name",
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
read: {
access: { roles: ["owner", "admin", "member"] },
},
create: { access: { roles: ["owner", "admin", "member"] } },
update: {
access: {
or: [
{ roles: ["owner", "admin"] },
{ roles: ["member"], record: { createdBy: { equals: "$userId" } } }
]
}
},
delete: {
access: {
or: [
{ roles: ["owner", "admin"] },
{ roles: ["member"], record: { createdBy: { equals: "$userId" } } }
]
},
mode: "hard"
},
guards: {
createable: ["tableName", "name", "description", "layoutConfig", "isShared"],
updatable: ["name", "description", "layoutConfig", "isShared"]
}
});Compile your project to generate the API endpoints. The CMS automatically detects the customView table and enables record layout management.
Fallback Behavior
| Scenario | Result |
|---|---|
No layouts config, no custom layouts | Auto-grouping by naming heuristics |
layouts config defined | Uses "default" layout or first available |
| Multiple layouts | Dropdown for switching, persisted per table |
| Custom layouts created | Appear in dropdown below code-defined layouts |
Next Steps
- Table Views — Column projections for list views
- Schema Format — Full TypeScript type reference
- Database Schema — defineTable() configuration reference