Add self-referential parent/child relation on AffirmationCategory for one-level hierarchy. Update AffirmationForm with two-level category and sub-category dropdowns, server actions with find-or-create hierarchy logic, AffirmationList with Parent
svelte-admin
A lightweight, configuration-driven admin panel framework for SvelteKit + Prisma applications. Models are registered once in a central registry and automatically get list views, create modals, edit pages, and delete functionality — all wired to Prisma via dynamic delegates.
Architecture
src/lib/svelte-admin/ # Framework core
types.ts # AdminModelConfig type + supporting types
registry.ts # Model configurations (the main config file)
roles.ts # Role constants + access helpers
introspect.ts # Field label formatting + nested value resolution
prisma-delegate.ts # Dynamic Prisma delegate lookup + ID parsing
form-data.ts # FormData → typed object extraction
index.ts # Barrel re-exports
src/lib/components/admin/ # UI components
AdminTable.svelte # Sortable, searchable list table
AdminForm.svelte # Dynamic form (creates inputs from field config)
src/routes/(protected)/admin/ # SvelteKit routes
+layout.server.ts # Auth guard (requires STAFF role or higher)
+layout.svelte # Admin shell layout
+page.svelte # Home grid (cards linking to each model)
+page.server.ts # Loads all model configs for the home grid
[model]/+page.svelte # List view + create modal for a model
[model]/+page.server.ts # List loader + create action
[model]/[id]/+page.svelte # Edit view + delete for a single record
[model]/[id]/+page.server.ts # Record loader + update/delete actions
Adding a New Model
- Open
src/lib/svelte-admin/registry.ts. - Add a new object to the
modelsarray:
{
slug: 'widgets', // URL segment: /admin/widgets
modelName: 'Widget', // Prisma model name (PascalCase)
displayName: 'Widgets', // Plural display name
displayNameSingular: 'Widget', // Singular display name
idType: 'String', // 'String' or 'Int' — matches Prisma @id type
include: { category: true }, // Optional: Prisma include for relations
listFields: ['name', 'category.name', 'price'],
searchFields: ['name'],
formFields: ['name', 'categoryId', 'price'],
readonlyFields: ['id', 'createdAt'],
fieldTypes: {
name: 'String',
categoryId: 'Int',
price: 'Float',
createdAt: 'DateTime',
},
relationOptions: {
categoryId: {
model: 'Category',
labelField: 'name',
valueField: 'id',
},
},
}
- That's it. The dynamic
[model]routes handle everything else.
Configuration Reference
AdminModelConfig
| Field | Type | Required | Description |
|---|---|---|---|
slug |
string |
Yes | URL path segment. Use kebab-case (e.g. 'license-keys'). |
modelName |
string |
Yes | Prisma model name in PascalCase (e.g. 'LicenseKey'). Used to look up the Prisma delegate. |
displayName |
string |
Yes | Plural name shown in headings and the admin home grid. |
displayNameSingular |
string |
Yes | Singular name for create/edit headings and button labels. |
idType |
'String' | 'Int' |
Yes | Primary key type. Controls how the id URL param is parsed. |
include |
Record<string, boolean> |
No | Prisma include clause for eager-loading relations in list and edit views. |
listFields |
string[] |
Yes | Fields shown as columns in the list table. Supports dot-notation for relations (e.g. 'user.name'). |
searchFields |
string[] |
Yes | Fields searched by the table's search box. Should be scalar fields on the model. |
formFields |
string[] |
Yes | Fields shown in the create/edit form. Order determines form field order. |
readonlyFields |
string[] |
No | Fields shown as read-only text on the edit page (not editable). Typically ['id', 'createdAt', 'updatedAt']. |
listOnly |
boolean |
No | When true, suppresses edit links in the table. The create modal still works. Use for models where records are auto-generated or shouldn't be edited individually. |
fieldTypes |
Record<string, PrismaScalarType> |
Yes | Maps field names to their Prisma scalar type. Controls form input type and form data parsing. |
relationOptions |
Record<string, RelationOptionConfig> |
No | Configures dropdown selects for foreign key fields. |
accessFilter |
(viewerRoleId: number) => Record<string, unknown> | undefined |
No | Returns a Prisma where clause to filter list results based on the viewer's role. Return undefined for no filter. |
PrismaScalarType
'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'
Controls both the HTML input type in forms and how FormData values are parsed:
| Type | Input | Parsing |
|---|---|---|
String |
<input type="text"> |
.toString() |
Int |
<input type="number" step="1"> |
parseInt() |
Float |
<input type="number" step="0.01"> |
parseFloat() |
Boolean |
<input type="checkbox"> |
'on' or 'true' → true |
DateTime |
<input type="datetime-local"> |
new Date() |
RelationOptionConfig
{
model: 'UserRole', // Prisma model to query for options
labelField: 'name', // Field to display in the dropdown
valueField: 'id', // Field value submitted with the form
}
The framework runs findMany on the related model, selecting only labelField and valueField, and renders a <select> dropdown in the form.
Relations
To display a related field in the list table (e.g. show a user's role name):
- Add the relation to
include:{ role: true } - Use dot-notation in
listFields:'role.name'
To make a foreign key editable as a dropdown:
- Add the FK field to
formFields:'roleId' - Add its type to
fieldTypes:roleId: 'Int' - Configure
relationOptions:relationOptions: { roleId: { model: 'UserRole', labelField: 'name', valueField: 'id' } }
List-Only Models
Set listOnly: true on a model config to suppress edit links in the list table. Records still appear in the table and the create modal still works, but there's no way to click into an individual record to edit or delete it.
This is useful for models like license keys where records are auto-generated and shouldn't be manually edited.
Read-Only Fields
Fields listed in readonlyFields are displayed as plain text on the edit page (above the form) but cannot be modified. Common use: ['id', 'createdAt', 'updatedAt'].
If you want the edit page to show mostly read-only data with only one or two editable fields, move most fields from formFields to readonlyFields. For example, the Users config has formFields: ['roleId'] with everything else in readonlyFields.
Access Control
Admin layout guard
The admin layout (admin/+layout.server.ts) checks canAccessAdmin(user.roleId) — which requires roleId <= ROLES.STAFF (i.e. Super, Admin, or Staff). Users with higher roleId values get a 403.
Role constants
Defined in roles.ts:
ROLES.SUPER = 1 // Full access
ROLES.ADMIN = 2 // Admin access
ROLES.STAFF = 3 // Staff access (minimum for admin panel)
ROLES.USER = 4 // Regular user (no admin access)
Lower numbers = higher privilege.
Per-model filtering with accessFilter
The accessFilter function receives the viewer's roleId and returns a Prisma where clause that's merged into the list query. This lets you restrict which records different roles can see.
Example from the Users config:
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined; // Super sees all
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } }; // Admin sees Staff+User
}
return { roleId: { gt: ROLES.STAFF } }; // Staff sees only Users
}
Customizing the UI
AdminTable
Props: records, listFields, searchFields, modelSlug, listOnly?
Features:
- Client-side search across
searchFields - Click column headers to sort (ascending/descending toggle)
- Edit links per row (suppressed when
listOnlyis true)
AdminForm
Props: fields, fieldTypes, relationOptions?, relationOptionsData?, record?, form?, action, submitLabel?
Renders a <form> with inputs determined by fieldTypes and relationOptions. When record is provided, fields are pre-filled. The form prop carries SvelteKit action data for error display and field re-population after failed submissions.