@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@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
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:
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:
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)),
}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:
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.EditresourceParams, 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:
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.