Files
portal/src/lib/svelte-admin
..
2026-02-23 10:21:42 -06:00
2026-02-23 10:21:42 -06:00
2026-02-23 10:21:42 -06:00
2026-02-23 10:21:42 -06:00
2026-03-18 01:09:21 -05:00
2026-03-18 11:27:57 -05:00
2026-02-25 13:19:26 -06:00
2026-03-16 13:23:25 -05:00

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

  1. Define the Prisma model in prisma/schema.prisma and create a migration:

    npm run migrate -- --name add_my_model
    
  2. Add a config object to the models array in registry.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'
        }
      }
    }
    
  3. 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
}

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

  1. User visits /admin/proclamations (or /admin/proclamations/abc123)
  2. SvelteKit matches the [model] (and [id]) route params
  3. +page.server.ts calls getModelConfig(params.model) to look up the config
  4. getDelegate(config) returns prisma[lcfirst(config.modelName)] (e.g., prisma.proclamation)
  5. The delegate runs findMany (list), findUnique (edit), create, update, or delete
  6. Data + config are passed to the Svelte page component
  7. 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
  }
}