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.
| Layer | Context | Can contain | Must not contain |
|---|---|---|---|
resource.ts | Shared server + client | Table metadata, schema selectors, route keys, permission keys, data shape | DB queries, joins, ilike, remote functions, Svelte components, TanStack renderers, form layout |
.remote.ts | Server only | query()/form() generation, DB filters, joins, permissions, redirects, loaders, custom mutations | Plain object exports, client UI, Svelte renderers |
| route/page | Client/UI boundary | TanStack columns, Svelte headers/cells, form widgets, layout/order, loader wiring | DB 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
ColumnDefrenderers - 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, appliesoverviewToQuery, and returns paginated rowscreateForm: runs the create gate, inserts data, and redirects to the new record orbasePathgetQuery: runs the get gate and selects one record byidupdateForm: runs the update gate and updates one record by routeiddeleteForm: runs the delete gate, deletes one record by routeid, and redirects tobasePathgetResourceAccessQuery: returnstrueorfalsefor 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' })returnstrue - 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
getResourceAccessQueryforget,update, anddelete - Audit logs render when the page passes the generated
getAuditLogQuery - Audit logs use the resource's Drizzle table and
getgates 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:
filterSchemainresource.tsdefines the available filter fields and their input typesfiltersin.remote.tstranslates 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.Overviewchecks create accessMonoStack.Createchecks create accessMonoStack.Updatechecks 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.