---
title: "Panels"
description: "Configure panel routes, access, navigation, branding, and resource registration."
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.

# Panels

This page documents the current panel builder and route contract.

A panel is the top-level unit of an Adonia install: a mount (a path, a domain,
or both), an auth guard, an access verdict, chrome, a navigation tree, and a
resource registry. The builder is pure configuration — it never touches the
router, the container, or a request. Three readers pick it back up: the
registrar turns it into routes, the `adonia.panel-access` middleware enforces
its gates, and the Inertia share hook projects it into the
[panel envelope](/panel-envelope) every page receives.

Inputs are validated at builder-call time, not at boot. A typo reports the
offending key with a path-qualified `E_ADONIA_INVALID_CONFIG` message
(`panel(admin).brand.name must be a non-empty string`) the moment the module is
imported.

<!-- @sample-preamble
import { Panel } from '@adonia/core'
-->

## Creating a panel

`Panel.make(id)` is the only constructor; the class constructor is private.

```ts
export default Panel.make('admin').path('/admin')
```

The id is a route-name segment — every route of the panel is named
`adonia.<id>.<suffix>` — so it must match `PANEL_ID_PATTERN`:

```text
/^[a-z][a-z0-9-]*$/
```

Lowercase ASCII first, then lowercase, digits and hyphens. No underscores, no
uppercase, no leading digit, no dots. Anything else throws
`InvalidConfigException` at `make()` time:

```text
[E_ADONIA_INVALID_CONFIG] panel id "Admin_Panel" must match /^[a-z][a-z0-9-]*$/ (e.g. 'admin', 'blog-admin')
```

The id is also the fallback wordmark: with no `brand({ name })`, `blog-admin`
renders as `Blog Admin`.

Ids are unique per application, and so are mounts — see
[Registering the panel](#registering-the-panel).

## Mounting

```ts
Panel.make('admin').path('admin') // → mountPath '/admin'
Panel.make('root').path('/') // → mountPath '/'
Panel.make('tenant').domain('admin.acme.com') // → mountPath '/' by default
```

`path()` normalizes: a missing leading slash is added, trailing slashes are
stripped, and `'/'` survives as itself. `domain()` accepts an exact host or one
dynamic label such as `:tenant.admin.example.com`. Matching strips the port,
ranks exact hosts before dynamic patterns, then selects the longest matching
mount path. If a more-specific domain pattern misses the path, lower-ranked
matching patterns are still considered before path-only panels. No match
returns `null`. A panel that only sets `domain()` mounts at `/` on that host.

Paths derived from the mount are read back off the panel: `pathTo(relative)`,
`dashboardPath` (the mount itself, and the post-login redirect target),
`loginPath`, `logoutPath`.

### The route group

The registrar creates **one group per panel** and applies, in this order:
`.domain(pattern)` when domain-mounted, `.prefix(path)` when the mount is not
`/`, then the middleware stack. The stack is two nested groups rather than one
flat list, because §19 carves out exactly one exception — *there are no
unauthenticated Adonia routes except `login`*:

```text
group( domain? · prefix? · [adonia.panel, share hook, error boundary, ...panel.middleware] )
  ├── login · login.attempt · logout        ← reachable as a guest
  └── group( [adonia.panel-access] )        ← guard + access verdict
    └── dashboard · search · pages.show · resources.* · field.*
```

Four slots run before anything of yours:

| Slot | Kind | Does |
| --- | --- | --- |
| `adonia.panel` | inline closure | Publishes the resolved panel as `ctx.adonia.panel`. Inline because it is the one middleware that must close over a specific panel. |
| share hook | inline | Registers the §9.1 envelope provider on the request's Inertia instance. Outside the gate: the login screen and the chromed 403/404 need the same chrome. |
| error boundary | inline | Renders a foreign error escaping a panel route as the panel-chromed page. Inside the share hook, outside everything below, so a throw from your middleware or the gate is chromed too. |
| `...panel.middleware` | yours | Everything passed to [`middleware()`](#middleware), in array order. |
| `adonia.panel-access` | named | Authenticates against the panel's guard, then runs the `access()` verdict. Applied to the inner group only. |

The §5.2 stack names a separate `guardMiddleware` slot before the access gate.
There is no such middleware: `adonia.panel-access` performs the authentication
itself (`ctx.auth.use(panel.guardName)`) and then the verdict, so the effective
order is `['adonia.panel', ...panel.middleware, 'adonia.panel-access']` with
authentication folded into the last entry. Adonia parses its own
`adonia.panel-access` through `router.named()` inside the registrar, so a host
app needs no `start/kernel.ts` edit for panels to be guarded.

Every route receives an explicit `.as('adonia.<panelId>.…')` name. v7 auto-names
controller routes, and an auto-name can collide with the host app's own when
`Router.commit()` runs — an explicit name is what keeps a panel from breaking an
application it was merely added to. Registration is idempotent per router
instance, so an in-process reboot cannot produce duplicate names.

Two panels must never resolve to the same `(domain, path)` pair; that is
rejected earlier, at registration time, with `E_ADONIA_PANEL_CONFLICT`. The
registrar never sees a conflicting pair.

### The route table

Paths are relative to the mount; names are suffixes of `adonia.<panelId>.`.
These three are registered first and sit **outside** the access gate, and only
when the login flow is enabled:

| Method | Path | Name suffix | Controller |
| --- | --- | --- | --- |
| `GET` | `login` | `login` | `AuthController.showLogin` |
| `POST` | `login` | `login.attempt` | `AuthController.attempt` |
| `POST` | `logout` | `logout` | `AuthController.logout` |

Everything else sits behind `adonia.panel-access`:

| Method | Path | Name suffix | Controller |
| --- | --- | --- | --- |
| `GET` | `/` | `dashboard` | `DashboardController.show` |
| `GET` | `search` | `search` | `GlobalSearchController.show` |
| `GET` | `pages/:page` | `pages.show` | `CustomPageController.show` |
| `GET` | `actions/status/:executionId` | `actions.status` | `ActionController.status` |
| `GET` | `widgets/:widget` | `widgets.data` | `WidgetController.data` |
| `GET` | `:resource` | `resources.index` | `ResourceController.index` |
| `GET` | `:resource/lens/:lens` | `resources.lens` | `ResourceController.lens` |
| `GET` | `:resource/create` | `resources.create` | `ResourceController.create` |
| `POST` | `:resource` | `resources.store` | `ResourceController.store` |
| `GET` | `:resource/field/:field/options` | `field.options` | `FieldCallbackController.options` |
| `POST` | `:resource/field/:field/upload` | `field.upload` | `FieldCallbackController.upload` |
| `POST` | `:resource/actions/:action` | `actions.run` | `ActionController.run` |
| `GET` | `:resource/:id/relations/:relation` | `resources.relations.index` | `RelationController.index` |
| `GET` | `:resource/:id/relations/:relation/create` | `resources.relations.create` | `RelationController.create` |
| `POST` | `:resource/:id/relations/:relation` | `resources.relations.store` | `RelationController.store` |
| `GET` | `:resource/:id/relations/:relation/attach/options` | `resources.relations.attachOptions` | `RelationController.attachOptions` |
| `POST` | `:resource/:id/relations/:relation/attach` | `resources.relations.attach` | `RelationController.attach` |
| `GET` | `:resource/:id/relations/:relation/:child/edit` | `resources.relations.edit` | `RelationController.edit` |
| `PUT` | `:resource/:id/relations/:relation/:child` | `resources.relations.update` | `RelationController.update` |
| `GET` | `:resource/:id` | `resources.show` | `ResourceController.show` |
| `GET` | `:resource/:id/edit` | `resources.edit` | `ResourceController.edit` |
| `PUT` | `:resource/:id` | `resources.update` | `ResourceController.update` |
| `DELETE` | `:resource/:id` | `resources.destroy` | `ResourceController.destroy` |
| `POST` | `:resource/:id/restore` | `resources.restore` | `ResourceController.restore` |

**Row order is part of the contract**, not presentation. The static rows precede
`/:resource`, or `/admin/pages/reports` would resolve as resource `pages`,
record `reports`; `:resource/create` precedes `:resource/:id`, or
`/admin/posts/create` would be a `resources.show` with `id = 'create'`. v7's
matcher prefers static segments and is more forgiving than that, but the table
is asserted in the stricter order so it stays correct if it is ever handed to a
different matcher.

## Authentication and access

```ts
Panel.make('admin')
  .path('/admin')
  .guard('web')
  .login({ model: () => import('#models/user') })
```

`guard(name)` names a guard from `config/auth.ts`. The access gate calls
`ctx.auth.use(name).check()`; the login flow calls `.login(user)` on the same
guard.

### Why `login()` needs a model

§5.1 declares `login(opts?: { enabled?, page? })`. That is not enough. Laravel
guards verify credentials (`Auth::attempt`); AdonisJS guards do not —
`SessionGuard` exposes `login(user)`, `check()`, `authenticate()`, `logout()`
and nothing that turns an email plus a password into a user. The credential
check lives on the **model**, as `withAuthFinder`'s `static verifyCredentials`,
and the guard's user provider — the only object that knows which model backs the
guard — is private. A library cannot derive the model from `guard('web')` alone,
so the panel asks for it once:

| Option | Type | Default |
| --- | --- | --- |
| `enabled` | `boolean` | `true` once `guard()` is set |
| `page` | `string` | `'adonia/login'` (`DEFAULT_LOGIN_PAGE`) |
| `model` | `() => Promise<{ default: CredentialsVerifiable }>` | — |
| `verify` | `(ctx, { uid, password }) => user \| null` | — |
| `uidField` | `string` | `'email'` |

`verify` is the escape hatch for apps that do not use `withAuthFinder` — LDAP, an
SSO exchange, a custom hash. Return the user to sign in, or `null` for a
mismatch; never throw for a bad password, because a throw propagates as a 500.
Whatever you return goes straight to `auth.use(guard).login(user)`, so it must be
what that guard's provider accepts.

<!-- @sample-preamble
declare function verifyAgainstDirectory(uid: string, password: string): Promise<{ id: number } | null>
-->

```ts
Panel.make('admin')
  .guard('web')
  .login({
uidField: 'username',
verify: (_ctx, { uid, password }) => verifyAgainstDirectory(uid, password),
  })
```

A panel whose login flow is enabled but configured with neither `model` nor
`verify` still registers `GET login` — the gate needs somewhere to redirect
guests to — and throws `E_ADONIA_INVALID_CONFIG` naming the missing option on
`POST login`. Nothing silently accepts or rejects a password.

The packaged flow is deliberately dull. `attempt` reads `body[uidField]` and
`body.password`; an empty one, or a mismatch, flashes `inputErrorsBag` under the
uid field and redirects back with the same message either way — a per-field
"unknown email" is an account-enumeration oracle. On success it signs the user
in, regenerates the session id *after* the guard has written its payload (which
closes the fixation window without losing the stored user id), and redirects to
`dashboardPath`. `logout` signs out, clears and regenerates the session, and
returns to the login screen.

Set `login({ enabled: false })` for a panel behind SSO or an app-owned login
page: no `/login`, `/logout` or `POST /login` is registered at all.

### `access()`

`guard()` decides who is signed in; `access()` decides who may enter this panel.

<!-- @sample-preamble
// Stands in for the reader's `config/auth.ts`: `HttpContext.auth` is
// `Authenticator<never>` until `Authenticators` is augmented, so `auth.user`
// would be `never` here. Exactly ONE sample module in the whole docs:check
// program may carry this augmentation — a second declaration merges into the
// interface, which then no longer satisfies `Record<string, GuardFactory>`
// and collapses back to `never`. Hence the reset immediately after the block.
import User from '#models/user'
import type { GuardContract } from '@adonisjs/auth/types'
declare module '@adonisjs/auth/types' {
  interface Authenticators extends Record<'web', () => GuardContract<User>> {}
}
-->

```ts
Panel.make('admin')
  .guard('web')
  .access((ctx) => ctx.auth.user instanceof User && ctx.auth.user.role === 'admin')
```

<!-- @sample-preamble-reset -->
<!-- @sample-preamble
import { Panel } from '@adonia/core'
-->

A panel without an `access()` callback admits every authenticated user. A `false`
verdict is a 403: the panel-chromed `adonia/error` page for browsers,
`{ code, message }` for JSON endpoints. A guest is a redirect to `loginPath`, or
a 401 body on a JSON endpoint. Both are coarse gates — what a user may *do*
inside the panel is [authorization](/authorization), resolved per resource.

## Brand and theme

```ts
Panel.make('admin')
  .brand({
name: 'Blog Admin',
logo: '/admin/logo.svg',
favicon: '/favicon.ico',
accent: '#c2410c',
  })
  .theme({
darkMode: 'class',
cssVariables: { '--adonia-radius': '0.25rem' },
  })
```

| Option | Type | Default |
| --- | --- | --- |
| `brand.name` | non-empty `string`, required | title-cased id (`blog-admin` → `Blog Admin`) |
| `brand.logo` | non-empty `string` | `null` |
| `brand.favicon` | non-empty `string` | `null` |
| `brand.accent` | non-empty `string` | `DEFAULT_PANEL_ACCENT`, `oklch(54.1% 0.17 265)` |
| `theme.darkMode` | `'system' \| 'class' \| 'off'` | `'system'` |
| `theme.cssVariables` | ``Record<`--adonia-${string}`, string>`` | `{}` |

Both are read back **resolved** — every optional replaced by a value or `null` —
so the [envelope](/panel-envelope) serializes without re-deriving defaults
on the client. Repeated calls merge per key rather than replacing the bag.

Token names are checked against `PANEL_CSS_VARIABLE_PATTERN`
(`/^--adonia-[a-z0-9-]+$/`) and rejected otherwise: the Tailwind preset maps
Adonia tokens only, so an app-specific custom property here would silently do
nothing. What the tokens are, how the accent seeds the OKLCH ramp, and what each
dark-mode strategy does on the client is [Theming](/theming).

## Resources

```ts
Panel.make('admin').path('/admin').resources({
  posts: () => import('#adonia/resources/post_resource'),
})
```

A registry is `slug → () => Promise<{ default: ResourceClass }>` — the shape of
the generated `.adonisjs/adonia/resources.ts`, which default-exports an object
literal `satisfies ResourceRegistryInput` with one lazy import per resource,
sorted by slug. The scaffolded panel imports it relatively
(`import registry from '../../../.adonisjs/adonia/resources.js'`) because the
generated file sits outside the app's `#` subpath map; because the file uses
`satisfies` rather than an annotation, `registry.posts` keeps its precise type
and a subset stays typo-proof.

<!-- @sample-preamble
import type { ResourceLazyImport } from '@adonia/core'
declare const registry: {
  posts: ResourceLazyImport
  categories: ResourceLazyImport
  tags: ResourceLazyImport
}
-->

```ts
// The whole generated registry…
Panel.make('admin').path('/admin').resources(registry)

// …or a subset, for a second panel that should see less.
Panel.make('editor')
  .path('/editor')
  .resources({ posts: registry.posts, categories: registry.categories })
```

The registry is copied on the way in, and a second `resources()` call
**replaces** it rather than merging.

Nothing validates slugs at definition time, because the registry is generated
and the URL is not. A URL slug the registry does not carry throws
`UnknownResourceException` (`E_ADONIA_UNKNOWN_RESOURCE`) at request time: the
chromed 404 page, or `{ code, message }` on a JSON endpoint. A slug named in
`navigation()` is the opposite case — that one is authored, so it is a
[definition-time throw](#navigation).

§6.2 sketches per-panel presentation overrides
(`panel.resources(registry, { posts: { navigationGroup: 'Content' } })`). That
second argument does not exist: `resources()` takes the registry only, and a
resource presents the same statics in every panel it is registered on.

Writing the resources themselves is [Resources](/guide/resources).

## Navigation

Without `navigation()` the sidebar is **derived** from the registry. Each
resource contributes one item, labelled by `labels.plural` (or derived from the
slug), iconed by `navigationIcon`, linked to `pathTo(slug)`, nested under
`navigationGroup`, ordered by `navigationSort`, and dropped entirely when
`static hidden` is set.

Ordering is one comparison for items and groups alike — a group weighs as much
as its lightest child — so `navigationSort` orders a group against a loose item
instead of sorting the two in separate passes. Ties keep registry order. An item
is `active` when the current path equals its URL or descends from it, compared
by segment, so `/admin/post-tags` never lights up for `/admin/posts`.

`navigation()` replaces that tree; it does not decorate it. The builder starts
**empty**, so the order on screen is the order written:

```ts
Panel.make('admin')
  .path('/admin')
  .resources(registry)
  .navigation((nav) => {
nav.group('Content', ['posts', 'categories'])
nav.item('Docs', { url: 'https://acme.dev/docs', icon: 'book' })
nav.group('System', (child) => {
  child.resource('tags')
  child.item('Health', { url: '/admin/health', active: false })
})
nav.resources() // everything not placed above
  })
```

| Method | Effect |
| --- | --- |
| `item(label, { url, icon?, active? })` | A free-form link: a custom page, an external URL, a report. `icon` serializes as `null` when omitted; `active` defaults to the segment match. |
| `group(label, slugs \| (nav) => void)` | A group from a slug list, or from a nested builder for mixed contents. |
| `resource(slug)` | One registry resource. |
| `resources()` | Every visible resource not yet placed, in derived order. |

Call `nav.resources()` at the end unless you mean to hide what you did not
place: it is what keeps a newly generated resource from silently vanishing from
a curated sidebar. Placement is tracked across nested builders, so a resource
placed by hand is never repeated.

Two rules govern the output, and both are omission rules:

1. **A resource the viewer cannot `viewList` is absent.** Not `visible: false` —
   absent. There is no hidden node on the wire to enumerate (protocol §2 rule 3).
2. **A group left with no visible children disappears with them**, rather than
   rendering an empty heading.

Visibility arrives as an injected predicate — `buildNavigation({ panel,
currentPath, canViewList })` — so the tree is built per request against the real
abilities of the user asking for it.

Two shapes ARCHITECTURE §7.1 sketches do not exist. Groups have no icon and no
URL: protocol v1 froze the `group` node as `{ type, label, children }`. And
there is no `nav.item(...).route(...)`: `item()` takes a path or an absolute
URL, while framework-built resource, tenant, and panel links use the
domain/tenant-aware URL builders.

A slug that is not in the registry at all is an authoring mistake, and
`nav.resource('postz')` throws immediately:

```text
[E_ADONIA_INVALID_CONFIG] navigation references resource "postz", which is not in the panel's registry
```

A slug that *is* registered but hidden, or invisible to this user, is skipped in
silence — that is the omission rule, not a typo.

## Middleware

```ts
import { middleware } from '#start/kernel'

Panel.make('admin').path('/admin').middleware([middleware.auth()])
```

`middleware()` takes **values, not app middleware names**. §5.2 declares
`middleware(names: string[])`; that cannot work. `router.named(collection)` in
v7 is "not registered anywhere, but instead converted in a new collection of
functions you can apply on the routes" — the factories it returns are local to
the module that called it, normally `start/kernel.ts`. There is no global name →
middleware registry a library can consult, so `'auth'` is not resolvable from
`@adonia/core`. Accepting it and dropping it would disable an auth middleware an
app believed it had applied, so an unresolvable name fails at panel-definition
time with a message pointing at the working form.

An entry is one of three things:

| Entry | Example |
| --- | --- |
| One of Adonia's own names | `'adonia.panel'`, `'adonia.panel-access'` |
| A middleware function | `async (ctx, next) => next()` |
| A parsed named middleware | `middleware.auth()` |

`ADONIA_MIDDLEWARE_NAMES` is that first set, exported as a const tuple:

```ts
import { ADONIA_MIDDLEWARE_NAMES } from '@adonia/core'

const names: readonly string[] = ADONIA_MIDDLEWARE_NAMES // ['adonia.panel', 'adonia.panel-access']
```

They are accepted for symmetry and then **dropped by the registrar**, which
applies them regardless — applying `adonia.panel-access` twice would run the
guard and the access verdict twice per request.

## Custom pages, dashboards, plugins, defaults

`pages()` registers custom panel pages, and `GET /pages/:page` serves them
today:

```ts
Panel.make('admin').path('/admin').pages([() => import('#adonia/pages/reports')])
```

Each entry must be a lazy import; the controller reads three statics off the
default export, structurally:

| Static | Required | Default |
| --- | --- | --- |
| `slug` | yes | — (the `:page` segment) |
| `component` | no | `'adonia/page'` |
| `title` | no | the slug |

A page module without a usable `slug` is skipped rather than throwing — one
malformed page must not take the whole panel's `pages.show` route down. A slug
no page claims is `E_ADONIA_UNKNOWN_PAGE`, a 404. Custom pages retain their
structural `slug`, `component`, and `title` contract; resource actions, lenses,
and dashboard widgets do not change it.

`defaults()` overrides framework behaviour for one panel. Repeated calls stack
as layers: a later call wins per key, and unspecified keys keep falling through
to `config/adonia.ts` and then to the framework defaults. Values are validated
against the same rules as the config file, with `panel(<id>).defaults.*` message
paths.

```ts
Panel.make('admin')
  .path('/admin')
  .defaults({ perPage: 50, pagination: 'cursor' })
  .defaults({ redirectAfterCreate: 'index' })
```

`dashboard(builder)` is consumed by `GET /`: widgets are resolved through one
grouped deferred prop, rendered on the twelve-column dashboard grid, and
refetched through `widgets.data` when a range changes:

```ts
import { ValueMetric } from '@adonia/core'

Panel.make('admin').dashboard((dashboard) => {
  dashboard.widget(
ValueMetric.make('users', async () => ({ value: 42, previous: 38 }))
  .label('Users')
  .placement(4)
  .ranges([{ key: '30d', label: '30 days' }])
  .cacheFor(5)
  )
})
```

`plugins([...])` installs named plugins into the ordered server hook bus during panel
boot. Tenant panels use `tenant()` with a dynamic domain or `tenantParam()` address
source; see the complete fail-closed resolver, scoping and switcher contract in
[Tenancy](/guide/tenancy).

## Registering the panel

A panel lives in `app/adonia/panels/*.ts` and is the module's **default export**.
Nothing imports it by hand:

- With `panels` set in `config/adonia.ts`, each entry is imported as a module
  specifier, in the order listed.
- With `panels` left `undefined` — the default — the provider reads
  `app/adonia/panels/`, keeps `.ts` and `.js` files, sorts them by name, and
  imports each as a file URL. A missing directory means "no panels yet", not an
  error.

A module whose default export is not a `Panel` instance fails boot with
`E_ADONIA_INVALID_CONFIG` naming the specifier. `PanelManager.register` then
rejects a duplicate id, or a second panel resolving to the same
`(domain, path)` mount, with `E_ADONIA_PANEL_CONFLICT`. Registration order is
preserved.

Routes are registered in the provider's **`boot()`, never `ready()`**. In the
`web` environment AdonisJS v7 commits the route store inside
`Application.start()` — after preloads, *before* provider `ready()` hooks — and
`Router.commit()` is one-shot, so a route added during `ready()` would silently
never reach the matcher and every panel URL would 404 under
`node ace serve --hmr`. `boot()` runs before that commit in every environment
(web, test, console), and the `router` binding already exists by then: the
framework's app provider registers it during the register phase, which precedes
all `boot()` hooks. TECH_SPEC §2.1 still says registration happens "during
ready"; the code is right and the spec is superseded.

With no panel registered the provider skips route wiring entirely.

<!-- @sample-preamble-reset -->
<!-- @sample-preamble
import type { ResourceLazyImport } from '@adonia/core'
declare const registry: {
  posts: ResourceLazyImport
  categories: ResourceLazyImport
  tags: ResourceLazyImport
}
-->

The whole surface, as one file — `app/adonia/panels/admin_panel.ts`, with
`registry` imported from the generated `.adonisjs/adonia/resources.ts`:

```ts
import { Panel } from '@adonia/core'

export default Panel.make('admin')
  .path('/admin')
  .guard('web')
  .login({ model: () => import('#models/user') })
  .access((ctx) => ctx.auth.user !== undefined)
  .brand({ name: 'Blog Admin' })
  .theme({ darkMode: 'system' })
  .resources(registry)
  .navigation((nav) => {
nav.group('Content', ['posts', 'categories'])
nav.resources()
  })
```

Everything this produces on the client — brand, navigation, flash, user,
abilities — travels in one shared object: the
[panel envelope](/panel-envelope).

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