Browse documentation

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):

PresetSession sourceRole read from
withBetterAuth(opts)the better-auth instance you pass, via auth.api.getSessionsession.user.role
withClerk(opts?)Clerk's auth() helper, loaded lazilysessionClaims.publicMetadata.role
withNextAuth(opts)the auth() / getServerSession you passsession.user.role
withLucia(opts)the Lucia instance you passuser.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; 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

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:

  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:

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.