resource.json
resource.json
Each resource is described by a resource.json file in its own directory under resources/. This one file drives the API endpoints, validation wiring, table columns, form fields, and filters.
A complete example
{
"name": "book",
"route": "books",
"model": "book",
"tag": "Book",
"title": "Books",
"database": "maindb",
"sidebar": {
"position": 1,
"label": "Books",
"group": "catalogue"
},
"operations": {
"findAll": true,
"findOne": true,
"create": true,
"update": true,
"delete": true
},
"columns": {
"id": {
"idField": true,
"hiddenInTable": true,
"hiddenInForm": true,
"hiddenInView": true
},
"title": {
"searchable": true,
"sortable": true,
"defaultSort": true
},
"summary": {
"hiddenInTable": true,
"fieldInput": {
"type": "textarea"
}
},
"created_at": {
"hiddenInTable": true,
"hiddenInForm": true,
"createable": false,
"updateable": false
}
}
}Top-level fields
| Field | Type | Description |
|---|---|---|
$schema | string | URL of the generated JSON Schema, for editor autocomplete β see Versioning |
schemaVersion | number | resource.json shape version; auto-migrated in dev β see Versioning |
draft | boolean | When true, kept in the repo but not loaded/served β see Draft resources |
kind | 'prisma' | 'custom' | Where the data comes from. Default prisma. custom means no Prisma model and no schema.ts β you implement data access in repository.ts, see Custom resources |
name | string | Unique resource name (used as form id in the frontend) |
id | string | Internal id; route falls back to it when omitted, and it falls back to name. Rarely set by hand |
route | string | URL segment for the generated endpoints |
model | string | Prisma model name. Required when kind is prisma; must be omitted when kind is custom |
tag | string | OpenAPI tag (default Crouton) |
title | string | Display title in the UI |
table | string | Database table (when it differs from the model) |
database | string | Name of the data source to use |
parent | object | Mount this resource under a parent route: { "route": "groups", "param": "groupId" }. Custom resources only β see Custom resources |
sidebar | object | Sidebar visibility, ordering, and grouping β see Sidebar |
display | object | mode ('page' | 'modal', default 'modal') and customComponent, see Display |
operations | object | Enable findAll, findOne, create, update, patch, delete |
security | object | Per-resource authorization guard(s) β see Security |
columns | map or array | Column definitions, see below |
calculatedColumns | array | SQL-computed read-only columns, see below |
actions | array | Row-level actions |
tableActions | array | Table-level actions |
modalSize | 'xs' | 'sm' | 'lg' | 'xl' | Size of the create/edit modal (default sm) |
include | array | Relations to eagerly load, see below |
Operations
All CRUD operations default to enabled β omitting the operations object, or a specific key, still exposes that endpoint. Set a key to false to disable it.
| Operation | HTTP Method | Route | Description |
|---|---|---|---|
findAll | GET | / | List all records (paginated) |
findOne | GET | /:id | Get one record by id |
create | POST | / | Create a new record |
update | PUT | /:id | Full replace β all fields required per schema |
patch | PATCH | /:id | Partial update β fields optional (auto .partial()) |
delete | DELETE | /:id | Delete a record |
Each key accepts three forms:
| Value | Meaning |
|---|---|
true (default) | Crouton registers and serves the endpoint |
false | Endpoint disabled β not registered |
{ "uri": "/path/{id}" } | External β client calls the given route directly; crouton registers nothing |
External operations
Declare an operation as { "uri": "..." } to have the frontend call an external service directly for that operation, with crouton registering nothing internally:
{
"operations": {
"findAll": true,
"findOne": true,
"create": false,
"update": false,
"patch": false,
"delete": { "uri": "/annotation/{id}" }
}
}{id} and other {param} placeholders work as usual. Use {env.VAR} to inject an environment variable at compile time. See External operations for the full reference.
PUT vs PATCH
Both update and patch default to true. They share the same Prisma update() call β the difference is in validation:
update(PUT) uses the full update schema. All required fields must be present.patch(PATCH) usesupdateSchema.partial()by default (all fields optional). You can override it by providing an explicitpatchschema in the resource definition.
In the frontend, manual save sends a PUT (full replace) and autosave sends a PATCH (partial update).
Hooks receive op: 'update' for PUT calls and op: 'patch' for PATCH calls, so beforeWrite/afterWrite hooks can distinguish between the two.
Columns
columns accepts either form:
Map (shown in the example above) β keyed by column id; the key becomes the column's
id.Array β each entry needs an explicit
id:{ "columns": [ { "id": "id", "idField": true, "hiddenInForm": true }, { "id": "title", "searchable": true, "sortable": true } ] }
Both forms support the same options:
| Option | Description |
|---|---|
idField | Marks the id column |
type | Data type β a shorthand ("string", "integer", "boolean", "date", β¦) or a JSON Schema fragment for nested shapes. Optional on a prisma resource (derived from the Zod model); required on every column of a kind: "custom" resource, see Custom resources |
label / hideLabel | Display label, or hide it |
hiddenInTable / hiddenInForm / hiddenInView | Visibility per context. On a oneToMany relation column, hiddenInTable also drops it from the _count subquery findAll issues β that count only ever fed a table cell |
sortable / defaultSort | Sorting; sortId overrides the sort column |
searchable | Marks this column as a ?q= search target. Multiple searchable columns produce an OR search. For manyToOne relation columns the search automatically resolves to the related resource's display field (e.g. authorId β author.name). |
filterable | Gets a filter control |
createable / updateable | Whether the field is written on create/update |
required | Whether the form demands a value. Overrides the Zod model in either direction on a prisma resource (true adds, false removes, omitted defers to the model); the only way to mark a field required on a kind: "custom" resource. Ignored on the id column and on fields that are neither createable nor updateable |
showWhen / hideWhen / disabledWhen | Conditional display: { "field": "...", "eq"/"neq"/"exists"/"notExists": ... } |
displayKey | Nested field to display (e.g. author.name) |
showInLookup | Sets the display label when this resource appears as an option in another resource's autocomplete. Does not affect ?q= search β use searchable: true for that. |
column | Source column name when it differs from the key/id (defaults to id) |
enum | Name of a shared enum in crouton.enums.json; its { value, label }[] is injected into fieldInput.options.values |
extend | Path to another resource.json whose columns are expanded as virtual sub-columns under this key (with a per-sub-column columns override map) |
fieldInput | Form control configuration, see below |
fieldView | Optional per-context override for the read-only view, see below |
fieldTable | Optional per-context override for the table cell, see below |
Free-text search (?q=)
Mark columns with searchable: true to include them in free-text search via ?q= on the list endpoint.
{
"title": { "searchable": true },
"authorId": {
"searchable": true,
"fieldInput": { "type": "autocomplete", "relationType": "manyToOne", "resource": "./author.resource" }
}
}- Multiple
searchablecolumns are ORed together:?q=tolkienmatches books whosetitlecontains "tolkien" OR whose author name contains "tolkien". manyToOnerelation columns (e.g.authorId) automatically resolve to the related resource's first visible display field (e.g.author.name), so you never search against a raw foreign key string.showInLookupis a separate concern β it controls the label shown when this resource appears as an autocomplete option somewhere else. Do not useshowInLookupto drive?q=search; usesearchableinstead.
Nested object and array columns
A column's type may be a full JSON Schema fragment, which is how you describe a value that is not a scalar:
{
"metadata": {
"displayKey": "name",
"type": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
},
"tags": {
"type": {
"type": "array",
"items": {
"type": "string"
}
}
}
}An object column renders as a nested group of controls in the form, and β when it has a displayKey β as a single-value record cell in the table.
Field inputs
fieldInput selects and configures the form control:
{
"column": "summary",
"fieldInput": {
"type": "textarea",
"position": 2,
"options": {
"colspan": 4
}
}
}defaultValue β pre-filling the create form
Set fieldInput.defaultValue to pre-fill a field when the user opens a blank create form. The value is applied at form-open time, not persisted until the user saves.
{
"status": {
"fieldInput": {
"type": "select",
"defaultValue": "draft"
}
}
}Dynamic default tokens
In addition to static values, crouton supports a small set of reserved token strings that are evaluated fresh at form-open time (not at build time, so they're always current):
| Token | Resolves to |
|---|---|
"$now" | Current datetime as an ISO 8601 string |
"$today" | Current date as an ISO 8601 date string ("2025-03-14") |
"$user" | Current user object (requires defaults in CroutonPlugin config) |
Example β default a date field to today:
{
"date": {
"fieldInput": {
"type": "date",
"defaultValue": "$today"
}
}
}Example β default a datetime field to now:
{
"createdAt": {
"fieldInput": {
"defaultValue": "$now"
}
}
}Using $user requires passing the current user when setting up the plugin:
app.use(
CroutonPlugin(api, {
defaults: {
'$user': { id: currentUser.id, name: currentUser.name },
},
}),
);{
"createdBy": {
"fieldInput": {
"defaultValue": "$user"
}
}
}Setting defaults at runtime (e.g. after a backend request) using useCrouton().setDefault():
const crouton = useCrouton();
onMounted(async () => {
const user = await fetchCurrentUser();
crouton.setDefault('$user', user);
});Any token in the defaults map can be updated this way β the new value is used the next time a create form opens.
Note: Token resolution only affects the create form pre-fill. It has no effect on existing records loaded for editing, and it does not run server-side β use
beforeWritehooks for server-side defaults.
Field variants β fieldView / fieldTable
By default a single fieldInput drives the form, the read-only view, and the table cell. A column may additionally declare fieldView and/or fieldTable to render differently per context. Both are optional and have the exact same shape as fieldInput (every key optional), so a variant overrides only what it needs.
The config that drives each context is resolved through a fallback chain:
- form (and filter) β
fieldInput - view β
fieldView, falling back tofieldInput - table β
fieldTable, falling back tofieldView, thenfieldInput
Resolution is a deep merge, one level into options: a variant layers over the level below it, so you can tweak a single options key without repeating format, resource, relationType, etc. A value of null in a variant deletes that inherited key.
{
"column": "author",
"label": "Author",
"displayKey": "name",
"fieldInput": {
"format": "relation",
"resource": "./author/resource.json",
"options": {
"display": "autocomplete",
"displayKey": "name"
}
},
"fieldView": {
"options": {
"display": "link"
}
},
"fieldTable": {
"options": {
"displayKey": "shortName"
}
}
}Here the edit form renders an autocomplete, the read-only view renders a link (inheriting displayKey: "name" from fieldInput), and the table cell renders a link (inherited from fieldView) keyed by shortName.
Resolution runs once inside the JSON transformer at resource-read time, after relation/URI enrichment, so the resolved fieldView/fieldTable inherit the injected relation options. Columns without any variant produce output identical to today's single-fieldInput behaviour. position may also be overridden per variant, giving free per-context ordering.
Relations (sub-resources)
A relation is configured as a column with fieldInput.format: "relation" pointing to a sub-resource file in the same directory:
{
"column": "author_id",
"fieldInput": {
"format": "relation",
"relationType": "manyToOne",
"resource": "./resource.author"
}
}resource.author.json then describes the related resource (its columns, operations, and lookup display). Supported relationType values: oneToOne, manyToOne, oneToMany, manyToMany.
The frontend picks the matching control automatically β an autocomplete for manyToOne, an editable nested table for oneToMany, and so on.
Foreign key
By default, the foreign key on the child model is derived as ${parentModel}Id (camelCase), matching the standard Prisma convention. If the FK field in your Prisma schema uses a different name, set foreignKey on fieldInput:
{
"column": "section",
"fieldInput": {
"format": "relation",
"relationType": "oneToMany",
"resource": "../section/resource.json",
"foreignKey": "work_id"
}
}When omitted, a parent model named work produces foreignKey: "workId".
Relation options
fieldInput.options accepts the following fields for relation columns:
| Option | Type | Description |
|---|---|---|
displayKey | string | Field used as the label in the relation control (e.g. "title") |
direction | 'row' | 'column' | CSS flex direction for the relation button layout |
sort | string | Field to sort related records by, e.g. "title" or "author.name" |
sortDir | 'asc' | 'desc' | Sort direction (default "asc") |
Sorting related records
Add sort (and optionally sortDir) to fieldInput.options to control the order of related records. This affects two places:
- Backend includes β when the parent record is fetched, the included relation records are returned in the specified order (Prisma
orderByinside theincludeclause). - Frontend picker β when the relation control fetches its option list (autocomplete / dropdown), the same sort params are forwarded as query parameters.
{
"id": "sections",
"label": "Sections",
"hiddenInForm": true,
"fieldInput": {
"type": "relation",
"resource": "./section/resource.json",
"options": {
"sort": "title",
"sortDir": "asc",
"displayKey": "title"
}
}
}Dotted paths work too β "sort": "author.name" sorts by a nested field.
Calculated columns
Read-only columns computed in SQL at query time. Use main as the alias for the resource's own table:
{
"calculatedColumns": [
{
"id": "chapter_count",
"alias": "chapter_count",
"label": "Chapters",
"type": "number",
"sqlExpression": "(SELECT count(*) FROM chapter c WHERE c.book_id = main.id)"
}
]
}Includes
Eagerly load relations with the list/detail queries:
{
"include": [
"author",
{
"relation": "chapters",
"include": [
"sections"
]
}
]
}When a relation column has a sort option (see Sorting related records), the loader automatically injects the corresponding orderBy into the include clause β no manual configuration needed.
Display
The display object controls how the create/edit form is presented. Both fields are optional.
| Field | Type | Description |
|---|---|---|
mode | 'page' | 'modal' | Render the form as a full page or a modal. Default 'modal'. |
customComponent | string | null | Name of a custom Vue component to render instead of the generated form. Default null. |
{
"display": {
"mode": "page"
}
}Page mode
When mode is 'page', the form renders inline (replacing the table) instead of opening a modal. This happens automatically β no inline: true option needed in useResources. The table is hidden while the form is open.
Page mode uses CroutonForm as its component, which enables autosave by default when editing.
Custom components
Register a custom Vue component in your app setup and reference it by name in resource.json:
{
"display": {
"mode": "page",
"customComponent": "WorkEditor"
}
}Register the component when creating the Crouton plugin:
import { CroutonPlugin, customComponentIs } from '@ghentcdh/crouton-vue';
import WorkEditor from './components/WorkEditor.vue';
app.use(
CroutonPlugin(api, {
customComponents: [
{
tester: customComponentIs('WorkEditor', 1),
renderer: WorkEditor,
},
],
}),
);The custom component receives the form configuration as props and is rendered inside the form wrapper. See the demo component for a working example.
Custom component fields
For field-level customisation (rendering a single field with a custom component rather than replacing the entire form), add customComponent to fieldInput.options. This works with any format β including relation:
{
"column": "section",
"label": "Sections",
"fieldInput": {
"format": "relation",
"resource": "../section/resource.json",
"options": {
"customComponent": "work-sections",
"sortDir": "section_number",
"displayKey": "title"
}
}
}The component is resolved from the same customComponents registry:
app.use(
CroutonPlugin(api, {
customComponents: [
{ tester: customComponentIs('work-sections', 1), renderer: markRaw(WorkSectionsEditor) },
],
}),
);When customComponent is present, it takes priority over the default renderer for that format. The custom field component receives wrapper, value (v-model), appliedOptions, schema, and uischema as props. All fieldInput.options are available via appliedOptions.
Table cells
Add customComponent to tableView.options (or fieldTable.options) to override the table cell renderer:
{
"column": "section_number",
"fieldInput": {
"type": "number",
"position": 0
},
"tableView": {
"options": {
"customComponent": "moveUpDown"
}
}
}The custom cell component receives data (full row object), value (cell value), column, and options as props.
Sidebar
The sidebar object controls how (and whether) a resource appears in the admin navigation. All fields are optional.
| Field | Type | Description |
|---|---|---|
hide | boolean | Exclude this resource from the sidebar entirely (default false) |
position | number | Order within its group or at the top level. Lower values come first; resources without a position are sorted alphabetically after positioned ones. |
label | string | Override the sidebar label. Defaults to the resource title. |
group | string | Slug of a group defined in sidebarGroups in crouton.json. Resources with the same group are nested under a shared collapsible section. |
Group labels and ordering are configured centrally in crouton.json, not per resource. See Sidebar groups below.
Example β grouping metadata resources
// crouton.json
{
"sidebarGroups": {
"metadata": {
"label": "Metadata",
"position": 10
}
}
}// author.resource.json
{
"name": "author",
"sidebar": {
"label": "Authors",
"group": "metadata",
"position": 1
}
}// genre.resource.json
{
"name": "genre",
"sidebar": {
"label": "Genres",
"group": "metadata",
"position": 2
}
}This renders as:
Texts
βΎ Metadata
Authors
GenresSidebar groups
Groups are defined in crouton.json under sidebarGroups, keyed by slug:
{
"sidebarGroups": {
"metadata": {
"label": "Metadata",
"position": 10
},
"admin": {
"label": "Admin",
"position": 20
}
}
}| Field | Type | Description |
|---|---|---|
label | string | Heading shown in the sidebar. Defaults to a title-cased version of the slug. |
position | number | Order of this group among top-level sidebar items. |
Keeping groups in the config file instead of individual resource files ensures label and ordering are defined exactly once and can't drift out of sync.
Escape hatch: resource.ts
When JSON is not expressive enough, replace resource.json with a resource.ts that default-exports a full ResourceConfig object. The loader falls back to it automatically when no resource.json is present.