svelte-admin
A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a registry and get full CRUD UI with dynamic routing, role-based access control, searchable dropdowns, and related-record panels — no per-model route files needed.
Architecture
src/lib/svelte-admin/ # Framework core
├── types.ts # AdminModelConfig and related types
├── registry.ts # Model configurations (the single source of truth)
├── roles.ts # Role hierarchy and access helpers
├── prisma-delegate.ts # Dynamic Prisma delegate lookup
├── introspect.ts # Field label generation and dot-notation resolution
├── form-data.ts # FormData → typed object parser
└── index.ts # Barrel exports
src/routes/(protected)/admin/ # Dynamic admin routes
├── +layout.server.ts # Role-based access guard
├── +layout.svelte # Layout shell
├── +page.server.ts # Admin home (model grid)
├── +page.svelte # Admin home UI
├── [model]/
│ ├── +page.server.ts # List view + create action
│ ├── +page.svelte # List view UI
│ └── [id]/
│ ├── +page.server.ts # Edit view + update/delete actions
│ └── +page.svelte # Edit view UI
src/lib/components/admin/ # Reusable admin UI components
├── AdminTable.svelte # Sortable, searchable data table
├── AdminForm.svelte # Dynamic form with type-aware inputs
├── ComboBox.svelte # Searchable dropdown for FK fields
├── RelatedHasOne.svelte # Display panel for 1:1 related records
└── RelatedHasMany.svelte # Display table for 1:N related records
Adding a New Model
-
Define the Prisma model in
prisma/schema.prismaand create a migration:npm run migrate -- --name add_my_model -
Add a config object to the
modelsarray inregistry.ts:{ slug: 'widgets', // URL segment: /admin/widgets modelName: 'Widget', // Prisma model name (PascalCase, singular) 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' } } } -
Done. Visit
/admin/widgets. The dynamic[model]routes handle list, create, edit, and delete automatically.
No new route files, no new components. The registry is the only thing you touch.
Configuration Reference
AdminModelConfig
| Field | Type | Required | Description |
|---|---|---|---|
slug |
string |
Yes | URL segment (kebab-case). Used in /admin/{slug}. |
modelName |
string |
Yes | Prisma model name (PascalCase, singular). Maps to prisma[lcfirst(modelName)]. |
displayName |
string |
Yes | Plural display name for headings and navigation. |
displayNameSingular |
string |
Yes | Singular name for edit page headings. |
idType |
'String' | 'Int' |
Yes | Primary key type. Controls how the id URL param is parsed. |
include |
Record<string, boolean | { include: ... }> |
No | Prisma include clause for eager-loading relations. |
listFields |
string[] |
Yes | Table columns. Supports dot-notation for relations (e.g., 'role.name'). |
searchFields |
string[] |
Yes | Fields searched by the client-side search filter. |
formFields |
string[] |
Yes | Fields rendered in create/edit forms, in display order. |
readonlyFields |
string[] |
No | Fields shown as read-only on the edit page. |
listOnly |
boolean |
No | If true, suppresses edit links in the table. |
fieldTypes |
Record<string, PrismaScalarType> |
Yes | Maps field names to scalar types. Controls input rendering and FormData parsing. |
relationOptions |
Record<string, RelationOptionConfig> |
No | FK fields that render as ComboBox dropdowns. |
relatedRecords |
RelatedRecordConfig[] |
No | Has-one/has-many panels shown on edit pages. |
accessFilter |
(viewerRoleId: number) => where | undefined |
No | Returns a Prisma where clause to filter records by viewer role. undefined = no filter. |
PrismaScalarType
'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'
Controls both form input rendering and FormData parsing:
| Type | HTML Input | Parse Logic |
|---|---|---|
String |
<input type="text"> |
raw.toString() |
Int |
<input type="number" step="1"> |
parseInt(), empty → null |
Float |
<input type="number" step="0.01"> |
parseFloat(), empty → null |
Boolean |
<input type="checkbox"> |
'on' or 'true' → true |
DateTime |
<input type="datetime-local"> |
new Date(), empty → null |
RelationOptionConfig
{
model: 'UserRole', // Prisma model to query for options
labelField: 'name', // Field displayed in the dropdown
valueField: 'id' // Field value submitted with the form
}
RelatedRecordConfig
{
relationKey: 'profile', // Key on parent record (must match a key in `include`)
label: 'Profile', // Section heading on edit page
type: 'has-one', // 'has-one' or 'has-many'
displayFields: ['firstName', 'lastName'], // Fields shown in the panel/table
editSlug: 'profiles' // Optional: admin slug to link for editing
}
Field Types & Form Rendering
The fieldTypes mapping is the central mechanism connecting Prisma scalar types to HTML inputs
and form data parsing. Every field in formFields must have a corresponding entry in fieldTypes.
When a field also has a relationOptions entry, the framework renders a ComboBox (searchable
dropdown) instead of the default input for that type.
Relations
Foreign Key Dropdowns (relationOptions)
When a form field is a foreign key, add a relationOptions entry to render a searchable ComboBox:
{
formFields: ['name', 'categoryId'],
fieldTypes: { name: 'String', categoryId: 'Int' },
relationOptions: {
categoryId: {
model: 'ProclamationCategory',
labelField: 'name',
valueField: 'id'
}
}
}
The route loader automatically queries the related model and passes options to the ComboBox.
Eager Loading (include)
Use include to load related data for display in list tables or edit pages:
{
include: { category: { include: { parent: true } } },
listFields: ['name', 'category.name'] // dot-notation resolves nested values
}
Related Records
Display has-one or has-many related records as panels on the edit page using relatedRecords:
relatedRecords: [
{
relationKey: 'profile',
label: 'Profile',
type: 'has-one',
displayFields: ['firstName', 'lastName'],
editSlug: 'profiles' // links to /admin/profiles/{id}
},
{
relationKey: 'licenseKeys',
label: 'License Keys',
type: 'has-many',
displayFields: ['key', 'active', 'level']
}
];
The relationKey must match a key in include so the data is actually loaded. has-one renders
a key-value panel via RelatedHasOne; has-many renders a table via RelatedHasMany. Both
support an optional editSlug to link to the related record's edit page.
Access Control
Role Hierarchy
Defined in roles.ts. Lower numbers = higher privilege:
| Constant | Value | Access Level |
|---|---|---|
ROLES.SUPER |
1 | Full access, sees all users |
ROLES.ADMIN |
2 | Admin access, sees staff and below |
ROLES.STAFF |
3 | Staff access (minimum for admin panel) |
ROLES.USER |
4 | Regular user (no admin access) |
Layout Guard
+layout.server.ts calls canAccessAdmin(user.roleId) which returns true when
roleId <= ROLES.STAFF. Users with ROLES.USER (4) or no role get a 403.
Per-Model Access Filtering
Use accessFilter to restrict which records a viewer can see:
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined; // no filter
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } }; // can't see other admins
}
return { roleId: { gt: ROLES.STAFF } }; // staff sees only regular users
};
How Dynamic Routing Works
- User visits
/admin/proclamations(or/admin/proclamations/abc123) - SvelteKit matches the
[model](and[id]) route params +page.server.tscallsgetModelConfig(params.model)to look up the configgetDelegate(config)returnsprisma[lcfirst(config.modelName)](e.g.,prisma.proclamation)- The delegate runs
findMany(list),findUnique(edit),create,update, ordelete - Data + config are passed to the Svelte page component
- Components (
AdminTable,AdminForm, etc.) render based on config
Component API
AdminTable
| Prop | Type | Description |
|---|---|---|
records |
Record<string, unknown>[] |
Data rows |
listFields |
string[] |
Column keys (dot-notation supported) |
searchFields |
string[] |
Fields for client-side search |
modelSlug |
string |
Used for building edit links |
listOnly |
boolean? |
Suppress edit links |
Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans, decimals), filtered count display.
AdminForm
| Prop | Type | Description |
|---|---|---|
fields |
string[] |
Field names to render |
fieldTypes |
Record<string, PrismaScalarType> |
Type mapping for inputs |
relationOptions |
Record<string, RelationOptionConfig>? |
FK dropdown configs |
relationOptionsData |
Record<string, { label; value }[]>? |
Loaded dropdown options |
record |
Record<string, unknown>? |
Pre-fill values (edit mode) |
form |
ActionData? |
Carries validation errors |
action |
string |
Form action (e.g., '?/create') |
submitLabel |
string? |
Button text |
ComboBox
| Prop | Type | Description |
|---|---|---|
id |
string |
Element ID |
name |
string |
Form field name |
options |
{ label; value }[] |
Dropdown items |
placeholder |
string? |
Placeholder text |
value |
string | number? |
Pre-selected value |
Features: type-to-search filtering, keyboard navigation (ArrowUp/Down, Enter, Escape), clear button, scroll-to-highlight.
RelatedHasOne
| Prop | Type | Description |
|---|---|---|
label |
string |
Section heading |
record |
Record<string, unknown> | null |
Related record |
displayFields |
string[] |
Fields to display as key-value pairs |
editSlug |
string? |
Links to edit the related record |
RelatedHasMany
| Prop | Type | Description |
|---|---|---|
label |
string |
Section heading (shows count) |
records |
Record<string, unknown>[] |
Related records |
displayFields |
string[] |
Table columns |
editSlug |
string? |
Per-row edit links |
Utility Functions
| Function | Module | Description |
|---|---|---|
fieldNameToLabel(name) |
introspect.ts |
'solfeggioFrequencyId' → 'Solfeggio Frequency' (strips Id, adds spaces) |
resolveFieldValue(record, path) |
introspect.ts |
Resolves dot-notation paths like 'category.name' on nested objects |
extractFormData(formData, fields, types) |
form-data.ts |
Parses FormData with type-aware conversion per PrismaScalarType |
getDelegate(config) |
prisma-delegate.ts |
Returns prisma[lcfirst(modelName)] delegate |
parseId(config, raw) |
prisma-delegate.ts |
Parses string to number when idType is 'Int' |
getModelConfig(slug) |
registry.ts |
Looks up config by URL slug |
getAllModelConfigs() |
registry.ts |
Returns all registered model configs |
hasRole(roleId, required) |
roles.ts |
true if roleId <= required |
canAccessAdmin(roleId) |
roles.ts |
true if roleId <= ROLES.STAFF |
Complete Example
A hypothetical Product model showing all configuration features:
{
slug: 'products',
modelName: 'Product',
displayName: 'Products',
displayNameSingular: 'Product',
idType: 'Int',
include: { category: true, variants: true, createdBy: true },
listFields: ['name', 'category.name', 'price', 'active', 'createdAt'],
searchFields: ['name', 'sku'],
formFields: ['name', 'sku', 'price', 'active', 'categoryId'],
readonlyFields: ['id', 'createdAt', 'updatedAt'],
fieldTypes: {
name: 'String',
sku: 'String',
price: 'Float',
active: 'Boolean',
categoryId: 'Int',
createdAt: 'DateTime',
updatedAt: 'DateTime'
},
relationOptions: {
categoryId: { model: 'Category', labelField: 'name', valueField: 'id' }
},
relatedRecords: [
{
relationKey: 'variants',
label: 'Variants',
type: 'has-many',
displayFields: ['size', 'color', 'stock'],
editSlug: 'variants'
},
{
relationKey: 'createdBy',
label: 'Created By',
type: 'has-one',
displayFields: ['name', 'email']
}
],
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= 2) return undefined;
return { active: true }; // staff only sees active products
}
}