Skip to content

Admin panels

An admin panel over any data - a database, an ORM, a REST API, a Laravel or Go backend - with server or client tables, tenants and plugins.

Updated View as Markdown

@rsc-kit/press builds an admin panel from a list of resources. Each one is a table, a form and a data source. The panel decides what each page shows and who may see it; a theme draws it - shadcn’s sidebar, data table and dialogs is the one that ships - and the app keeps everything that is its own: where the data lives, who may see it, and whose it is.

The package has three entry points:

  • @rsc-kit/press - resources, data sources, tenants, plugins and blocks. Nothing here draws.
  • @rsc-kit/press/admin - definePanel, and the url schemas its routes export.
  • @rsc-kit/press/forms - defineForm, for public forms drawn from a schema.
  • @rsc-kit/press/pages - definePages, for pages an editor builds from blocks.
  • @rsc-kit/press/shadcn - the shadcn theme, which draws both.

Install

The shadcn theme is styled by the app’s shadcn variables, so the app has Tailwind v4 and shadcn set up (create-rsc-kit --tailwind --shadcn does both):

bun add @rsc-kit/press
src/app/styles.csscss
@import "tailwindcss";
@import "shadcn/tailwind.css";
@import "@rsc-kit/press/shadcn.css";

The last line tells Tailwind to scan the theme’s components. Without it the panel renders unstyled.

The header’s theme menu is Light, Dark or System, and System - the default - follows the OS. It sets the dark class on <html> before the first paint, so give the root layout’s <html> suppressHydrationWarning, as shadcn’s own theme setup does. definePanel({ modeToggle: false }) hides it for an app with its own.

A resource

src/admin/resources/products.tsts
import * as z from 'zod'
import { defineResource } from '@rsc-kit/press'

export const products = defineResource<Product>()({
  name: 'products',
  key: 'id',
  title: 'name',
  source: productsSource,
  table: (col) => [
    col.text('name').searchable().sortable(),
    col.money('price').sortable(),
    col.badge('status'),
    col.boolean('featured').hidden(),
  ],
  filters: ['status'],
  form: z.object({
    name: z.string().min(2),
    price: z.number().positive(),
    status: z.enum(['draft', 'active']),
    featured: z.boolean(),
  }),
  fields: { description: { type: 'textarea' } },
})

Columns are text, money, badge, boolean, date and relation. .hidden() starts a column switched off in the Columns menu, and .cell((value, row) => ...) draws a cell yourself - a server component, so it can use the app’s own shadcn components.

The form is any Standard Schema

form is a Standard Schema of an object: zod, valibot, arktype, Effect Schema

  • whatever the app already uses. The panel never imports a validation library. The same schema checks every save on the server, and each refusal shows under the field it is about.

The fields are read from the schema. A schema that describes itself as JSON Schema - Standard JSON Schema, which zod 4.2+ implements - gives the form its fields, their types, which are required and an enum’s options. A schema that does not still validates; list its fields in fields, and the panel says so if you forget:

form: v.object({ title: v.pipe(v.string(), v.minLength(1)) }),
fields: { title: { type: 'text' } },

Checked in the browser first

The constraints the browser can check itself come from the schema too, as attributes on the controls: required, min and max, minlength and maxlength, an anchored pattern, type="email", and step="1" for an integer. A submit with a wrong field never leaves the page, and the browser’s message - in the visitor’s language - shows under the field in the same FieldError the server’s refusals use. With JavaScript off, the browser shows its own bubble instead.

The schema itself never reaches the browser, so this is the same for a schema in the app and for rules that live in Laravel or Go. It is a courtesy only: a custom message ('Like ABC-123') and anything the browser cannot know - a unique email - come from the server, which checks every save.

A number field arrives as a number, so the schema says z.number() - not z.coerce.number() - and is the same schema an API would use. A switch that is off is false, and an empty optional field is left out.

Any data source

A data source is five functions. The panel calls nothing else, so it is a database, an ORM, a REST API or a backend behind rpc() alike:

src/admin/resources/customers.tsts
import type { DataSource } from '@rsc-kit/press'

const customersSource: DataSource<Customer, Values> = {
  async list({ q, sort, dir, page, perPage, filters }, { tenant }) {
    const res = await fetch(`${API}/customers?${new URLSearchParams({ q: q ?? '', page: String(page) })}`)
    const { data, total } = await res.json()

    return { rows: data, total }
  },
  find: (id) => fetch(`${API}/customers/${id}`).then((r) => (r.ok ? r.json() : null)),
  create: (values) => post(`${API}/customers`, values),
  update: (id, values) => put(`${API}/customers/${id}`, values),
  delete: (id) => del(`${API}/customers/${id}`),
}

A save says “Product saved.” on the list it lands on, and a delete says “Product deleted.” in place, as a toast. Both use flash().

find answering null is a 404, for the page and for a save or delete of that id. memorySource(rows, key, make) is one for tests and demos.

A Laravel or Go backend

On a backend the five functions are rpc() calls. The source runs in the renderer, in a page and in the panel’s save, and both forward the visitor’s cookie, so auth()->user() is them:

src/admin/resources/products.tsts
const productsSource: DataSource<Product, Values> = {
  list: (query) => rpc('Products.list', query),
  find: (id) => rpc('Products.find', Number(id)),
  create: (values) => rpc('Products.create', values),
  update: (id, values) => rpc('Products.update', Number(id), values),
  delete: (id) => rpc('Products.delete', Number(id)),
}
app/Rsc/Products.phpphp
public function create(StoreProduct $request): Product
{
    return Product::create($request->validated());
}

The backend validates again, and its answer is the one that counts. A rule the schema cannot know - unique:products,sku, a stock check - comes back as a 422, and its messages show under the same fields as the schema’s, with the form’s values kept. Nothing to catch: the panel treats the backend’s refusal and its own the same way.

Choices that read as words

An enum’s options are shown humanized - 'in_stock' reads “In stock” in the form and the filter, and 'in_stock' is still what is saved. Say them yourself where that is not the word:

fields: { status: { options: ['draft', { value: 'active', label: 'Live' }] } },

An optional select starts on “None”, which saves nothing.

Relations

A belongsTo is a field that names another resource of the panel:

table: (col) => [col.text('name'), col.relation('categoryId', 'categories')],
form: z.object({ name: z.string(), categoryId: z.number().int() }),
fields: { categoryId: { relation: 'categories' } },

The form offers the categories as a select, labelled “Category”: each one’s key saved, its title shown. The column shows “Lighting”, not 3, and a client-mode table searches and sorts it by that word. A z.number() relation arrives as a number, as a number field does.

The choices are the related resource’s list with perPage: null, asked as the visitor - scoped to their tenant like any list - so a relation is for a resource with a select’s worth of rows: categories, teams, plans. A save checks the id it was sent with that resource’s find, as the visitor, so an id posted by hand from another tenant is refused under the field. A relation to a resource the panel does not have fails when the panel is defined.

Bulk actions

Every table that is not readOnly can tick rows; ticking shows what can be done to them below the table, with the panel’s own Delete. Add your own:

bulkActions: [
  { name: 'publish', label: 'Publish', done: 'published', run: (rows) => rpc('Products.publish', rows.map((row) => row.id)) },
  { name: 'archive', label: 'Archive', destructive: true, run: archiveAll },
],

run is given the rows themselves, each found by the source’s find as the visitor: an id the browser sent that is not theirs, or is already gone, never reaches it. It runs after authorize, like every save. A destructive action asks first, as Delete does. When it is done the table refreshes and a toast says “3 products published.” from done, as Delete says “deleted” - or whatever run returns as a string.

Server or client tables

mode: 'server' // the default
mode: 'client'

Server - each search, filter, sort and page is a request, and list is asked for one page (perPage a number). The url holds the state, so a table is a link, the back button works and it works with JavaScript off. For data that grows.

Client - list is asked once with perPage: null and returns every row; search, filters, sorting and paging happen in the browser with no request. For data that stays small - hundreds of rows, not thousands.

Who may use it

The panel has no login of its own. It asks the app’s:

src/admin/panel.tsts
import { definePanel } from '@rsc-kit/press/admin'
import { shadcn } from '@rsc-kit/press/shadcn'

export const panel = definePanel({
  path: '/admin',
  theme: shadcn,
  resources: [products, customers],
  authorize: async () => (await currentUser())?.isAdmin === true,
  user: currentUser,
  signOut: () => endSession(),
})

authorize runs on every page, and on every save, delete and team switch - an action is reachable without the page that rendered it, so a guard on the pages alone would not be one. A refusal is a 404. user fills the sidebar’s footer, and signOut runs in a server action and lands the visitor on /.

Tenants

tenancy: {
  label: 'Teams',
  tenants: () => teamsOf(currentUser()),
  current: () => teamFromSession(),
  switch: (id) => rememberTeam(id),
},

The sidebar gets a team switcher, and every data source call is told the tenant as its last argument - list(query, { tenant }). A current tenant that is not one of the visitor’s own - a cookie naming someone else’s team - is refused, so current reads the session and the panel checks membership. switch runs in a server action, so it may set a cookie.

perTenant((tenantId) => source) keeps a source per tenant, for data that is already split that way.

Mounting it

The engine finds routes, url schemas and sections by their files, so the panel is mounted with seven files of one or two lines each:

src/app/admin/middleware.ts                      export default panel.middleware
src/app/admin/layout.tsx                         export default panel.Layout
src/app/admin/page.tsx                           export default panel.Dashboard
src/app/admin/[resource]/page.tsx                export const params = resourceParams
                                                 export const searchParams = listSearch
                                                 export default panel.Index
src/app/admin/[resource]/admin-table.section.tsx export default panel.Table
src/app/admin/[resource]/new/page.tsx            export const params = resourceParams
                                                 export default panel.Create
src/app/admin/[resource]/[id]/page.tsx           export const params = recordParams
                                                 export default panel.Edit

resourceParams, recordParams, listSearch and pluginsSearch come from @rsc-kit/press/admin. The section must be named admin-table.section.tsx: a delete refreshes the table by that name.

Themes

theme is what draws the panel. The panel works out everything a page needs - whether the visitor may see it, which tenant’s rows, the save already bound to its record - and hands the theme the result as props. A theme is an object of components, typed AdminTheme from @rsc-kit/press/admin:

Component Draws
Shell The frame: brand, nav, user and crumbs arrive drawn, and the page as children
Brand, TenantSwitcher, Nav, Breadcrumbs, UserMenu What goes in the frame
Dashboard, Index, Form, Plugins Each page
Table A resource’s rows, in server or client mode
Cell A money, badge, boolean or date value
Loading What shows while a page waits for the request

Every prop is data, a server action already bound, or an element already drawn - never a component - so any of them may be a client component. The panel keeps the Suspense boundaries, because it knows which parts read the request; a theme only draws what shows meanwhile.

To change one page, spread the shipped theme and replace it:

src/admin/panel.tstsx
import { shadcn } from '@rsc-kit/press/shadcn'
import { BrandedDashboard } from './branded-dashboard'

export const panel = definePanel({
  theme: { ...shadcn, Dashboard: BrandedDashboard },
  // ...
})

Plugins

A plugin adds resources, dashboard widgets and a reaction to every save and delete, and is installed with plugins: [auditLog()]. Writing one to publish or sell is its own guide.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close