How it renders
Commerce Studio is a rendering engine. Understanding the pipeline is what makes the rest of this group obvious rather than arbitrary.
The pipeline
every installed app's cockpit.json
│
┌───────────┴───────────┐
│ merge + resolve │ group, position, id/parent anchors
└───────────┬───────────┘
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
sidebar live routes dashboard cards
(navigation[]) (views[]) (widgets[])
│
┌──────────────┼──────────────┐
▼ ▼ ▼
list renderer detail renderer form renderer
│ │ │
└──────────────┴──────────────┘
│
reads/writes the entity directly
— unless a view declares `write`,
which dispatches to your own capability
Three consequences follow, and they explain most of the design:
- There is no component to ship, because there is no place to put one. The renderers belong to the studio.
- You declare where things go, not how they look. Your app's screens and a dozen other apps' screens have to end up in one coherent sidebar, and no app is in a position to arrange that on its own.
- The manifest is read per request. Editing it and reloading is the whole development loop; there is no build step.
What the renderer handles
Everything in this list is the studio's job and none of it is yours:
| Concern | Handled by |
|---|---|
| Authentication and tenant context | The studio, from the operator's session |
| Reading the entity, pagination, total counts | The list renderer |
| Sorting, filter state, URL state | The list renderer |
| Loading, empty and error states | Every renderer |
| Form controls per field type, client-side validation | The form renderer |
| Optimistic concurrency, where declared | The detail and form renderers |
| Localised labels, per the operator's UI locale | Every renderer |
| Layout, spacing, typography, dark mode | The Studio design system |
That is why the renderers get all of it right in one place instead of in thirteen apps.
Where a view's data comes from
By default the renderer reads and writes the entity directly. That is the right default for an admin surface: a position column, a label, a boolean flag gain nothing from a round trip through your function that would write the same column back.
Declare write on a view when the database cannot do the job on its own — a contact that must also become a platform user, a stock movement that has to leave a ledger entry behind it:
"write": { "method": "POST", "endpoint": "/suppliers" }
That dispatches the save to your app's own capability at <gateway>/v1<endpoint>, with the operator's Bearer token, X-Revenexx-Tenant and X-Revenexx-Market. See Actions and writes.
The three view types, and only three
list detail form
There is no chart type, no board type, no custom type. A view declares one of the three and configures the renderer that goes with it.
Every shipped app declares the same four-view set per entity:
"views": [
{ "route": "/suppliers", "type": "list", "entity": "suppliers", "columns": [] },
{ "route": "/suppliers/:id", "type": "detail", "entity": "suppliers" },
{ "route": "/suppliers/new", "type": "form", "entity": "suppliers", "mode": "create" },
{ "route": "/suppliers/:id/edit", "type": "form", "entity": "suppliers", "mode": "edit" }
]
Two route conventions, and they differ
This trips people up, so it is worth stating plainly:
| Where | Placeholder | Example |
|---|---|---|
A cockpit.json route | :param | /suppliers/:id/edit |
A capability route in manifest.capabilities.json | {braces} | POST /suppliers/{id}/deactivate |
A view's route is a Cockpit route. An action's or a write's endpoint is your capability's path — and its :param placeholders are substituted from the route params and the record id before the request goes out.
Routes are mounted under the studio that hosts the app, which for these views is /commerce, so "/suppliers" becomes /commerce/suppliers in the browser. Declare the studio-relative path.
Composition across apps
The studio reads every installed app's manifest and merges the declarations. Sidebar entries from all apps are ordered together by group and position; every views entry becomes a live route; every widgets entry becomes available on the dashboard.
That is also why an app can attach a page under another app's heading rather than claiming its own top-level entry, using id and parent anchors — see Navigation.
When declaring is not enough
The renderers cover an admin surface, not every surface. Two honest boundaries:
- Filters have four shapes in production use. If an operator needs to slice data a way they cannot express, that is a capability answering a question, not a filter. (
GET /v1/products/gridandPOST /v1/prices/resolveexist for exactly that reason.) - A widget has two renderers.
EntityCountandEntityList. There is no chart widget.
Where the declarative surface runs out, the answer is a capability your function serves and an api action that calls it — not a component.
Where to go next
- Preview locally — see this happen on your machine in two minutes.
- cockpit.json reference — the top-level keys.
- Actions and writes — dispatching to your own code.
- Cockpit integration — the App Studio view of the same file.