- Add Superforms + Zod 4 for all admin form validation - Replace address isDefault with three separate boolean defaults (Mailing, Billing, Shipping) - Update profiles page with three-checkbox address form and toast improvements - Convert all admin page server actions to pass request directly to crud - Export superFormErrorsToFieldErrors helper for profiles server actions - Regenerate Prisma client for new schema fields
19 KiB
svelte-admin
A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a registry and get full CRUD UI with per-model static pages, role-based access control, searchable tables, FK dropdowns, TipTap editor for rich text, and related-record panels.
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
├── crud.ts # CRUD action factory (load, upsert, delete)
└── index.ts # Barrel exports
src/routes/(protected)/admin/ # Per-model admin pages (no dynamic [model] routing)
├── +layout.server.ts # Role-based access guard
├── +layout.svelte # Layout shell
├── +page.server.ts # Admin home (model grid)
├── +page.svelte # Admin home UI
├── users/ # Each model gets its own page directory
├── profiles/
├── addresses/
├── user-roles/
├── proclamations/
├── solfeggio-frequencies/
├── proclamation-categories/
├── subscription-levels/
└── license-keys/ # listOnly — no create/edit modal
src/lib/components/admin/ # Reusable admin UI components
├── AdminTable.svelte # Sortable, searchable data table
├── AdminForm.svelte # Dynamic form with type-aware inputs
├── AdminModal.svelte # Modal overlay for CRUD forms
├── TipTap.svelte # Rich-text editor (in components/tiptap/)
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' } } } -
Create the page directory
src/routes/(protected)/admin/widgets/with:+page.server.ts— load records + form actions usingcrud.tshelpers+page.svelte— render AdminTable + AdminModal with AdminForm
-
Done. Visit
/admin/widgets. The page handles list, create, edit via modal.
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 and no modal. |
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 <select> dropdowns. |
relatedRecords |
RelatedRecordConfig[] |
No | Has-one/has-many panels shown on edit pages (display only via related records table). |
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' | 'Tiptap'
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 |
Tiptap |
TipTap rich-text editor (hidden input) | raw.toString() |
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 <select> dropdown
instead of the default input for that type. The options are fetched server-side by crud.ts
and passed to the client as relationOptionsData.
For Tiptap fields, the TipTap rich-text editor is rendered. The editor content is stored in a
hidden input and submitted with the form.
Relations
Foreign Key Dropdowns (relationOptions)
When a form field is a foreign key, add a relationOptions entry to render a <select> dropdown:
{
formFields: ['name', 'categoryId'],
fieldTypes: { name: 'String', categoryId: 'Int' },
relationOptions: {
categoryId: {
model: 'ProclamationCategory',
labelField: 'name',
valueField: 'id'
}
}
}
The crud.ts loadRecords function automatically queries the related model and passes options
to the page as relationOptionsData.
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.
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;
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } };
}
return { roleId: { gt: ROLES.STAFF } };
};
How Static Pages Work
- User visits
/admin/proclamations - SvelteKit matches the static route
/admin/proclamations/+page.server.ts +page.server.tscallsgetModelConfig('proclamations')to look up the configloadRecords(config)callsprisma.proclamation.findMany()with the config'sincludeandaccessFilter- For each FK field with a
relationOptions, it also queries the related model for dropdown options - Data + config are passed to
+page.svelte - The page renders
AdminTablewith records andAdminModal+AdminFormfor create/edit
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 |
fieldTypes |
Record<string, PrismaScalarType> |
Type mapping for cell formatting |
singular |
string |
Singular label for UI text |
onRowClick |
(record) => void? |
Click handler for edit modal |
onNew |
() => void? |
Click handler for create modal |
Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans, decimals), empty state.
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) |
readonlyFields |
string[]? |
Read-only field names |
action |
string |
Form action (e.g., '?/upsert') |
submitLabel |
string? |
Button text |
AdminModal
| Prop | Type | Description |
|---|---|---|
open |
boolean |
Show/hide modal |
title |
string |
Modal heading |
onClose |
() => void |
Close handler |
children |
Snippet |
Modal body content |
TipTap
| Prop | Type | Description |
|---|---|---|
initialContent |
string |
Initial HTML content |
name |
string |
Hidden input name for form submit |
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 |
loadRecords(locals, config) |
crud.ts |
Fetches records + FK dropdown options for a model |
upsertRecord(formData, config, locals) |
crud.ts |
Creates or updates a record based on presence of id in form data |
deleteRecord(formData, config, locals) |
crud.ts |
Deletes a record by ID |
Models in Admin
| Registry Slug | Prisma Model | Notes |
|---|---|---|
users |
User | Role assignment, view-only fields |
profiles |
Profile | Linked to user FK |
addresses |
Address | Linked to profile FK |
user-roles |
UserRole | |
proclamations |
Proclamation | TipTap rich-text editor for content |
solfeggio-frequencies |
SolfeggioFrequency | |
proclamation-categories |
ProclamationCategory | Parent category hierarchy |
license-keys |
LicenseKey | listOnly — read-only table, no CRUD |
subscription-levels |
SubscriptionLevel |
Excluded from Admin
| Model | Reason |
|---|---|
| Session | Better Auth internal — not for manual editing |
| Account | Better Auth internal |
| Verification | Better Auth internal |
| UserProclamations | Composite key — managed via other models |
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 };
}
}