Metadata
Configure table display, views, sections, filters, and column behaviour via JSON metadata in PostgreSQL comments
Overview
Supasheet reads a JSON object from each table's PostgreSQL comment to control how the resource is presented. The same pattern works for columns — each column's comment can carry its own metadata.
COMMENT ON TABLE your_table IS '{ ...table metadata... }';
COMMENT ON COLUMN your_table.your_column IS '{ ...column metadata... }';All properties are optional. The full TableMetadata shape mirrors the TypeScript type defined in src/lib/database-meta.types.ts:
type TableMetadata = {
display?: "block" | "none"
name?: string
description?: string
icon?: string
singleton?: boolean
inline_form?: boolean
query?: {
sort?: { id: string; desc: boolean }[]
filter?: { id: string; value: string | string[]; operator: string }[]
join?: { table: string; on: string; columns: string[] }[]
select?: string[]
}
primary_view?: string
views?: ViewLayout[] // Kanban | Calendar | Gallery | List | Tree
filter_presets?: FilterPreset[]
fields?: {
sections?: FieldSection[]
metadata?: string[]
quick_create?: string[]
duplicated?: string[]
behavior?: Record<string, FieldBehavior> // keyed by field name
lookups?: Record<string, LookupConfig> // keyed by field name
}
tabs?: string[] // filters which related-table tabs show — see `tabs` below
}FilterRule (used in both query.filter and filter_presets) has no variant field — only { id, value, operator }. The valid operator values depend on the target column's type; see Query Configuration for the full list.
display
Controls whether the resource appears in the sidebar.
| Value | Behaviour |
|---|---|
"block" (default) | Listed in the schema's resource sidebar |
"none" | Hidden from the sidebar — still reachable by direct URL or as a child via inline_form |
COMMENT ON TABLE supasheet.notifications IS '{"display": "none"}';name and description
Override the default label and description shown in the sidebar, page header, and breadcrumbs.
COMMENT ON TABLE blog.blog_settings IS '{
"name": "Blog Settings",
"description": "Site-wide blog configuration"
}';icon
Any Lucide React icon name.
COMMENT ON TABLE desk.tasks IS '{"icon": "ListTodo"}';
COMMENT ON TABLE store.products IS '{"icon": "Package"}';
COMMENT ON TABLE hr.employees IS '{"icon": "Users"}';singleton
Marks the table as a singleton — the resource opens directly into the single record instead of a list. Perfect for settings tables.
COMMENT ON TABLE blog.blog_settings IS '{
"icon": "BookOpen",
"name": "Blog Settings",
"singleton": true
}';inline_form
When true, the table renders as an editable section on its parent record's detail page instead of a top-level resource. Combine with "display": "none" to hide the table from the schema sidebar.
COMMENT ON TABLE store.order_items IS '{
"icon": "Receipt",
"display": "none",
"inline_form": true
}';The parent table is detected automatically via the foreign-key relationship.
query
Default sorting, filtering, joins, and column selection applied when the resource first loads. See Query Configuration for the full reference.
COMMENT ON TABLE desk.tasks IS '{
"query": {
"sort": [{"id": "created_at", "desc": true}],
"join": [
{"table": "users", "on": "user_id", "columns": ["name", "email"]}
]
}
}';views and primary_view
views declares the alternate views (Kanban, Calendar, Gallery, List, Tree). primary_view is the id of the view to load by default. See Views for the per-view configuration reference.
COMMENT ON TABLE desk.projects IS '{
"primary_view": "kanban",
"views": [
{"id": "kanban", "name": "By Status", "type": "kanban",
"group": "status", "title": "title", "badge": "priority"},
{"id": "calendar", "name": "Timeline", "type": "calendar",
"title": "title", "start_date": "start_date", "end_date": "end_date"}
]
}';If primary_view is omitted, the default sheet view is shown.
fields.sections
Group fields into named sections on the detail / create / edit page. Sections control both layout and what fields are visible per form mode.
type FieldSection = {
id: string
title: string
description?: string
icon?: string
collapsible?: boolean
fields: string[] | Partial<Record<"create" | "update" | "read", string[]>>
}COMMENT ON TABLE desk.tasks IS '{
"fields": {
"sections": [
{"id": "summary", "title": "Summary",
"fields": ["title", "description", "cover"]},
{"id": "schedule", "title": "Schedule",
"fields": ["status", "priority", "assignee_id", "due_date"]},
{"id": "organization", "title": "Organization",
"fields": ["project_id", "tags", "is_important"]},
{"id": "extras", "title": "Attachments & notes", "collapsible": true,
"description": "Files, color tag, and free-form notes",
"fields": ["attachments", "color", "notes"]}
]
}
}';Mode-specific fields
Pass an object instead of an array to show different fields per mode:
"fields": {
"create": ["title", "status", "assignee_id"],
"update": ["title", "status", "assignee_id", "due_date", "completed_at"],
"read": ["title", "status", "assignee_id", "due_date", "completed_at", "created_at"]
}Any field not listed in any section is hidden from the form.
filter_presets
Pre-canned filter sets exposed as one-click chips above the table.
type FilterPreset = {
id: string
name: string
description?: string
icon?: string
filters: {
id: string
value: string | string[]
operator: string // valid values depend on the column's type — see Query Configuration
}[]
}COMMENT ON TABLE desk.tasks IS '{
"filter_presets": [
{"id": "open", "name": "Open (not completed)",
"filters": [{"id": "status", "value": ["pending", "in_progress"], "operator": "in"}]},
{"id": "critical", "name": "Critical & High",
"filters": [{"id": "priority", "value": ["critical", "high"], "operator": "in"}]},
{"id": "important", "name": "Important",
"filters": [{"id": "is_important", "value": "true", "operator": "is"}]}
]
}';fields.metadata
Override the columns considered "metadata" (hidden from regular forms but shown in audit / system sections). Defaults to:
["deleted_at", "created_at", "updated_at", "created_by", "updated_by"]COMMENT ON TABLE store.products IS '{
"fields": {
"metadata": ["created_at", "updated_at", "created_by"]
}
}';fields.quick_create and fields.duplicated
quick_create lists the fields shown in the abbreviated "quick create" form (e.g. the inline creator opened from a lookup or a kanban column's "+" button) — everything else is left to its default value and can be filled in later. duplicated lists the fields carried over when a user duplicates a record (fields left out reset to their defaults, e.g. status or created_at).
COMMENT ON TABLE desk.tasks IS '{
"fields": {
"quick_create": ["title", "assignee_id", "due_date"],
"duplicated": ["title", "description", "priority", "project_id", "assignee_id"]
}
}';fields.behavior
Conditionally show, require, or lock fields based on the values of other fields in the same form. Keyed by field name; each entry accepts visible / required / read_only, each an array of FieldCondition ({ id, operator, value }) that must all match.
type FieldCondition = {
id: string // the other field to check
operator: string // eq, neq, lt, lte, gt, gte, like, ilike, is, in, not.ilike, not.is, not.in
value: string | string[]
}
type FieldBehavior = {
visible?: FieldCondition[]
required?: FieldCondition[]
read_only?: FieldCondition[]
}COMMENT ON TABLE desk.tasks IS '{
"fields": {
"behavior": {
"due_date": {
"required": [{"id": "status", "operator": "neq", "value": "backlog"}]
},
"completed_at": {
"visible": [{"id": "status", "operator": "eq", "value": "completed"}],
"read_only": [{"id": "status", "operator": "neq", "value": "completed"}]
},
"escalation_reason": {
"visible": [{"id": "priority", "operator": "in", "value": ["high", "critical"]}]
}
}
}
}';In this example: due_date becomes required once a task leaves backlog; completed_at only shows up (and is only editable) once status is completed; escalation_reason only shows up for high/critical priority tasks — the in operator here is the new capability, not just equality checks.
fields.lookups
Auto-fill and filter dropdown options based on a related record picked in another field. Keyed by the local field that triggers the lookup (usually a foreign key select).
type LookupFillRule = { target: string; source: string } // local field ← lookup column
type LookupFilterRule = { on: string; column: string } // local field → lookup column filter
type LookupConfig = {
fill?: LookupFillRule[]
filter?: LookupFilterRule[]
}-- Selecting a customer auto-fills their default shipping address,
-- and the product list is filtered to the selected warehouse's stock.
COMMENT ON TABLE store.orders IS '{
"fields": {
"lookups": {
"customer_id": {
"fill": [
{"target": "shipping_address", "source": "default_address"},
{"target": "shipping_city", "source": "default_city"}
]
},
"product_id": {
"filter": [
{"on": "warehouse_id", "column": "warehouse_id"}
]
}
}
}
}';Here, picking a customer_id copies default_address/default_city from the looked-up customer into shipping_address/shipping_city on the form. Picking a warehouse_id restricts the product_id dropdown to products whose warehouse_id column matches.
The lookup target table must be in the same schema as the table declaring the lookup — this is the same PostgREST cross-schema limitation described in Cross-Schema Joins. If customer_id pointed at a table in another schema (most commonly supasheet.users), create a same-named replica view with security_invoker = true in your own schema first, then point the lookup at that view. The foreign key constraint itself must keep referencing the real table (REFERENCES supasheet.users(id)) — a foreign key can never target a view, replica or not, so this part doesn't change.
tabs
Filters which related-table tabs appear on the record detail page. By default — when tabs is omitted — every one-to-one, one-to-many, and many-to-many relationship Supasheet detects is shown as its own tab alongside the main "Detail" tab. Set tabs to a list of relationship names to show only those and hide the rest:
COMMENT ON TABLE desk.tasks IS '{
"tabs": ["comments", "attachments"]
}';Each string must match the relationship's name/embed key (the foreign table's PostgREST embed name), not a column or arbitrary label. The main "Detail" tab is always shown regardless of this setting.
tabs has nothing to do with the Audit Log or Comments features — those are separate, permission-gated pages (schema.table:audit / schema.table:comment) linked from the record's actions menu, not tabs controlled by this field. See Audit Logs and Comments.
Column-level Metadata
Each column's PostgreSQL comment carries its own JSON object that the cell renderer and form field consult.
Base ColumnMetadata
type ColumnMetadata = {
name?: string // Override the column label
description?: string // Helper text shown under inputs
icon?: string // Lucide icon
}COMMENT ON COLUMN store.products.sku IS '{
"name": "Product SKU",
"description": "Unique stock-keeping unit",
"icon": "Barcode"
}';name isn't just a form label — it now drives the column header everywhere the column appears: the data table, report tables, foreign-key display columns, and CSV export headers. Set it once here and it's consistent across the whole app.
Enum columns
Style enum / text-with-check values with icons and badge variants. Set progress: true to render a progress bar instead.
type EnumColumnMetadata = ColumnMetadata & {
progress?: boolean
enums?: {
[value: string]: {
icon?: string
variant: "default" | "secondary" | "success" | "warning" | "destructive" | "info"
}
}
}COMMENT ON COLUMN desk.tasks.status IS '{
"enums": {
"pending": {"variant": "secondary", "icon": "Clock"},
"in_progress": {"variant": "info", "icon": "Loader"},
"completed": {"variant": "success", "icon": "Check"},
"archived": {"variant": "default", "icon": "Archive"}
}
}';FILE columns
type FileColumnMetadata = ColumnMetadata & {
accept?: string // MIME types or extensions accepted by the picker
maxFiles?: number // Maximum file count (FILE / array)
maxSize?: number // Maximum size in bytes
}COMMENT ON COLUMN desk.tasks.cover IS '{"accept": "image/*"}';
COMMENT ON COLUMN desk.tasks.attachments IS '{"accept": "*", "maxFiles": 999}';AVATAR columns
type AvatarColumnMetadata = ColumnMetadata & {
maxSize?: number
}Complete Example
CREATE TABLE desk.tasks (
id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
title TEXT NOT NULL,
description RICH_TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'in_progress', 'completed', 'archived')),
priority TEXT NOT NULL DEFAULT 'medium'
CHECK (priority IN ('low', 'medium', 'high', 'critical')),
cover FILE,
attachments FILE,
assignee_id UUID REFERENCES supasheet.users(id),
project_id UUID,
due_date DATE,
completed_at TIMESTAMPTZ,
is_important BOOLEAN DEFAULT false,
completion PERCENTAGE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
COMMENT ON TABLE desk.tasks IS $$
{
"icon": "ListTodo",
"display": "block",
"query": {
"sort": [{"id": "created_at", "desc": true}],
"join": [
{"table": "users", "on": "assignee_id", "columns": ["name", "email"]}
]
},
"primary_view": "status",
"views": [
{"id": "status", "name": "Tasks By Status", "type": "kanban",
"group": "status", "title": "title", "description": "description",
"date": "created_at", "badge": "priority"},
{"id": "calendar", "name": "Task Schedule", "type": "calendar",
"title": "title", "start_date": "due_date", "badge": "status"}
],
"filter_presets": [
{"id": "open", "name": "Open (not completed)",
"filters": [{"id": "status", "value": ["pending", "in_progress"], "operator": "in"}]},
{"id": "important", "name": "Important",
"filters": [{"id": "is_important", "value": "true", "operator": "is"}]}
],
"fields": {
"quick_create": ["title", "assignee_id", "due_date"],
"duplicated": ["title", "description", "priority", "project_id"],
"behavior": {
"completed_at": {
"visible": [{"id": "status", "operator": "eq", "value": "completed"}]
}
},
"sections": [
{"id": "summary", "title": "Summary",
"fields": ["title", "description", "cover"]},
{"id": "schedule", "title": "Schedule",
"fields": ["status", "priority", "assignee_id", "due_date", "completed_at"]},
{"id": "progress", "title": "Progress",
"fields": ["completion", "is_important"]},
{"id": "extras", "title": "Attachments & notes", "collapsible": true,
"fields": ["attachments", "notes"]}
]
},
"tabs": ["tasks_history", "timesheets"]
}
$$;
COMMENT ON COLUMN desk.tasks.status IS '{
"enums": {
"pending": {"variant": "secondary", "icon": "Clock"},
"in_progress": {"variant": "info", "icon": "Loader"},
"completed": {"variant": "success", "icon": "Check"},
"archived": {"variant": "default", "icon": "Archive"}
}
}';
COMMENT ON COLUMN desk.tasks.priority IS '{
"enums": {
"low": {"variant": "default", "icon": "Minus"},
"medium": {"variant": "info", "icon": "Equal"},
"high": {"variant": "warning", "icon": "TrendingUp"},
"critical": {"variant": "destructive", "icon": "AlertTriangle"}
}
}';
COMMENT ON COLUMN desk.tasks.cover IS '{"accept": "image/*"}';
COMMENT ON COLUMN desk.tasks.attachments IS '{"accept": "*", "maxFiles": 999}';
COMMENT ON COLUMN desk.tasks.completion IS '{"progress": true}';Because Supabase comments only allow single-line strings, use dollar-quoted strings ($$ ... $$) for multi-line JSON like the example above.
Next Steps
- Views — Six view types and their configuration
- Query Configuration — Sort, filter, join, and select
- Data Types — Custom domains and the
FILEcomposite type - Comments — Per-record discussions
- Audit Logs — Wire up the audit trigger