---
title: "Admin panels"
description: "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."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.rsc-kit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Admin panels

`@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](#themes) 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](/guides/public-forms/) drawn from a schema.
- `@rsc-kit/press/pages` - `definePages`, for [pages an editor builds from blocks](/guides/pages/).
- `@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):

```sh
bun add @rsc-kit/press
```

```css title="src/app/styles.css"
@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

```ts title="src/admin/resources/products.ts"
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:

```ts
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:

```ts title="src/admin/resources/customers.ts"
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()`](/guides/server-actions#a-message-for-the-next-page).

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

```ts title="src/admin/resources/products.ts"
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)),
}
```

```php title="app/Rsc/Products.php"
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:

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

An optional select starts on "None", which saves nothing.

## Files and images

A field the schema holds as a string - the url of what was uploaded - can be
an upload instead:

```ts title="src/admin/resources/products.ts"
form: z.object({ name: z.string(), photo: z.url().nullable().optional() }),
fields: {
  photo: { type: 'image', storage: uploads, maxSize: 2 * 1024 * 1024 },
},
```

`storage` is where the file goes. It is one function, and press ships none,
as it ships no database:

```ts title="src/storage.ts"
import type { Storage } from '@rsc-kit/press'

export const uploads: Storage = {
  async put(file, { field, tenant }) {
const key = `uploads/${crypto.randomUUID()}`

await Bun.s3.write(key, file, { type: file.type })

return `https://cdn.example.com/${key}`
  },
}
```

What `put` answers is what the record saves. On a Worker the same function
writes to an R2 binding; on a server, to a disk the app serves. A Laravel or Go
backend gets the url, not the file - `rpc()` carries JSON - so point `put` at
the bucket the backend's own disk reads, and the url means the same on both
sides.

The form shows what the field holds, a preview of a new image as it is chosen,
and, when the schema allows `null`, a **Remove** button that saves `null`.

- **Upload last.** The form is checked first with the file still a file; only
  when nothing else is wrong is it stored. A refused save leaves nothing in
  the bucket.
- **Checked twice.** `accept` and `maxSize` are checked in the browser as the
  file is chosen and again on the server. An image field takes PNG, JPEG,
  WebP, GIF and AVIF unless it says `accept`. Not SVG: an SVG can carry script,
  and served from your own origin it runs as your app.
- **Kept values are checked.** A save that chooses nothing sends back what
  the field held, and the server keeps it only if the record already had it.
  A posted url is not an upload.
- **The body limit.** Files arrive in the action's body, 8 MB unless the host's
  `maxActionBody` says more. Set `maxSize` below it, so the visitor reads which
  field is too large rather than a refused request.
- **Replaced files stay.** Removing or replacing an upload does not delete the
  old file; a bucket lifecycle rule, or a job that sweeps keys no record
  names, does that.

The field works the same in a [block](/guides/pages/) and in a
[public form](/guides/public-forms/).

## Relations

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

```ts
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:

```ts
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

```ts
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:

```ts title="src/admin/panel.ts"
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

```ts
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:

```tsx title="src/admin/panel.ts"
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](/guides/admin-plugins/).

Source: https://docs.rsc-kit.dev/guides/admin/index.mdx
