Browse documentation

Next.js runtime

createFlowpanel, generated pages, route handlers, protected controllers and realtime helpers.

@flowpanel/kit/next binds one config to every Next.js surface: the generated page, HTTP handlers, request-scoped protected controllers, client metadata and realtime publisher.

createFlowpanel

Bind one typed admin definition to all supported Next.js runtime surfaces.

export function createFlowpanel<const Resources extends readonly AnyResourceConfig[]>(definition: AdminDefinition<Resources> | ResolvedAdminConfig<Resources>): FlowpanelRuntime<Resources>;

Create one module-level runtime and reuse it from your route files:

// src/flowpanel.ts
import { createFlowpanel } from "@flowpanel/kit/next";
import config from "@/flowpanel.config";

export const flowpanel = createFlowpanel(config);
// app/admin/[[...slug]]/page.tsx
import { flowpanel } from "@/src/flowpanel";

export default flowpanel.page;
// app/api/flowpanel/[...route]/route.ts
import { flowpanel } from "@/src/flowpanel";

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

createFlowpanel(definition) returns a frozen FlowpanelRuntime:

MemberPurpose
pageGenerated catch-all page component.
handlersFlowpanelHandlers for REST and generated-UI routes.
request()Current request's protected FlowpanelRequest.
clientSerializable FlowpanelClientMetadata for @flowpanel/client.
events.publish(channel, payload?)Namespaced, validated realtime event.
dispose()Idempotent runtime cleanup hook.

Payloads passed to events.publish must serialize to WireValue, use a valid channel name, and remain under 64 KiB. FlowpanelClientMetadata is safe to send to a client; it contains paths and protocol metadata, never sessions or adapter state.

Protected request controllers

await flowpanel.request() authenticates once and returns a request-local FlowpanelRequest. Its typed ResourceControllers map exposes a ResourceController<Row> for each configured resource; resource(name) is the dynamic-name alternative. ResourceListOptions<Row> supports page, page size, search, filters, sort, projection and soft-delete visibility.

const request = await flowpanel.request();
const result = await request.resources.orders.list({
  pageSize: 25,
  select: ["id", "status", "totalCents"],
  sort: { field: "createdAt", dir: "desc" },
});

Controllers return FlowpanelResult rather than throwing expected access, validation and not-found failures. They are not raw adapter shortcuts: every method applies admin/resource/operation policy, field projection, scope and the same mutation pipeline as the generated UI. Do not cache a FlowpanelRequest across requests.

The FlowpanelRuntime, FlowpanelRequest, ResourceControllers, ResourceController and ResourceListOptions types are exported for custom server components and framework adapters.

Flowpanel

Mount the admin UI as a Next.js page component.

export function Flowpanel(config: ResolvedAdminConfig, opts?: FlowpanelOptions): ({ params, searchParams }: PageProps) => Promise<React.JSX.Element>;

Flowpanel(config) is the compatibility page factory used internally by createFlowpanel(config).page. Prefer the runtime when wiring a new app.

Both props are required — the returned component awaits searchParams as well as params, because filters, pagination, the drawer and the dashboard date range all live in the query string. Next.js passes both to a page component, so export default Flowpanel(config) satisfies it; a hand-written wrapper has to forward both.

params is read structurally rather than by a fixed key: the first array- valued entry is taken as the catch-all segments, so the route folder may be [[...slug]], [...rest], or any other name.

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

export default Flowpanel(config);

Prop

Type

FlowpanelContent(config, opts?) is the same renderer without the shell, for embedding the admin inside chrome you already have. Its second parameter is Omit<FlowpanelOptions, "shell">shell is not yours to set there, because FlowpanelContent is Flowpanel(config, { …opts, shell: "bare" }).

handlers

The catch-all `/api/flowpanel/[...route]/route.ts` handler.

export function handlers(config: ResolvedAdminConfig): FlowpanelHandlers;

handlers(config) is the compatibility handler factory used internally by createFlowpanel(config).handlers. It returns the FlowpanelHandlers map:

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

export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } = handlers(config);

Each method is a RouteHandler: (request, context) => Promise<Response>. RouteContext carries the catch-all route params. Unsupported methods return the stable method_not_allowed envelope; OPTIONS advertises the enabled method/CORS contract.

The routes it mounts

All paths are relative to /api/flowpanel.

MethodPathPurpose
GET/<resource>Typed list result for custom clients
GET/<resource>/<id>One projected row
POST/<resource>Create through the v1 JSON API
PATCH / PUT/<resource>/<id>Update through the v1 JSON API
DELETE/<resource>/<id>Delete through the v1 JSON API
GET/drawer/<resource>/<id>Drawer payload for one row
GET/<resource>/reference/<field>Options for a reference picker
POST/<resource>/createCreate form submit
POST/<resource>/<id>/editEdit form submit
POST/<resource>/<id>/updateInline cell edit
POST/<resource>/<id>/restoreUndo a soft delete
POST/<resource>/importCSV / JSON import
POST/<resource>/<id>/actions/<action>Row action
POST/<resource>/bulk-actions/<action>Bulk action
POST/drawer/<resource>/<id>/actions/<action>Drawer action
POST/dashboards/<path>/actions/<action>Dashboard action

Every one of them runs the same guard pipeline before its own work: the admin-wide role gate and rate limit, the resource gate, global read-only for writes, then the action's requireRole. See Roles & permissions.

stream

export function stream(config: ResolvedAdminConfig, opts?: StreamOptions): (req: Request) => Promise<Response>;

The SSE endpoint backing realtime. Mount it on its own route.

export function stream(
  config: ResolvedAdminConfig,
  opts?: StreamOptions,
): (req: Request) => Promise<Response>;

StreamOptions has one member — heartbeatMs, the interval between : keep-alive comments, defaulting to 15 s.

// app/api/flowpanel/stream/route.ts
import { stream } from "@flowpanel/kit/next";
import config from "@/flowpanel.config";

export const GET = stream(config);
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

flowpanel init scaffolds exactly that file, both route-segment exports included: an SSE response has to stay open and must never be statically rendered.

What the stream checks

No FlowPanel route requires a session — the stream is not a special case. Every gate in the framework is a role check, and a role check with no requirement configured passes immediately. An admin with no auth.requireRole, no per-resource requireRole and no global scope therefore answers anonymous requests on every route above as well as this one: list, detail, create, update, delete, row/bulk/dashboard actions, import and drawer. Set auth.requireRole (a role name, a list, or a predicate such as (s) => s !== null) to gate the whole admin. defineAdmin warns about this in development; a deployment that is open on purpose — behind a VPN or an authenticating proxy — declares auth.allowUnauthenticated to silence the warning, and in production must pair it with readOnly: true or the config refuses to compile.

When auth.requireRole is set and the caller's role fails it, the route answers 403 with { ok: false, error: "Forbidden" } — not 401, and not an open pipe. There is no separate "authenticated" check: a requireRole predicate is the only thing that can reject a session.

Beyond that gate, stream bounds what one connection may ask for:

LimitValueBehavior when exceeded
Channels per connection25Extra ?channel= values are dropped, not rejected
Channel name charset/^[A-Za-z0-9_.:-]{1,128}$/Non-matching names are silently ignored
Per-resource role gateresource.<name> channelsDropped when the resource's own requireRole fails
Heartbeat15s (heartbeatMs)A : keep-alive comment keeps proxies from closing the response

Dropping rather than rejecting is deliberate: one bad channel in a batch must not kill a subscription to the other 24. A channel your client asked for but never receives events on is the symptom of hitting one of the first three rows.

Realtime helpers

function publish(channel: string, payload?: unknown): Promise<void>;
function publishResource(
  name: string,
  event: { action: "create" | "update" | "delete"; id?: string },
): Promise<void>;
function subscribe(channel: string, handler: (payload: unknown) => void): () => void;
function bindPublisher(config: ResolvedAdminConfig): void;

publishResource(name, event) publishes to resource.<name> — the channel a resource subscribes to when it sets realtime: true. Use it from a worker or a webhook to make lists refresh:

import { publishResource } from "@flowpanel/kit/next";

await publishResource("orders", { action: "update", id: order.id });

Inside an action, prefer ctx.publish — it is the same publisher, already bound.

bindPublisher is called for you by handlers, Flowpanel and stream. You only need it when publishing from code that never goes through any of them — a standalone worker or cron, and your own route handlers, which on serverless are their own instances.

The same three functions are re-exported from @flowpanel/kit/server, against the same process-wide store, so importing from either subpath in one process is equivalent: a publish sent through /server reaches a subscribe registered through /next. That subpath carries a smaller surface — publish, publishResource, bindPublisher, plus emitAudit, requireRole, and the request-context accessors — for server code that has no business importing the Next.js runtime wholesale.

Publishing before bindPublisher has run in that process falls back to an in-memory publisher, which no other process can see — a Redis-configured deployment loses the event. FlowPanel logs one console.warn the first time this happens instead of degrading quietly, so a worker that "publishes but nothing refreshes" says so in its own logs.

Route handlers

handlers(config) wires the table below to the routes listed earlier in The routes it mounts. Each factory takes ResolvedAdminConfig and returns a Next.js GET/POST handler; mount one directly only when you need it on a path of your own, or wrapped with extra logic handlers doesn't give you a seam for.

resourceCreateRoute / resourceUpdateRoute — the create and edit form submit handlers, behind POST /<resource>/create and POST /<resource>/<id>/edit. Both parse FormData, coerce it against the adapter's introspected columns, and run it through makeActions.

rowActionRoute — runs a resource's row action, behind POST /<resource>/<id>/actions/<action>.

bulkActionRoute — runs a resource's bulk action over a set of selected ids, behind POST /<resource>/bulk-actions/<action>.

dashboardActionRoute — runs a dashboard action, behind POST /dashboards/<path>/actions/<action>. The <path> segment is the dashboard's path run through encodeDashboardPath.

drawerActionRoute — runs a drawer action, behind POST /drawer/<resource>/<id>/actions/<action>.

drawerRoute — the drawer payload handler, behind GET /drawer/<resource>/<id>. Resolves the row, its tabs, and its actions into the DrawerPayload the client drawer renders.

inlineUpdateRoute — the single-cell edit handler, behind POST /<resource>/<id>/update. Only accepts a field declared editable: true on the resource's columns.

Three of the routes in the table above have no exported factory: the reference-picker search (GET /<resource>/reference/<field>), the CSV / JSON import (POST /<resource>/import) and the soft-delete restore (POST /<resource>/<id>/restore) are handled inside handlers() and are not part of the package's export surface. Mount handlers(config) to get them; there is no supported way to mount one of the three on a path of your own.

Serialization pairs

Every action route serializes its config-side action object into a wire-safe shape before it reaches the client — dropping server-only fields like handler functions.

Config typeSerializerWire type
RowActionserializeRowActionSerializedRowAction
BulkActionserializeBulkActionSerializedBulkAction
DashboardActionserializeDashboardActionSerializedDashboardAction
Drawer action— (inline in drawerRoute)SerializedDrawerAction
Drawer tab— (inline in drawerRoute)SerializedDrawerTab

SerializedDashboardAction.form is a SerializedDashboardActionField[] — each entry is the wire-safe shape of one action form field (name, label, type, options, …), nested inside the action rather than serialized on its own.

applyActionResult

Applies the side effects of a successful ActionResult: publishes to the channels its refresh names (or publishResource when refresh: true and a resourceName is given), then revalidatePaths the given pathname unless refresh is explicitly false. Every action route calls this after a successful mutation — call it yourself only from a custom route.

Prop

Type

Dashboard path encoding

function encodeDashboardPath(path: string): string;
function decodeDashboardPath(encoded: string): string;

A dashboard's path (e.g. /reports/revenue) isn't a valid single URL segment, so dashboardActionRoute's <dashboard> param is the path run through encodeDashboardPath. Use the same pair when linking to or parsing a dashboard action route by hand.

makeActions

The create / update / delete pipeline that the form routes use, available directly for scripts and custom routes. It applies the resource's schema, field rules, scope and audit — the same path the UI takes.

function makeActions(
  config: ResolvedAdminConfig,
  resource: ResourceConfig,
  opts?: MakeActionsOptions,
): ResourceActions;

Prop

Type

Prop

Type

The create and edit form routes answer with a FormActionResult:

Prop

Type

Pass reqCtx when you already built one, so the caller's session and scope carry through instead of being resolved a second time.

buildRequestContext

Builds the RequestContext every gate and query reads. It enforces auth.requireRole and the rate limit, so it throwsFlowpanelAccessError or FlowpanelRateLimitError. Catch it if you are writing your own route:

import { buildRequestContext } from "@flowpanel/kit/next";

let reqCtx;
try {
  reqCtx = await buildRequestContext({ req, config });
} catch (err) {
  // FlowpanelError carries the right status
  return Response.json({ ok: false, error: err.safeMessage }, { status: err.status });
}
function buildNav(config: ResolvedAdminConfig, reqCtx?: RequestContext): NavGroup[];
function resourceNavName(resource: ResourceConfig): string;

buildNav produces the same entries the sidebar and tab strip render, grouped as Dashboards, Pages, Resources and Queues. Useful when you build your own chrome around a "bare" shell. Pass the request context to apply every surface's requireRole; the built-in shell always does this. Omitting reqCtx keeps the context-free behavior for tooling that only needs the complete site map. Resource entries with hidden: true are omitted in either mode.

resourceNavName is @flowpanel/core's resolveResourceName — the same resolver defineAdmin uses to key resourcesByName and derive the resource.<name> SSE channel. It throws if a ref's name can't be resolved, never a silent fallback.