Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Saved views plugin

Saved views plugin @orthacms/content-server

Saving a filtered records list under a name, and sharing it — routes and payload.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

A saved view is the named slice of a collection an editor returns to: filter, sort, page size and columns, Personal or Shared.

It is not a separate package. The server half is a second ServerPlugin entry exported from @orthacms/content-server; the admin half is part of @orthacms/content-admin. (A plugin carries one migration descriptor, and content’s already points at your generated tables.)

Install

Nothing to install.

Register

// apps/server/src/plugins.ts
import { ContentPlugin, ContentViewsPlugin } from '@orthacms/content-server';

export function buildPlugins(config: OrthaCmsConfig): ServerPlugin[] {
    const content = ContentPlugin({ types: contentTypes, migrations: { /* … */ } });

    return [
        // DatabasePlugin, IdentityPlugin, WorkspacesPlugin, …
        content,
        ContentViewsPlugin({ content }) // after identity, workspaces and content
    ];
}
  • Must come after identity and workspaces: saved_views has foreign keys to users and workspaces, and migrations run in list order.
  • Takes content by value — it needs the registry to resolve content:<typeName>.
  • Its migrations are tracked in __drizzle_migrations_content_views.
  • The admin needs nothing extra: ContentPlugin() already draws the views as a row of tabs above the records toolbar — All records, each saved view, a View button, and Trash at the end on a collection with a trash. Each view’s menu (Rename, Duplicate, Copy link, your default, sharing, Delete) is on its own tab: click the selected tab again, right-click it, or press Shift+F10.

Configuration

None. No options, no environment variables.

Routes

Admin routes: session cookie and X-Workspace-Id — see calling admin routes. The whole surface requires content:read.

MethodPathNeedsReturns
GET/api/views?scope=content:<type>content:readSavedView[] — yours plus every shared one
POST/api/viewscontent:read, plus views:share to shareSavedView
PATCH/api/views/:idowner; views:share to shareSavedView
DELETE/api/views/:idowner204
PUT/api/views/:id/defaultcontent:read204 — your default for that list
DELETE/api/views/:id/defaultcontent:read204, idempotent
Example language
curl -X POST https://cms.example.com/api/views \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' \
-H 'Content-Type: application/json' \
-d '{
  "scope": "content:article",
  "name": "Drafts to review",
  "visibility": "workspace",
  "makeDefault": true,
  "payload": {
    "filter": "{\"and\":[{\"field\":\"status\",\"op\":\"eq\",\"value\":\"draft\"}]}",
    "sort": "-updatedAt",
    "pageSize": 25,
    "columns": ["title", "status", "updatedAt"],
    "extra": { "locale": "en" }
  }
}'
const filter = { and: [{ field: 'status', op: 'eq', value: 'draft' }] };

const view = await fetch('/api/views', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json', 'X-Workspace-Id': workspaceId },
  body: JSON.stringify({
      scope: 'content:article',
      name: 'Drafts to review',
      visibility: 'workspace',
      payload: {
          filter: JSON.stringify(filter), // a string, exactly as in ?filter=
          sort: '-updatedAt',
          columns: ['title', 'status', 'updatedAt']
      }
  })
}).then((r) => r.json());
{
    "id": "d9e0…",
    "scope": "content:article",
    "name": "Drafts to review",
    "visibility": "workspace",
    "ownerId": "8a0e…",
    "isOwn": true,
    "isDefault": true,
    "payload": {
        "filter": "{\"and\":[{\"field\":\"status\",\"op\":\"eq\",\"value\":\"draft\"}]}",
        "sort": "-updatedAt",
        "pageSize": 25,
        "columns": ["title", "status", "updatedAt"],
        "extra": { "locale": "en" }
    },
    "updatedAt": "2026-09-30T09:30:00.000Z"
}

PATCH takes only what changes — { "name": … }, { "visibility": "private" } or { "payload": … }.

The payload

It mirrors the records page’s URL parameters, so a view round-trips through the same code as a hand-edited link.

KeyHolds
filterThe raw ?filter= JSON string — see filtering
sortupdatedAt or -updatedAt style
pageSizeRows per page
columnsVisible column ids, in display order
extraSlot-owned list params (e.g. i18n’s locale), a flat string map
  • search and page are not stored: a view always opens on page 1 with no search.
  • extra keys come from the listParamKeys of RECORDS_TOOLBAR_SLOT items at runtime, so they are not typed.
  • The server checks the shape only. A view can outlive a field it mentions; the admin skips columns the type no longer has, and says how many.

Rules

  • visibility: private (default) or workspace. Sharing needs views:share (admin, contributor — not viewer).
  • Only the owner edits or deletes — 403 otherwise, even for an admin. The remedy is Save as new.
  • Defaults are per user (saved_view_defaults). Making a shared view your default does not change anyone else’s.
  • A view is a bookmark, not a grant. It replays through the ordinary list query with the reader’s own permissions and grants.
  • scope must be content:<typeName>. An unknown or ungranted type is the same 404.
  • Create, update and delete are audited as saved_view.created, saved_view.updated, saved_view.deleted. Choosing a default is not.

Limits

LimitValueOver it
Name length80400
Scope key length128400
Columns per view100400
Column id length128400
extra keys20400
extra key or value length255400
Views per user, per workspace and scope100409

A name is unique per owner within a workspace and scope — a duplicate is 409.