Supasheet.
Resource

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
  collapsible_group?: string        // sidebar collapsible section this resource is grouped under
  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[]
    behavior?: Record<string, FieldBehavior>   // keyed by field name
    lookups?: Record<string, LookupConfig>     // keyed by field name
  }
  detail?: {                         // table-only — views have no detail page
    header?: { title?: string; badges?: string[] }
    tabs?: string[]
    timelines?: string[]
  }
}

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.

ValueBehaviour
"block" (default)Listed in the schema's resource sidebar
"none"Hidden from the sidebar — still reachable by direct URL, or automatically as a tab on a related parent's detail page (via foreign key, independent of 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

Any table related to a parent via a foreign key automatically renders as a tab on the parent's detail page — no configuration needed. Rows inside a detail-page tab always open in an inline slide-over ("sheet") instead of navigating away, regardless of inline_form.

inline_form only affects the table's own top-level views (its table/grid/kanban/calendar/gallery/list/tree/gantt pages, if it's ever browsed directly rather than through a parent's tab). When true, opening or creating a record there also uses the sheet overlay instead of the full detail page:

COMMENT ON TABLE store.order_items IS '{
  "icon": "Receipt",
  "display": "none",
  "inline_form": true
}';

"display": "none" is a separate, independent setting — it hides the table from the sidebar. The two are commonly combined for junction/line-item tables (hide it from top-level navigation, and keep interactions lightweight if it's ever reached directly), but neither one implies the other.


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

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.

COMMENT ON TABLE desk.tasks IS '{
  "fields": {
    "quick_create": ["title", "assignee_id", "due_date"]
  }
}';

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

// source_column is always a field on this table's own form;
// target_column is always a column on the lookup target table.
type LookupFillRule   = { source_column: string; target_column: string }
type LookupFilterRule = { source_column: string; target_column: string }

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": [
          {"source_column": "shipping_address", "target_column": "default_address"},
          {"source_column": "shipping_city",    "target_column": "default_city"}
        ]
      },
      "product_id": {
        "filter": [
          {"source_column": "warehouse_id", "target_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.


detail

Table-only — views have no detail page. Configures the detail page's heading and which related-table tabs it shows.

COMMENT ON TABLE desk.tasks IS '{
  "detail": {
    "header": {"title": "title", "badges": ["status", "priority"]},
    "tabs": ["comments", "attachments"],
    "timelines": ["task_events"]
  }
}';

header.title names a column whose value renders as the page heading (falls back to the record's primary key when empty). header.badges names columns rendered as badges next to the heading.

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. 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, grant-gated pages (audit via a fixed SELECT grant on supasheet.audit_logs; comments via the resource's own SELECT grant) linked from the record's actions menu, not tabs controlled by this field. See Audit Logs and Comments. Custom, function-backed buttons in that same actions menu are configured separately — see Row Actions.


detail.timelines — activity feed tabs

timelines renders specific related-table tabs as a vertical activity feed instead of a data table — the same visual pattern as an audit log, but for your own domain events. A table listed in timelines is still just a normal FK-related child table; timelines only changes how that one tab is displayed. A name can appear in tabs or timelines, not both — if it's in timelines, that wins.

COMMENT ON TABLE desk.tasks IS '{
  "detail": {"timelines": ["task_events"]}
}';

COMMENT ON TABLE desk.task_events IS '{
  "icon": "History",
  "display": "none",
  "query": {
    "sort": [{"id": "occurred_at", "desc": true}],
    "join": [{"table": "users", "on": "actor_id", "alias": "actor", "columns": ["name", "avatar"]}]
  }
}';

The child table needs a specific column shape for the feed to render:

columnrequiredrendered as
occurred_atyesa timestamptz. The feed is always sorted by this column, descending — fixed, not configurable.
titleyesthe event's headline text.
event_typenoa badge, if the column carries the usual enum column metadata (values map with variant/icon).
metadatanoraw JSON, rendered inline (e.g. {"from": "todo", "to": "done"}).
actor_idnorendered as "by name" — requires a query.join entry aliased exactly "actor", pointing at the users table with name included in columns.

Timeline tables are typically system-populated activity logs, not something users fill in directly: "display": "none" (hidden from the sidebar, never browsable on its own), a trigger that inserts rows on changes to the parent, and grants limited to SELECT only (no INSERT/UPDATE/DELETE) so the feed is read-only. The timeline itself is always a read-only feed — it renders no create button, even where INSERT is granted. Add rows from a trigger, or from the child table's own resource route.

See demo.tasks / demo.task_events in supabase/demo.sql for a complete working example, including the trigger (demo.trg_tasks_log_event) that logs status, assignee, and priority changes onto the timeline.


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
  aggregate?: "none" | "count" | "count_distinct" | "sum" | "avg" | "min" | "max"
}
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.

Default aggregate (aggregate)

aggregate sets the default aggregate function shown in the sheet view's table footer for this column. It defaults to "none"; users can always change it per column at runtime from the footer selector.

COMMENT ON COLUMN store.invoices.total    IS '{"aggregate": "sum"}';
COMMENT ON COLUMN store.invoices.due_date IS '{"aggregate": "max"}';
COMMENT ON COLUMN store.orders.customer_id IS '{"aggregate": "count_distinct"}';

Not every function applies to every column type:

  • count / count_distinct — any column (non-empty / unique non-empty values)
  • min / max — numeric-backed columns (numbers, MONEY, PERCENTAGE, DURATION, RATING) plus date, time, and timestamp columns
  • sum / avg — numeric-backed columns only

Aggregates are computed client-side over the rows currently loaded in the table (the current page), not server-side over the whole table. See Footer Aggregates for how the footer behaves.

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
  icon_only?: boolean  // show only the icon (in a tooltip), not the text label
  values?: {
    [value: string]: {
      icon?: string
      variant: "default" | "secondary" | "success" | "warning" | "destructive" | "info"
    }
  }
}
COMMENT ON COLUMN desk.tasks.status IS '{
  "values": {
    "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
  max_files?: number   // Maximum file count (FILE / array)
  max_size?: number    // Maximum size in bytes
}
COMMENT ON COLUMN desk.tasks.cover       IS '{"accept": "image/*"}';
COMMENT ON COLUMN desk.tasks.attachments IS '{"accept": "*", "max_files": 999}';

AVATAR columns

type AvatarColumnMetadata = ColumnMetadata & {
  max_size?: 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"],
    "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"]}
    ]
  },
  "detail": {
    "tabs": ["tasks_history", "timesheets"]
  }
}
$$;

COMMENT ON COLUMN desk.tasks.status IS '{
  "values": {
    "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 '{
  "values": {
    "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": "*", "max_files": 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
  • Row Actions — Custom operations that reuse this same FieldCondition shape for visibility
  • Custom Forms — Reuses FieldSection/FieldBehavior/LookupConfig to render a SQL function's arguments as a form
  • Data Types — Custom domains and the FILE composite type
  • Comments — Per-record discussions
  • Audit Logs — Wire up the audit trigger

On this page