`defineAdmin` is the only required call in your config. It takes one options
object, validates uniqueness (resource names, dashboard paths, queue keys),
wires the default bulk-delete action when applicable, and returns a
`ResolvedAdminConfig` that the Next.js bridge consumes.

`defineAdmin` is pure: it performs no I/O and does not mutate module-level
state. Importing a config from a page, script, or test has no external effect.

Define and validate a Flowpanel admin while preserving its resource tuple types.

```ts
export function defineAdmin<const Resources extends readonly AnyResourceConfig[] = readonly AnyResourceConfig[]>(config: AdminConfig<Resources>): ResolvedAdminConfig<Resources>;
```

Every property table below is generated at build time from the package's own
TypeScript **source** — the same declarations the compiler reads — so a table
cannot drift from the type it documents.

## AdminConfig

`adapter` and `auth` are required. Everything else is optional, with sensible
defaults: no resources rendered, sidebar shell, English labels, no audit sink,
no realtime, no throttling.

**AdminConfig**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | no | Stable id surfaced in the wire client metadata (`client.id`, default `"local"`) to tell multi-admin deployments apart. |
| `adapter` | `object` | yes | Database binding — `drizzleAdapter`, `prismaAdapter`, or your own. |
| `auth` | `object` | yes | How the admin reads sessions and roles. |
| `scope` | `function` | no | Tenant scope resolved per request, then applied to every query. |
| `theme` | `object` | no | Branding, color scheme, design tokens and component overrides. |
| `shell` | `"bare" \| "tabs" \| "sidebar" \| object` | no | Chrome around the content area — sidebar, tabs, or none. |
| `labels` | `object` | no | Override built-in UI strings. |
| `formatting` | `object` | no | Locale, timezone and currency for numbers, money and timestamps. |
| `resources` | `Resources` | no | Tables the admin manages. |
| `dashboards` | `array` | no | Widget dashboards. |
| `pages` | `array` | no | Custom pages rendered inside the shell. |
| `queues` | `array` | no | BullMQ queues surfaced in the admin. |
| `commandPalette` | `object` | no | What ⌘K offers. |
| `audit` | `object` | no | Audit trail for every mutation. |
| `realtime` | `PublisherOptions` | no | Cross-instance realtime transport. Redis in production. |
| `rateLimit` | `RateLimitConfig` | no | Throttle requests per user or per IP. |
| `security` | `object` | no | Browser request protections. Same-origin mutation checks are enabled by default. |
| `readOnly` | `boolean` | no | Put the whole admin in read-only mode. |
| `paths` | `Partial<object>` | no | Mount points for the generated admin UI and HTTP API. |
| `basePath` | `string` | no |  |
| `hooks` | `object` | no | Cross-cutting callbacks. |

## AuthConfig

The only part of FlowPanel that knows about your auth provider. `session` and
`role` run on every request and every page render, so keep them cheap — cache
inside your own session helper if they hit the network.

`session` receives the request being served, so a provider that reads cookies,
headers or the host — a tenant resolver, an OAuth callback check — can use it
directly. A helper that ignores the argument still satisfies the type.

**AuthConfig**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `session` | `function` | yes | Reads the current session. Called on every request and page render, and handed
the request being served so a provider that inspects headers, cookies or the
host — a tenant resolver, an OAuth callback check — does not have to forge one. |
| `role` | `function` | yes | Maps a session to a role string, which every `requireRole` gate compares against. |
| `requireRole` | `function \| array \| string` | no | Admin-wide gate. Blocks every route and page before anything else runs. |
| `signInUrl` | `string` | no | Where to send an unauthenticated visitor. Without it they get an inline notice. |
| `forbiddenUrl` | `string` | no | Where to send a signed-in visitor who lacks the role. |
| `userId` | `function` | no | Extracts the actor id for audit rows and per-user rate limiting. |
| `allowUnauthenticated` | `boolean` | no | Declares that this admin is meant to be reachable without any role gate —
a deployment already fronted by a VPN or an authenticating proxy. Silences
the development-only "no access control" warning. Production refuses to
start without `requireRole` unless this is `true` AND the admin is
`readOnly`. |

`requireRole` is the admin-wide gate, and nothing else in FlowPanel demands a
session: a config with no `requireRole` here, none on any resource, and no
global `scope` serves every route and page to anonymous visitors. `defineAdmin`
prints a warning about that in development. A deployment that is open on
purpose — behind a VPN or an authenticating proxy — sets
`allowUnauthenticated: true` to say so and silence the warning. Production is
stricter: a config without `auth.requireRole` refuses to compile unless it sets
both `allowUnauthenticated: true` and `readOnly: true`.

Five presets build an `AuthConfig` for you, all from `@flowpanel/kit/auth`
(`devAuth` is also on the package root):

| Preset | Session source | Role read from |
| --- | --- | --- |
| `withBetterAuth(opts)` | the better-auth instance you pass, via `auth.api.getSession` | `session.user.role` |
| `withClerk(opts?)` | Clerk's `auth()` helper, loaded lazily | `sessionClaims.publicMetadata.role` |
| `withNextAuth(opts)` | the `auth()` / `getServerSession` you pass | `session.user.role` |
| `withLucia(opts)` | the Lucia instance you pass | `user.role` |
| `devAuth(role?)` | a fixed `{ id: "dev" }` | the `role` argument, default `"admin"` |

`devAuth()` is for local development and examples only: it authenticates
nobody, sets no `requireRole`, and leaves the "no access control" warning
firing.

See [Auth with better-auth](/docs/guides/auth-with-better-auth) and
[Auth with Clerk](/docs/guides/auth-with-clerk) for the worked setups.

## ThemeConfig

**ThemeConfig**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `brand` | `object` | no | Product name, logo and home link shown in the shell header. |
| `accent` | `string` | no | Accent color as an HSL triplet, e.g. `"220 90% 50%"`. An `hsl(…)` wrapper is unwrapped. |
| `accentDark` | `string` | no | Dark-mode accent override, same format as `accent`. Custom accents tuned
for light surfaces usually fail WCAG contrast on dark ones — pass a
re-lightened triplet here to keep both schemes accessible. |
| `mode` | `"auto" \| "dark" \| "light"` | no | Initial color scheme. `"auto"` follows the operating system. |
| `cssVars` | `Record<string, string>` | no | Override any `--fp-*` design token, e.g. `{ "--fp-radius": "0.25rem" }`. |
| `cssVarsDark` | `Record<string, string>` | no | Dark-mode token overrides, scoped to FlowPanel's dark roots and portals.
Choose foreground tokens for contrast against the actual dark background. |
| `components` | `Partial<object>` | no | Replace built-in components. See the theme-slots guide. |
| `user` | `function` | no | What the account menu shows for the current session. Return `undefined` to
render no menu at all — the usual choice when there is no session. |

`components` is typed as `Partial<FlowpanelComponentSlots>` — see
[Theme slots](/docs/customization/theme-slots) for the ten shipped slots and how
to register your own.

`accent` and `cssVars` are emitted only on `[data-flowpanel-root]` and
`[data-flowpanel-portal]`. Dialogs and drawers carry the portal marker, so they
receive the theme without changing the host application's tokens.
`accentDark` and `cssVarsDark` target the same namespaced roots when
`html[data-flowpanel-theme="dark"]` or a FlowPanel root's `data-theme="dark"`
is active. Set `accentDark` whenever a custom `accent` is tuned for light surfaces;
use `cssVarsDark` when custom foreground or border tokens need dark-surface values.
Choose foreground values for contrast against the actual tinted background.
Every `--fp-*` token is overridable:

```ts excerpt
theme: {
  brand: { name: "Acme", logo: "/logo.svg", href: "/" },
  accent: "262 83% 58%",
  accentDark: "262 90% 72%",
  cssVars: { "--fp-radius": "0.25rem" },
  cssVarsDark: { "--fp-text-1": "0 0% 98%" },
}
```

Colors are HSL triplets, not `hsl(...)` calls, because the tokens are consumed
as `hsl(var(--fp-accent))`. Writing the wrapper form anyway is fine — it is
unwrapped for you.

`user` populates the account menu at the foot of the sidebar (or the right end
of the tab strip). Return nothing from it and no menu renders:

```ts excerpt
theme: {
  user: (session) => ({
    name: session?.user?.name,
    email: session?.user?.email,
    avatar: session?.user?.image,
    items: [{ label: "Settings", href: "/settings" }],
    signOut: "/api/auth/signout",
  }),
}
```

## ShellConfig

Pass a bare string when you only need the mode: `shell: "tabs"` is shorthand for
`shell: { mode: "tabs" }`.

**ShellConfig**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | `ShellMode` | no |  |
| `brand` | `object \| false` | no | Overrides `theme.brand`. `false` hides brand even in sidebar/tabs. |
| `skipLink` | `boolean` | no | Disable when the host application layout already renders a skip-to-content link. |

`ShellMode` is `"sidebar" | "tabs" | "bare"`:

- `"sidebar"` — full app shell with left nav and brand. The default, for
  standalone admins where FlowPanel owns the whole route.
- `"tabs"` — horizontal tab strip above the content, for admins embedded under
  a host app's existing header.
- `"bare"` — no chrome at all; your `app/layout.tsx` supplies it. Globals
  (toasts, drawer host, command palette, realtime) still mount.

The shell includes a skip-to-content link by default. Set `skipLink: false`
only when the host application layout already renders one for the same content
target; this avoids duplicate keyboard stops without removing the affordance.

## AuditConfig

**AuditConfig**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | no | Turn auditing on. Individual resources can opt out with `audit: false`. |
| `sink` | `function` | no | Where events go — your table, your log pipeline, anywhere. |

## SecurityConfig

Same-origin mutation protection is enabled when `security` is omitted. Add
exact `trustedOrigins` only when another frontend must write to the admin API.
Requests without browser `Origin` / Fetch Metadata headers remain available to
server-to-server clients.

**SecurityConfig** — Browser request protections applied by the Next.js runtime.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `sameOrigin` | `boolean` | no | Reject cross-origin write requests using Origin and Fetch Metadata headers.
Enabled by default. Disable only when an upstream gateway enforces an equivalent policy. |
| `trustedOrigins` | `array` | no | Additional exact origins allowed to submit writes, for example `https://ops.example.com`. |

### AuditEvent

What your `sink` receives. FlowPanel never writes audit rows itself — where they
land is entirely yours.

**AuditEvent** — One record handed to `AuditConfig.sink`.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `actorId` | `string \| null` | yes | Who acted, from `AuthConfig.userId`. Null when unresolvable. |
| `action` | `string` | yes | What ran, e.g. `"orders.action.refund"` or `"orders.inline-update"`. |
| `resource` | `string` | no | Resource name the action targeted. |
| `targetId` | `string` | no | Id of the affected row. Bulk actions carry the first ten, comma-joined. |
| `diff` | `object` | no | Before/after values, for mutations that can compute them. |
| `ip` | `string` | no |  |
| `userAgent` | `string` | no |  |
| `at` | `object` | yes |  |

## RateLimitConfig

`RateLimitConfig` is `RateLimitOptions` plus `per` (`"user"` or `"ip"`,
defaulting to `"user"`) and `enabled`.

```ts excerpt
type RateLimitOptions =
  | { driver: "memory"; limit: number; windowMs: number }
  | { driver: "redis"; url: string; limit: number; windowMs: number; keyPrefix?: string };
```

The memory driver counts per process — use the Redis driver whenever you run
more than one instance. A caller over the limit gets `429` from every API route.

## RealtimeConfig

An alias of `PublisherOptions`. Without it, realtime falls back to an in-process
bus that only reaches the instance that published — fine locally, wrong in
production. See [Realtime in production](/docs/guides/realtime).

## LabelsConfig

Overrides for built-in chrome. Set `labels` once in `defineConfig`; list filters,
pagination and the command palette use the same provider as action buttons.
Nested groups merge with English defaults. Undefined values keep their defaults;
empty strings are intentional overrides. A component's explicit placeholder or
boolean label takes precedence over configuration.

`allOption` controls the unfiltered choice in select, multiselect and boolean
filters. `filters` controls toolbar/reset text, boolean choices and text search.
`pagination.page` and `pagination.pageSize` accept `{n}`; `bulkBar.selected`
accepts `{n}` for selection counts. Pagination registry overrides receive these
strings in their `labels` prop; `DefaultPagination` remains usable standalone.

`navigation`, `table`, `savedViews` and `dateRange` also cover the built-in menu,
table controls, saved-view dialog and calendar. `dateRange.locale` controls
calendar and table-count presentation, with a deterministic `en-US` fallback on
both server and client. It does not change stored dates, query boundaries or
your custom column formatters.

`form` covers the generated create and edit pages: `createTitle` and `editTitle`
accept `{label}` for the resource's singular label, and `selectPlaceholder`,
`noOptions`, `searching` and `loadFailed` name the states of a select or a
reference search. The submit button uses `actions.create` on a create form and
`actions.save` on an edit form, and both pages offer a cancel link labelled with
`actions.cancel` that returns to the record or its list without submitting.

For a Russian admin, import `RU_LABELS` from `@flowpanel/kit` and pass
`labels: RU_LABELS` to `defineConfig`. The preset is a complete object of plain
strings, safe across the server/client boundary. Customize a nested group by
spreading it, for example `labels: { ...RU_LABELS, actions: {
...RU_LABELS.actions, new: "Добавить" } }`. Browser-only consumers can import the
same preset from `@flowpanel/core/labels`.

Resource names, domain actions, custom renderers and validation messages supplied
by your application remain application-owned. The preset does not translate
arbitrary content or server errors.

**LabelsConfig** — Localizable strings for FlowPanel's built-in chrome.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `noResults` | `string` | no | Empty list state — used when a resource list returns 0 rows. |
| `allOption` | `string` | no | Empty filter / select dropdown — the "any/all" sentinel. |
| `navigation` | `object` | no |  |
| `table` | `object` | no |  |
| `dateRange` | `object` | no |  |
| `savedViews` | `object` | no |  |
| `pagination` | `object` | no | Pagination chrome. |
| `filters` | `object` | no |  |
| `bulkBar` | `object` | no | Bulk action bar. |
| `searchPlaceholder` | `string` | no | Resource search input placeholder. Use `{label}` for the resource's label. |
| `actions` | `object` | no | Generic action buttons. |
| `form` | `object` | no | Generated create and edit form chrome. |
| `drawer` | `object` | no | Drawer chrome. |
| `detail` | `object` | no | Full-page detail chrome. |
| `related` | `object` | no | Related detail-tab chrome. |
| `notFound` | `object` | no | The admin's own not-found page, for a URL no resource or dashboard matches. |
| `formError` | `string` | no | Form-level banner shown when a submit fails with no field errors of its own. |
| `confirm` | `object` | no | Confirm dialog. |
| `widget` | `object` | no | Dashboard and detail-tab widget chrome. |
| `errors` | `object` | no | FlowPanel's own failure surfaces, shown in place of the data that did not load. |
| `palette` | `object` | no | ⌘K palette. |

## CommandPaletteConfig

**CommandPaletteConfig**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `groups` | `array` | no | Your own groups, listed after the built-in ones. |
| `placeholder` | `string` | no | Placeholder text for the input. Defaults to `labels.palette.placeholder`. |
| `disableNavigation` | `boolean` | no | Drop the built-in group that links to every resource. |
| `disableTheme` | `boolean` | no | Drop the built-in light/dark toggle. |

### CommandGroup

**CommandGroup** — A labelled block of entries in the palette.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | yes |  |
| `items` | `array` | yes |  |

### CommandItem

**CommandItem** — One entry in the ⌘K palette.
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | yes |  |
| `icon` | `IconName` | no | Serializable Lucide icon rendered beside the label. |
| `shortcut` | `string` | no | Shortcut hint shown on the right, e.g. `"⌘J"`. Display only — bind it yourself. |
| `keywords` | `array` | no | Extra terms that should match this item in search. |
| `action` | `object` | yes | What running the item does. Only `navigate` is supported. |

`CommandItem.icon` uses the same `IconName` registry as resources and actions;
see [resource icons](/docs/reference/resources#icons).

## ResolvedAdminConfig

What `defineAdmin` returns. The lookup maps are precomputed so request-time
routing is O(1).

**ResolvedAdminConfig**
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `__resolved` | `true` | yes |  |
| `resources` | `Resources` | yes | Tables the admin manages. |
| `resourcesByName` | `object` | yes |  |
| `dashboardsByPath` | `object` | yes |  |
| `pagesByPath` | `object` | yes |  |
| `queuesByKey` | `object` | yes |  |
| `basePath` | `string` | yes | Normalized `basePath` — leading slash, no trailing slash. Defaults to `/admin`. |
| `paths` | `object` | yes | Normalized mount points with leading slashes and no trailing slash. |
| `warnings` | `array` | yes | Config problems that do not stop the admin from booting — a create form that
cannot satisfy a required column, so far. `flowpanel doctor` prints them, and
development prints each one once. |
| `id` | `string` | no | Stable id surfaced in the wire client metadata (`client.id`, default `"local"`) to tell multi-admin deployments apart. |
| `adapter` | `object` | yes | Database binding — `drizzleAdapter`, `prismaAdapter`, or your own. |
| `auth` | `object` | yes | How the admin reads sessions and roles. |
| `scope` | `function` | no | Tenant scope resolved per request, then applied to every query. |
| `theme` | `object` | no | Branding, color scheme, design tokens and component overrides. |
| `shell` | `"bare" \| "tabs" \| "sidebar" \| object` | no | Chrome around the content area — sidebar, tabs, or none. |
| `labels` | `object` | no | Override built-in UI strings. |
| `formatting` | `object` | no | Locale, timezone and currency for numbers, money and timestamps. |
| `dashboards` | `array` | no | Widget dashboards. |
| `pages` | `array` | no | Custom pages rendered inside the shell. |
| `queues` | `array` | no | BullMQ queues surfaced in the admin. |
| `commandPalette` | `object` | no | What ⌘K offers. |
| `audit` | `object` | no | Audit trail for every mutation. |
| `realtime` | `PublisherOptions` | no | Cross-instance realtime transport. Redis in production. |
| `rateLimit` | `RateLimitConfig` | no | Throttle requests per user or per IP. |
| `security` | `object` | no | Browser request protections. Same-origin mutation checks are enabled by default. |
| `readOnly` | `boolean` | no | Put the whole admin in read-only mode. |
| `hooks` | `object` | no | Cross-cutting callbacks. |

`defineAdmin` throws if two resources share a name, two dashboards or pages
share a path, or two queues share a key.

It also throws when a config points at a resource name nothing resolves to —
`reference.resource` on a column or field, a drawer or detail tab's `resource`,
or a `table()` widget's `resource`. The names it checks against are the
*resolved* ones, so a Drizzle table declared as `pgTable("ai_usage", …)` is
`"ai_usage"` even when the exported variable is `aiUsage`. The error names the
config path and suggests the near match.

Bind the definition with `createFlowpanel(config)`, then mount its page and
handlers. The runtime comes from `@flowpanel/kit/next`:

```ts excerpt
// app/admin/[[...slug]]/page.tsx
import { createFlowpanel } from "@flowpanel/kit/next";
import config from "@/flowpanel.config";

const flowpanel = createFlowpanel(config);

export default flowpanel.page;
```

```ts excerpt
// app/api/flowpanel/[...route]/route.ts
import { createFlowpanel } from "@flowpanel/kit/next";
import config from "@/flowpanel.config";

const flowpanel = createFlowpanel(config);

export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } = flowpanel.handlers;
```

## Default bulk delete

When a resource has delete enabled (i.e. **not** `delete: { disabled: true }`)
and declares no `bulkActions`, `defineAdmin` injects a default `delete` bulk
action. Set `delete.confirm` to replace its confirmation description. Opt out
with `bulkActions: []` or `delete: { disabled: true }`.

`readOnly: true` short-circuits the whole rule: a read-only admin rewrites
every resource to `bulkActions: []` (and empties `actions` and the drawer's
actions, disables create/update/delete, drops `import`, and clears every
column's `editable`), so no default delete is injected for any resource.

## Reserved names

`defineAdmin` also throws when a resource name or a queue key is one of
`dashboards`, `drawer` or `queues`. Those are the first URL segments the
admin's own routing claims — `handlers()` matches `drawer` and `dashboards`
positionally, and the page router matches `queues` — so a resource named after
one has its requests captured by the wrong route. The error names the option
to change (`options.name` for a resource, `options.key` for a queue).

## Behavior beyond validation

`defineAdmin` does three things to your config on the way through, besides
checking it:

1. **Fills `columns`** from `adapter.introspect(ref)` for any resource that
   omitted them, in introspection order. If the adapter cannot introspect the
   ref, or reports zero columns, that throws.
2. **Injects the default bulk delete** (above).
3. **Normalizes `basePath`** and every dashboard / page `path` — a missing
   leading `/` is added, a trailing one stripped.

None of it is I/O: `introspect` is a synchronous
read of a table object or DMMF, not a database round trip.

### `basePath` and a Next.js deployment `basePath`

`basePath` and `paths.api` are app-relative: they say where the admin sits inside
your app, not where the app sits under a domain. If `next.config` sets its own
`basePath`, leave both alone — Next serves and links everything under that prefix
already.

FlowPanel adds the deployment prefix for you to the requests the browser issues
by itself: the client fetches built from `paths.api`, the generated form actions
and the SSE endpoint. `Link`, router navigation and `revalidatePath` are prefixed
by Next and are left untouched. Only a fetch you write yourself against
`paths.api` needs the helper:

```ts excerpt
import { withDeploymentBasePath } from "@flowpanel/kit/react"; // or "@flowpanel/core/paths" on the server

await fetch(`${withDeploymentBasePath(config.paths.api)}/user/u1/actions/verify`, {
  method: "POST",
});
```

### Formatting

`formatting` decides how the admin renders values, on the server and in the
browser alike:

```ts excerpt
formatting: { locale: "ru-RU", timeZone: "Europe/Moscow", currency: "RUB" },
```

`locale` governs the `number` and `money` column formats and the date shape;
`currency` is the default for a `money` column that names none, and a column's own
`currency` still wins. `timeZone` is what the server and the first paint use — after
hydration a timestamp shows in the reader's own zone, which is what a
`LocalTime` has always done.

With no `locale`, dates keep the sortable `YYYY-MM-DD HH:mm` shape they had
before, and numbers keep `en-US` grouping.

### Identifiers in URLs

Record identifiers are percent-encoded into the URL and decoded once when the page
route reads them back, so an id containing `/`, `%`, `?`, `#` or a space addresses
the record it names. Configured dashboard and page paths keep their separators —
`/reports/weekly` stays two segments.

A row whose projection did not include the key column is displayed but not treated
as addressable: no selection, no inline editing, no row activation. Keep the key
column in `columns`, or in `detail.tabs[].fields`, wherever the table has to act
on rows.
