---
title: "Resources"
description: "Bind Lucid models to schemas, tables, policies, and panel routes."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Resources

A resource is the one place a model becomes a panel: it binds the Lucid model,
claims a URL slug, and declares the schema and table behavior.

Resources are **instantiated per request**. Construction must be cheap and
state-free — an instance holds no request data, and the compiled artifacts
(descriptors, validators) are cached across requests rather than the instance
itself.

## The shape of a resource

```ts
import { BaseResource, F } from '@adonia/core'
import type { SchemaBuilder, SchemaComponent } from '@adonia/core'
import Post from '#models/post'

export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'
  static override labels = { singular: 'Post', plural: 'Posts' }

  schema(s: SchemaBuilder): SchemaComponent[] {
return [s.section('Content').components([F.text('title').required().maxLength(200)])]
  }
}
```

Three required pieces: `static model`, `static slug`, and `schema()`. Everything
else on the class is optional.

The type parameter — `BaseResource<typeof Post>` — is anchored on the instance
side through a phantom `modelType` property that is never assigned at runtime.
Statics cannot reference a class type parameter, which is why `static model` is
typed as the base `LucidModel` on `BaseResource` and why `static recordTitle`'s
callback is declared over `never`. Declaring the parameter still pays: your
`static query()` gets a `ModelQueryBuilderContract<typeof Post>` instead of an
untyped builder.

<!-- @sample-preamble
import { BaseResource, C, F } from '@adonia/core'
import type { HttpContextLike, SchemaBuilder, SchemaComponent, TableBuilder } from '@adonia/core'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
import Post from '#models/post'
import type User from '#models/user'
-->

## The statics

Every static `BaseResource` declares, its type, its default, and what reads it.
Anything not in this table does not exist on the class today.

| Static | Type | Default | Effect |
|---|---|---|---|
| `model` | `LucidModel` | — (required) | The Lucid model the resource manages. Every query, fill and delete goes through it. |
| `slug` | `string` | — (required) | URL segment and registry key, unique per panel. Convention: pluralized snake of the model name (`posts`, `blog_categories`). |
| `labels` | `{ singular: string; plural: string }` | derived from `slug` | Singular/plural display labels in the descriptor (protocol §3), the navigation item and the flash copy after a delete. |
| `navigationIcon` | `string` | `undefined` → `icon: null` | Lucide icon name beside the navigation item. |
| `navigationGroup` | `string` | `undefined` | Sidebar group. Resources sharing a group collapse into one `group` node; ungrouped ones stay top-level. |
| `navigationSort` | `number` | `0` | Ascending weight; ties keep registry order. A group weighs as much as its lightest child, so items and groups sort against each other in one pass. |
| `hidden` | `boolean` | `undefined` | Omits the resource from the navigation tree. It stays registered, routable and resolvable — this is for resources reached only from a parent's detail page. |
| `softDeletes` | `boolean` | `undefined` (convention) | Forces soft-delete detection on or off. Undeclared, a `deletedAt`/`deleted_at` attribute enables the `trashed` scopes. Set `false` when `deletedAt` is a plain audit timestamp; set `true` when the tombstone column has a custom name. |
| `pagination` | `'offset' \| 'cursor'` | panel/config `defaults.pagination` | Pagination mode for this resource's index. Cursor mode trades `total`/`lastPage` and the `COUNT(*)` behind them for stable pages and a single sort key. |
| `perPage` | `number` | panel/config `defaults.perPage` | Initial rows per page. Also unioned into the effective `perPageOptions`, so a client echoing the value back as `?perPage=` is not dropped by the §8.3 allowlist. |
| `recordTitle` | `string \| ((record: never) => string)` | primary key | How one record is named in prose. See [below](#recordtitle-and-labels). |
| `globallySearchable` | `string[] \| false` | derived searchable table columns | Columns queried by grouped global search; `false` opts the resource out. |
| `lockVersion` | `string` | disabled | Numeric model attribute used for opt-in optimistic locking; edit state carries its value out-of-band as `_adoniaLock`. |
| `query` | `(query, ctx, tenant?) => unknown` | none | Base scope. See [below](#query-and-scoping). |
| `onInvalidCursor` | `'error' \| 'reset'` | `'error'` | What a malformed, tampered or sort-mismatched `?cursor=` does. `'error'` is the normative 422 `E_ADONIA_INVALID_CURSOR`; `'reset'` serves the first page instead. It grants nothing extra — filters, scopes and authorization all run before `paginate` and come from the query string, never from the token. |
| `policy` | `PolicyReference` | `undefined` | Bouncer policy consulted for every ability `can` does not override — step 2 of the [§12 chain](/authorization). `@adonisjs/bouncer` is an optional peer; without it the policy is never consulted at all. |
| `can` | `Partial<Record<Ability, AbilityCheck>>` | `{}` | Inline ability overrides — step 1 of the chain, and the only step that always wins. An entry returning `false` is a deny that no fallback rescues. |

`pagination` and `perPage` are folded into the resolved defaults by
`resolvePaginationDefaults(resourceClass, defaults)`: most-specific wins, and
the object is returned unchanged (same reference) when a resource declares
neither. A `table()` that calls `perPageOptions()` is the more specific
declaration of the *selectable* list and still wins there; the static then
governs only the initial page size.

`labels` derivation is mechanical: `deriveLabels('blog-posts')` splits on `-`/`_`,
title-cases each word, and strips one trailing `s` from the last word of the
singular — `Blog Post` / `Blog Posts`. The navigation builder and the descriptor
header call the same function, so protocol §1 and §3 cannot disagree.

## `schema(s)`

```text
abstract schema(s: SchemaBuilder): SchemaComponent[]
```

The one abstract member. It returns a flat list of root components; nesting
happens through each layout's `.components([...])`.

`s` is the **layout and display** factory — it is stateless, so the shared
instance is safe under concurrent compilation, and every call returns a fresh
node:

| Category | Factories |
|---|---|
| Layout | `section(label?)`, `grid()`, `tabs()`, `tab(label?)`, `wizard()`, `step(label?)`, `aside()`, `fieldset(label?)`, `card(label?)`, `divider(label?)`, `spacer()` |
| Display (detail only) | `textEntry(attr)`, `badgeEntry(attr)`, `imageEntry(attr)`, `codeEntry(attr)`, `keyValueEntry(attr)`, `relationEntry(attr)`, `html(attr)` |

State-bearing **fields** do not come from `s`. They come from the separate `F`
namespace, imported from `@adonia/core` — see [fields](/fields) and
[schema components](/schema-components) for the full DSL.

The returned tree is the **single source** for every projection.
The descriptor compiler maps `create` and `edit` to the single projection context
`form`, maps `detail` to `detail`, and consults `index` only when deriving a table.
`visibleOn(...)` is an allowlist: once set, the field appears only in the named
contexts. `hiddenOn(...)` is a denylist, and wins when the same context is present
in both lists. Repeating either method replaces that method's previous list; it does
not append. `canSee` is the separate request-dependent gate. A field excluded from
the `form` projection is absent from both create/edit descriptors, validators and fill
sets, not merely unrendered; `readonlyOn('create' | 'edit')` is the API for
distinguishing those two form modes.

```ts
export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  schema(s: SchemaBuilder): SchemaComponent[] {
return [
  s.grid().columns(3).components([
    s.section('Content').columnSpan(2).components([
      F.text('title').required().maxLength(200),
      F.textarea('body').rows(12).required(),
    ]),
    s.aside().components([
      F.select('status').options({ draft: 'Draft', review: 'In review', published: 'Published' }),
      F.datetime('publishedAt').nullable(),
      s.textEntry('createdAt').label('Created'),
    ]),
  ]),
]
  }
}
```

A single `grid()` returned as the sole root is used as the tree root verbatim.
The `textEntry` above appears on the detail page and nowhere else — display
entries are read-only and detail-only by construction.

## `table()`

```text
table?(t: TableBuilder): TableBuilder
```

Optional. Leave it off and the index columns are **derived** from the schema;
declare it and derivation does not run at all.

### Derivation (§7.5)

`resolveTable(resource, defaults, ctx?)` is the entry point;
`deriveTableDescriptor(resource, defaults)` is the same resolution compiled to
the wire §3 table block. With no `table()`, columns are derived like this:

1. Walk the schema tree in document order, collecting every `Field`. When a
   request context is passed, `canSee` (and with it `requiresAbility()`) is
   applied here — a field the request may not see contributes no column, no row
   value, and no sortable/searchable/`?columns=` key. Omission has to reach the
   allowlists, or a column the user cannot see could still be sorted by, which
   leaks its ordering.
2. Keep only **tabulatable** fields: those whose instance `columnType` is
   defined. Untabulatable types (repeater, rich text, hidden, …) leave it
   `undefined` and are skipped.
3. If any tabulatable field's `visibleOn(...)` allowlist includes `index`, **those
   explicitly marked fields are the candidates** — all of them, no cap. Otherwise
   the candidates are the first `INDEX_COLUMN_CAP` (**5**) tabulatable fields in
   document order.
4. Apply `appearsIn('index')` to the candidates. `hiddenOn('index')` always removes
   a candidate, including one also marked by `visibleOn('index')`.
5. Instantiate each surviving field's column from its `columnType`:
   `text`, `badge`, `boolean`, `date`, `number`, `image`, `relation`. The field's
   declared `label` carries over. An unrecognised `columnType` falls back to
   `text` rather than failing at boot — a plugin field naming an unregistered
   cell is the client's `<UnknownComponent>` problem.

A derived table has columns and nothing else: **no filters**, no `defaultSort`,
no `searchPlaceholder` (so the search input is hidden), `perPageOptions` from the
resolved defaults, and `multiSort` off.

Both paths — declared and derived — reject a column keyed `id` or `can`, the two
reserved row keys every serialized row carries. It throws
`E_ADONIA_INVALID_CONFIG` at derivation time (boot for a declared table, first
request for a derived one) rather than letting a silent winner emerge at
serialization.

### Overriding

A declared `table()` **fully replaces** derivation. There is no merge and no
"derived plus these": a partial override would make the visible column set
change whenever an unrelated schema field was added, which is exactly the
surprise §7.5 rules out. Everything the builder can carry comes from it —
columns, filters, `defaultSort`, `searchPlaceholder`, `perPageOptions`,
`multiSort` — and the request context is *not* applied to declared columns,
because a resource that owns its column set gates a column through the column
DSL, not through the schema field it happens to share a key with.

```ts
export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  schema(): SchemaComponent[] {
return [F.text('title').required()]
  }

  override table(t: TableBuilder): TableBuilder {
return t
  .columns([
    C.text('title').sortable().searchable().link('detail'),
    C.badge('status').colors({ draft: 'gray', review: 'amber', published: 'green' }),
    C.date('publishedAt').since().sortable(),
    C.count('tags').label('Tags').toggleable(true),
  ])
  .defaultSort('publishedAt', 'desc')
  .searchPlaceholder('Search posts…')
  .perPageOptions([10, 25, 100])
  }
}
```

The builder arrives fresh on every invocation, so mutating it is safe. The `C`
namespace and every column modifier are documented in
[table columns](/table-columns); how the declared columns turn into SQL is
[the query pipeline](/query-pipeline).

## `query()` and scoping

```text
static query?(
  query: ModelQueryBuilderContract<LucidModel>,
  ctx: HttpContextLike,
  tenant?: unknown
): unknown
```

The base scope, mutated **in place** and returned. Returning a different builder
is not supported — callers keep the one they passed. The panel, tenant, parent
and lens scopes compose on top of this at their own layers, so a resource never
re-implements them here.

```ts
export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  static override query(query: ModelQueryBuilderContract<typeof Post>, ctx: HttpContextLike) {
const user = ctx.auth.user as User | null
if (user === null || user.role === 'admin') return query
return query.where('authorId', user.id)
  }

  schema(): SchemaComponent[] {
return [F.text('title').required()]
  }
}
```

**What honours it today.** The only caller in the shipped code is the relation
field's target scoping: both the option list a `belongs-to`/`belongs-to-many`
field offers and the §11.1 existence check behind it go through the same scope,
which is what makes them agree — a key outside the scope is neither offerable
nor submittable.

The index pipeline's `base` stage — first of the six in
`base → softDeleteScope → search → filters → sort → eagerLoad` — is still
registered as a **no-op** by `buildIndexPipeline`, and the controller's
record loader (`findForWrite`) calls `Model.query()` directly. So a declared
`query()` does **not** yet scope the index list or the detail/edit/delete
lookup. §6.1 specifies that it should; the code does not do it yet. Do not rely
on `query()` for security today — put that in
[`can` / `policy`](/authorization), which are enforced at all three points.

`onInvalidCursor` is the other scoping-adjacent static and *is* honoured: the
index action passes `resourceClass.onInvalidCursor` straight into `paginateIndex`.

## `recordTitle` and labels

```text
static recordTitle?: string | ((record: never) => string)
```

How one record is titled when it has to be **named** rather than tabulated. The
callback is declared over `never` because a static cannot name its own class'
type parameter; the row it receives is an instance of `model`.

```ts
export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'
  static override recordTitle = (post: Post) => `${post.title} (${post.status})`

  schema(): SchemaComponent[] {
return [F.text('title').required()]
  }
}
```

Relation option labels, global-search hits, relation managers, and page headings consume
`recordTitle` in this order: a field's own `optionLabel(attribute)`, then the resource's
`recordTitle` attribute/function, then the primary key rendered as text.

`labels` is the plural/singular pair, not the record title: it names the
*resource* in the descriptor header, the navigation item, and the flash message
after a delete (`labels.singular`, falling back to the slug). Navigation
placement is `navigationIcon` / `navigationGroup` / `navigationSort` / `hidden`,
all read by the default navigation derivation; a panel calling
`panel.navigation(...)` starts from an empty tree instead and places items by
hand. See [panels](/guide/panels).

## Lifecycle hooks

Two, both around delete:

```text
beforeDelete?(ctx: HttpContext, record: LucidRow): Promise<void>
afterDelete?(ctx: HttpContext, record: LucidRow): Promise<void>
```

There is **no** `beforeSave`/`afterSave` on `BaseResource` today, and nothing
calls one. `Field.virtual()`'s own documentation still promises them —
"persistence is the resource's job in `beforeSave`/`afterSave`" — so a
`virtual()` field currently has no resource-level hook to persist from. Field
types that own their persistence do it in their `dehydrate`.

Both hooks run inside the delete transaction, after the §12 mutation re-check
and around the write. Both delete shapes pass through them — the soft-delete
tombstone write and the hard row removal, `?force=1` included — so a hook that
detaches related rows, releases a slug or writes an audit entry runs exactly
once per delete without having to ask which one happened. In `afterDelete` the
record instance is still readable: a hard delete removes the row, not the
in-memory attributes.

Throwing **aborts the delete**: the transaction rolls back and the request
fails. That is the deliberate `record:*` exception to §16.4's "a throwing
runtime hook is logged and must not break the request" — a hook that cannot
complete its half of the delete must not leave the other half committed.

```ts
import type { HttpContext } from '@adonisjs/core/http'
import type { LucidRow } from '@adonisjs/lucid/types/model'

export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  schema(): SchemaComponent[] {
return [F.text('title').required()]
  }

  override async beforeDelete(_ctx: HttpContext, record: LucidRow): Promise<void> {
await (record as Post).related('tags').detach()
  }
}
```

## Actions, lenses, widgets, and relation managers

Four instance declarations extend a resource without changing its schema/table contracts:

- `actions(): AnyAction[]` returns `Action` instances. Each action declares a stable slug,
  label, contexts (`row`, `bulk`, `page`, `form`, or `relation`), optional confirmation and
  modal schema, and `isQueued`. `canSee`, `canRun`, and `runAction:<slug>` are checked before
  descriptor emission and again before execution. Inline and queued workers share the same
  chunked, transaction-scoped executor and mandatory scrubbed audit events.
- `lenses(): Lens[]` returns alternate authorized index scopes. A lens has a stable
  `slug`, label, query mutation, and optional replacement columns. Access requires
  `accessLens:<slug>`; bulk “all matching” actions replay the exact validated lens query and
  recheck the lens inside each transaction.
- `widgets(): AnyWidget[]` returns value, trend, partition, table, or custom widgets for the
  resource surface. Initial data is grouped in one deferred Inertia request. Ranges refetch
  through `widgets.data`; `cacheFor(minutes)` uses `@adonisjs/cache` while HTTP responses
  remain `no-store`.
- `relations(): RelationManagerDef[]` defines parent-scoped child managers. Place one with
  `s.relationManager('<relation>')` in the detail/edit schema. Create, attach, edit, search,
  and pagination use server-built child descriptors and URLs; capability flags are enforced
  by both the descriptor and nested write routes.

Set `static lockVersion = 'version'` to opt into optimistic locking. The update route
atomically compares and increments that numeric attribute. A stale write returns 409; the
packaged form keeps locally dirty declared fields, reloads the fresh baseline and lock, and
requires an explicit retry.

`F.hasMany(relation, schema)` and
`F.morphTo(attribute, { types, typeColumn?, idColumn? })` complete the built-in relation
field set. Has-many writes load owned children inside the parent transaction, reject foreign
IDs, and apply the authoritative create/update/delete diff. Morph-to stores an allowlisted
stable resource slug plus row id and uses searchable, pageable options that hydrate the
selected id on edit.

Each `has-many` item has a reserved boundary key, `id`, in addition to its
declared child-field keys. Existing hydrated rows carry their string or number
`id`; new rows omit it. Preserve existing ids unchanged: they are validated and
checked against the active parent's relation before update. They are deliberately
not represented by a child field in the descriptor.

## Registration

A resource lives at `app/adonia/resources/<model>_resource.ts` and is exported
as the module's **default**.

You do not list it anywhere by hand. The `indexAdoniaResources()` assembler hook
writes `.adonisjs/adonia/resources.ts` — a `slug → () => import(module)` map
checked with `satisfies AdoniaResourceRegistry` (an alias of
`ResourceRegistryInput`) — and regenerates it under `node ace serve --hmr`
whenever a file under `app/adonia/resources/` changes. It is that shape:

```ts
import type { ResourceRegistryInput } from '@adonia/core'

const registry = {
  posts: () => import('#adonia/resources/post_resource'),
} satisfies ResourceRegistryInput

export default registry
```

The panel imports the generated file and passes it to `resources()` —
`Panel.make('admin').resources(registry)`; see [panels](/guide/panels). Two
resources claiming the same slug is a generation failure, and no partial
registry is ever written. See [codegen](/codegen).

Scaffold one with the generator, which reads the model's columns and writes a
schema mirroring the table:

```sh
node ace adonia:resource Post
node ace adonia:resource Post --fields=title,slug,body --no-table
node ace adonia:resource Post --panel=admin
```

Full flag and type-mapping tables are in [generators](/generators).

## Full example

Everything above, on one class, against the `Post` model.

<!-- @sample-preamble-reset -->

```ts
import { BaseResource, C, F } from '@adonia/core'
import type { HttpContextLike, SchemaBuilder, SchemaComponent, TableBuilder } from '@adonia/core'
import type { HttpContext } from '@adonisjs/core/http'
import type { LucidRow } from '@adonisjs/lucid/types/model'
import Post from '#models/post'
import type User from '#models/user'

const STATUSES = { draft: 'Draft', review: 'In review', published: 'Published' } as const

export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'
  static override labels = { singular: 'Post', plural: 'Posts' }

  static override navigationIcon = 'newspaper'
  static override navigationGroup = 'Content'
  static override navigationSort = 10

  static override recordTitle = 'title'
  static override perPage = 25
  static override pagination = 'offset' as const

  /** Authors may only delete their own drafts; everything else falls to the policy. */
  static override can = {
delete: (ctx: HttpContextLike, record?: unknown) => {
  const user = ctx.auth.user as User | null
  if (user === null || !(record instanceof Post)) return false
  return user.role === 'admin' || record.authorId === user.id
},
  }

  schema(s: SchemaBuilder): SchemaComponent[] {
return [
  s.grid().columns(3).components([
    s.section('Content').columnSpan(2).components([
      F.text('title').label('Title').required().maxLength(200).live(),
      F.text('slug').label('Slug').required().maxLength(220).helper('Lower-case, dash separated.'),
      F.textarea('body').label('Body').rows(16).required(),
    ]),

    s.aside().components([
      F.select('status').label('Status').options(STATUSES).required().default('draft'),
      F.toggle('featured').label('Featured').default(false),
      F.number('readingMinutes').label('Reading time').min(1).max(120).nullable(),
      F.datetime('publishedAt').label('Published at').nullable(),
      s.textEntry('createdAt').label('Created'),
    ]),
  ]),
]
  }

  override table(t: TableBuilder): TableBuilder {
return t
  .columns([
    C.text('title').sortable().searchable().link('edit').limit(60),
    C.badge('status').colors({ draft: 'gray', review: 'amber', published: 'green' }),
    C.boolean('featured').label('★'),
    C.count('tags').label('Tags').toggleable(true),
    C.date('publishedAt').label('Published').since().sortable(),
  ])
  .defaultSort('publishedAt', 'desc')
  .searchPlaceholder('Search posts…')
  .perPageOptions([25, 50, 100])
  }

  override async beforeDelete(_ctx: HttpContext, record: LucidRow): Promise<void> {
await (record as Post).related('tags').detach()
  }
}
```

The schema declares no `visibleOn('index')`, so if `table()` were removed the
index would derive its columns from the first five tabulatable fields —
`title`, `slug`, `body`, `status`, `featured` — which is a serviceable default
and a poor list. That gap is the reason to declare a table.

Source: https://adonia.pages.dev/guide/resources/index.mdx
