defineAdmin
The config entry point and every option it accepts.
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.
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.
Prop
Type
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.
Prop
Type
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 and Auth with Clerk for the worked setups.
ThemeConfig
Prop
Type
components is typed as Partial<FlowpanelComponentSlots> — see
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:
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:
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" }.
Prop
Type
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; yourapp/layout.tsxsupplies 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
Prop
Type
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.
Prop
Type
AuditEvent
What your sink receives. FlowPanel never writes audit rows itself — where they
land is entirely yours.
Prop
Type
RateLimitConfig
RateLimitConfig is RateLimitOptions plus per ("user" or "ip",
defaulting to "user") and enabled.
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.
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.
Prop
Type
CommandPaletteConfig
Prop
Type
CommandGroup
Prop
Type
CommandItem
Prop
Type
CommandItem.icon uses the same IconName registry as resources and actions;
see resource icons.
ResolvedAdminConfig
What defineAdmin returns. The lookup maps are precomputed so request-time
routing is O(1).
Prop
Type
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:
// app/admin/[[...slug]]/page.tsx
import { createFlowpanel } from "@flowpanel/kit/next";
import config from "@/flowpanel.config";
const flowpanel = createFlowpanel(config);
export default flowpanel.page;// 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:
- Fills
columnsfromadapter.introspect(ref)for any resource that omitted them, in introspection order. If the adapter cannot introspect the ref, or reports zero columns, that throws. - Injects the default bulk delete (above).
- Normalizes
basePathand every dashboard / pagepath— 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:
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:
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.