Skip to main content

Resource Admin Guide

@repo/resource-admin removes CRUD boilerplate by combining one shared resource contract, generated server remote functions, and default Svelte admin pages.

The exported UI namespace is MonoStack:

<MonoStack.Overview resource={usersResource} {listQuery} {getResourceAccessQuery} />

Boundaries

Keep each layer small and import-safe.

LayerContextCan containMust not contain
resource.tsShared server + clientTable metadata, schema selectors, route keys, permission keys, data shapeDB queries, joins, ilike, remote functions, Svelte components, TanStack renderers, form layout
.remote.tsServer onlyquery()/form() generation, DB filters, joins, permissions, redirects, loaders, custom mutationsPlain object exports, client UI, Svelte renderers
route/pageClient/UI boundaryTanStack columns, Svelte headers/cells, form widgets, layout/order, loader wiringDB logic, permission checks, remote wrappers

resource.ts may import Drizzle table metadata only when the schema file is client-import-safe. If the schema file imports server-only connection code, split the table metadata from the DB connection code.

Dynamic imports do not change ownership. Do not put () => import('./Cell.svelte') in a resource file; that still couples a shared data contract to UI.

Basic Flow

Create one shared resource, generate flat remotes, then render the default pages.

1. Define The Resource

defineResource is the shared contract. It contains keys, routes, permissions, and schema selectors. It contains no UI, DB filtering, or server logic.

// domains/exampleModels/resource.ts
import { defineResource } from '@repo/resource-admin'
import { exampleModelsTable } from './database/dbSchema'

export const exampleModelsResource = defineResource(exampleModelsTable, {
key: 'exampleModels',
basePath: '/example-models',
listSchema: schema => schema.pick({ name: true }),
insertSchema: schema => schema.pick({ name: true, description: true }),
updateSchema: schema => schema.pick({ name: true, description: true }),
filterSchema: schema => schema.pick({ name: true }),
})

Resource files should not contain:

  • Display labels or table headers
  • Svelte components
  • TanStack ColumnDef renderers
  • Form field widget choices like textarea/select
  • Form order, sections, or styling
  • DB filter functions
  • Loader implementations
  • Gate functions or auth checks

Default UI labels are derived client-side from field keys, for example createdAt becomes Created at. Move copy and rendering to page-level overrides when it matters.

2. Configure The Server Helper

Configure the app DB resolver once. The returned createRemoteFunctions injects getDb into every resource.

// src/lib/resourceAdmin.server.ts
import { getTenantDb } from '$lib/db/managedDb.server'
import { createResourceAdminServer } from '@repo/resource-admin/server'

export const { createRemoteFunctions } = createResourceAdminServer({ getDb: getTenantDb })

3. Generate Remote Functions

.remote.ts files should export flat remote functions.

// domains/exampleModels/database/exampleModels.remote.ts
import { createRemoteFunctions } from '$lib/resourceAdmin.server'
import { exampleModelsResource } from '../resource'

export const {
listQuery,
createForm,
getQuery,
updateForm,
deleteForm,
getResourceAccessQuery,
} = createRemoteFunctions(exampleModelsResource)

Generated functions:

  • listQuery: runs the list gate, applies overviewToQuery, and returns paginated rows
  • createForm: runs the create gate, inserts data, and redirects to the new record or basePath
  • getQuery: runs the get gate and selects one record by id
  • updateForm: runs the update gate and updates one record by route id
  • deleteForm: runs the delete gate, deletes one record by route id, and redirects to basePath
  • getResourceAccessQuery: returns true or false for one UI access check

Overrides replace only the implementation. createRemoteFunctions stays responsible for query()/form(), validation schemas, gates, params, and redirects.

4. Render The Overview Page

<!-- routes/(admin)/example-models/+page.svelte -->
<script lang="ts">
import { MonoStack } from '@repo/resource-admin/components'
import { exampleModelsResource } from '$lib/domains/exampleModels/resource'
import { listQuery, getResourceAccessQuery } from '$lib/domains/exampleModels/database/exampleModels.remote'
</script>

<MonoStack.Overview resource={exampleModelsResource} {listQuery} {getResourceAccessQuery} />

Default behavior:

  • Columns are generated from listSchema
  • Filters are generated from filterSchema
  • The add action is shown only when getResourceAccessQuery({ action: 'create' }) returns true
  • Server remote functions still enforce the same gates

5. Render The Create Page

<!-- routes/(admin)/example-models/add/+page.svelte -->
<script lang="ts">
import { MonoStack } from '@repo/resource-admin/components'
import { exampleModelsResource } from '$lib/domains/exampleModels/resource'
import { createForm, getResourceAccessQuery } from '$lib/domains/exampleModels/database/exampleModels.remote'
</script>

<MonoStack.Create resource={exampleModelsResource} {createForm} {getResourceAccessQuery} />

Default behavior:

  • Form fields are generated from insertSchema
  • Validation comes from the resource schemas
  • Submit uses createForm
  • Visibility uses getResourceAccessQuery({ action: 'create' })

6. Render The Update Page

<!-- routes/(admin)/example-models/[id]/+page.svelte -->
<script lang="ts">
import { MonoStack } from '@repo/resource-admin/components'
import { exampleModelsResource } from '$lib/domains/exampleModels/resource'
import { getAuditLogQuery, getQuery, updateForm, deleteForm, getResourceAccessQuery } from '$lib/domains/exampleModels/database/exampleModels.remote'
</script>

<MonoStack.Update
resource={exampleModelsResource}
{getQuery}
{updateForm}
{deleteForm}
{getResourceAccessQuery}
getAuditLogs={getAuditLogQuery}
/>

Default behavior:

  • Loads the record with getQuery({ id: page.params.id })
  • Seeds form fields from the loaded data
  • Submit uses updateForm
  • Delete uses deleteForm
  • Visibility uses getResourceAccessQuery for get, update, and delete
  • Audit logs render when the page passes the generated getAuditLogQuery
  • Audit logs use the resource's Drizzle table and get gates server-side

Overview Customization

MonoStack.Overview is the default table/list page. Use it while the page still follows the normal CRUD list flow.

Filters

Filters have two parts:

  • filterSchema in resource.ts defines the available filter fields and their input types
  • filters in .remote.ts translates those fields to DB conditions

Level 1: auto filter form from filterSchema.

export const usersResource = defineResource(usersTable, {
filterSchema: schema => schema.pick({ name: true, status: true }),
})
<MonoStack.Overview resource={usersResource} {listQuery} {getResourceAccessQuery} />

Level 2: custom filter visualization in the page.

<script lang="ts">
import { TypedForm } from '@repo/form'
import { MonoStack } from '@repo/resource-admin/components'
</script>

<MonoStack.Overview resource={usersResource} {listQuery} {getResourceAccessQuery}>
<MonoStack.FilterForm schema={usersResource.filterSchema}>
{#snippet children({ fields })}
<MonoStack.FilterField field={fields.name} />
<MonoStack.FilterField
field={fields.status}
input={TypedForm.SelectInput}
optionItems={statusOptions}
/>
{/snippet}
</MonoStack.FilterForm>
</MonoStack.Overview>

Filter functions are server-only.

// domains/users/database/users.remote.ts
import { ilike } from 'drizzle-orm'
import { createRemoteFunctions } from '$lib/resourceAdmin.server'
import { usersResource } from '../resource'

export const { listQuery, getResourceAccessQuery } = createRemoteFunctions(usersResource, {
filters: {
name: ({ table, value }) => value ? ilike(table.name, `%${value}%`) : undefined,
},
})

Use an override when the list query is fully custom.

export const { listQuery, getResourceAccessQuery } = createRemoteFunctions(usersResource, {
overrides: {
list: async ({ data, dbContext, resource }) => {
// custom list query logic
},
},
})

Columns

Default columns come from listSchema. Custom columns use TanStack and stay client-side in the route page.

<script lang="ts">
import { renderComponent } from '@repo/table/remote'
import { MonoStack } from '@repo/resource-admin/components'
import UserNameCell from './UserNameCell.svelte'
import { usersResource } from '$lib/domains/users/resource'
import { listQuery, getResourceAccessQuery } from '$lib/domains/users/database/users.remote'
</script>

<MonoStack.Overview
resource={usersResource}
{listQuery}
{getResourceAccessQuery}
columns={(ch) => [
ch.accessor('name', {
cell: ({ row }) => renderComponent(UserNameCell, { user: row.original }),
}),
ch.accessor('email'),
]}
/>

Use custom columns for:

  • Custom labels
  • Formatted cells
  • Svelte cell renderers
  • Row actions
  • Derived display values

Table Options

Pass extra TanStack table options through tableOptions.

<MonoStack.Overview
resource={usersResource}
{listQuery}
{getResourceAccessQuery}
tableOptions={{
enableSorting: true,
enableColumnVisibility: true,
}}
/>

Use listQuery directly with DataGrid/useDataGrid when MonoStack.Overview no longer fits.

<script lang="ts">
import { DataGrid, useDataGrid } from '@repo/table/remote'
import { listQuery } from '$lib/domains/users/database/users.remote'

const { table, dataGrid } = useDataGrid({ query: listQuery })
</script>

<DataGrid.Root {table} {dataGrid} />

Create And Update Forms

MonoStack.Create and MonoStack.Update use the resource schemas and generated forms. Form rendering and layout stay in the page.

Default Create

<MonoStack.Create resource={usersResource} {createForm} {getResourceAccessQuery} />

Default Update

<MonoStack.Update
resource={usersResource}
{getQuery}
{updateForm}
{deleteForm}
{getResourceAccessQuery}
/>

Custom Visualization

Use the form snippet for field widgets, order, sections, help text, and page-specific layout.

<script lang="ts">
import { TypedForm } from '@repo/form'
import { MonoStack } from '@repo/resource-admin/components'
</script>

<MonoStack.Create
resource={exampleModelsResource}
{createForm}
{getResourceAccessQuery}
>
{#snippet form({ fields })}
<section class="space-y-4">
<h2 class="text-lg font-medium">Basics</h2>
<TypedForm.RemoteField field={fields.name} />
<TypedForm.RemoteField
field={fields.description}
input={TypedForm.TextareaInput}
rows={5}
/>
</section>
{/snippet}
</MonoStack.Create>

Update snippets also receive the loaded record.

<MonoStack.Update
resource={usersResource}
{getQuery}
{updateForm}
{deleteForm}
{getResourceAccessQuery}
>
{#snippet form({ fields, data })}
<TypedForm.RemoteField field={fields.name} />
<p class="text-sm text-muted-foreground">Editing {data.id}</p>
{/snippet}
</MonoStack.Update>

Use the actions snippet for page-specific header actions.

<MonoStack.Update
resource={exampleModelsResource}
{getQuery}
{updateForm}
{deleteForm}
{getResourceAccessQuery}
>
{#snippet actions({ id })}
<ActionButton.Remote form={startExampleModelJobForm}>
<input type="hidden" name="id" value={id} />
</ActionButton.Remote>
{/snippet}
</MonoStack.Update>

Custom Server Logic

Override only the implementation in createRemoteFunctions; schemas, query()/form(), auth, params, and redirects stay generated.

export const { createForm, updateForm, getResourceAccessQuery } = createRemoteFunctions(usersResource, {
overrides: {
create: async ({ data, dbContext, resource }) => {
// custom insert/junction table logic
return { id: createdUserId }
},
update: async ({ id, data, dbContext, resource }) => {
// custom update/junction table logic
},
},
})

Use TypedForm.Remote directly or write form()/query() manually when the MonoStack component API no longer fits.

Permissions And Access

Permissions are implemented as Gate functions from @repo/auth. resource.permission is the default permission domain, and gates.domain can override it in .remote.ts.

createRemoteFunctions creates default backend gates for every generated CRUD action and uses the same gate resolver for generated functions and getResourceAccessQuery.

Input shape:

import type { ResourceAccessInput } from '@repo/resource-admin'

type ResourceAccessInput =
| { action: 'list' | 'create', id?: never }
| { action: 'get' | 'update' | 'delete', id: string }

Default permission mapping:

list: permissionGate('users', ['list'])
get: permissionGate('users', ['read'])
create: permissionGate('users', ['create'])
update: permissionGate('users', ['update'])
delete: permissionGate('users', ['delete'])

Pure Auto

export const usersResource = defineResource(usersTable)

export const {
listQuery,
createForm,
getQuery,
updateForm,
deleteForm,
getResourceAccessQuery,
} = createRemoteFunctions(usersResource)

Base Gates

Keep the default permission gates and add gates that apply to every generated action.

import { createRemoteFunctions } from '$lib/resourceAdmin.server'
import { hasActiveSubscriptionGate } from '$lib/domains/users/gates.server'
import { usersResource } from '../resource'

export const { listQuery, getResourceAccessQuery } = createRemoteFunctions(usersResource, {
gates: {
domain: 'users',
base: [hasActiveSubscriptionGate],
},
})

Extra Gates Per Action

Action overrides are functions. They receive the default action gates, so they can extend or replace them intentionally. Final gates are base plus the returned action gates.

import { createRemoteFunctions } from '$lib/resourceAdmin.server'
import { hasActiveSubscriptionGate } from '$lib/domains/users/gates.server'
import { usersResource } from '../resource'

export const { createForm, getResourceAccessQuery } = createRemoteFunctions(usersResource, {
gates: {
domain: 'users',
create: ({ defaults }) => [...defaults, hasActiveSubscriptionGate],
},
})

Contextual Gates

Use a factory when a gate needs action context such as id.

export const { updateForm, getResourceAccessQuery } = createRemoteFunctions(usersResource, {
gates: {
domain: 'users',
update: ({ defaults, id }) => [...defaults, canEditUserGate(id)],
},
})

Return a new array without defaults only when the action should replace the generated permission gate.

export const { deleteForm, getResourceAccessQuery } = createRemoteFunctions(usersResource, {
gates: {
delete: ({ id }) => [canDeleteUserGate(id)],
},
})

Why Gates Live In The Backend

Frontend checks are UI hints only. They can hide buttons and pages, but they do not protect data.

Backend gates can use DB checks, tenant settings, feature flags, subscriptions, or other server-only state. Keep them in .remote.ts, not resource.ts, so shared resource metadata stays safe for client imports.

getResourceAccessQuery uses the same backend resolver as generated queries and forms, so UI visibility mirrors backend enforcement.

UI Visibility

getResourceAccessQuery answers one access question and returns a boolean. It is only for UI visibility; generated server functions always check gates internally.

const canCreate = await getResourceAccessQuery({ action: 'create' })
const canUpdate = await getResourceAccessQuery({ action: 'update', id })

getResourceAccessQuery is required by the default components:

  • MonoStack.Overview checks create access
  • MonoStack.Create checks create access
  • MonoStack.Update checks get, update, and delete access

Write query()/form() manually when auth no longer maps to generated CRUD actions.

Current Scope

v1 focuses on simple single-table CRUD.

Relations, many-to-many editing, relation-aware loaders, and automatic nested forms should be added as server-side layers later. Resource files may describe data shape, but loader implementations stay remote/server-side.