Skip to main content

Configuration

Configuration Reference

Every option createAdminHandler accepts, in one place.


createAdminHandler takes a single configuration object. Only prisma is required — everything else has a sensible default.

Full example

createAdminHandler({
  // Required: your Prisma client
  prisma,

  // Path to your Prisma schema (default: './prisma/schema.prisma')
  prismaSchemaPath: './prisma/schema.prisma',

  // Base path for admin routes (default: '/admin')
  basePath: '/admin',

  // Authentication check (optional)
  authCheck: async (event) => {
    const session = event.locals.session;
    return session?.user?.role === 'admin';
  },

  // Per-model configuration
  models: {
    User: {
      hidden: ['password', 'hashedPassword'],
      readonly: ['id', 'createdAt', 'updatedAt'],
      listFields: ['email', 'name', 'role', 'createdAt'],
      label: 'Users'
    }
  },

  // Models to exclude from admin
  exclude: ['Session', 'VerificationToken'],

  // Custom branding
  branding: {
    title: 'My Admin',
    primaryColor: '#6366f1'
  },

  // Extra pages + record actions (optional)
  plugins: []
});
createAdminHandler({
  // Required: your Prisma client
  prisma,

  // Path to your Prisma schema (default: './prisma/schema.prisma')
  prismaSchemaPath: './prisma/schema.prisma',

  // Base path for admin routes (default: '/admin')
  basePath: '/admin',

  // Authentication check (optional)
  authCheck: async (event) => {
    const session = event.locals.session;
    return session?.user?.role === 'admin';
  },

  // Per-model configuration
  models: {
    User: {
      hidden: ['password', 'hashedPassword'],
      readonly: ['id', 'createdAt', 'updatedAt'],
      listFields: ['email', 'name', 'role', 'createdAt'],
      label: 'Users'
    }
  },

  // Models to exclude from admin
  exclude: ['Session', 'VerificationToken'],

  // Custom branding
  branding: {
    title: 'My Admin',
    primaryColor: '#6366f1'
  },

  // Extra pages + record actions (optional)
  plugins: []
});

Options

Option Type Default Description
prisma PrismaClient Required. Your Prisma client instance.
prismaSchemaPath string './prisma/schema.prisma' Path to the schema file the admin introspects to discover models and fields.
basePath string '/admin' URL prefix all admin routes are served under.
authCheck (event: RequestEvent) => boolean Promise<boolean> none (open access) Gate that must return true for a request to reach the admin. See Authentication.
csrf false { trustedOrigins?: string[] } check enabled Origin verification for every state-changing admin request. On by default; false opts out entirely. See Cross-site protection.
audit (entry: AuditEvent) => void Promise<void> none Called after a successful create/update/delete with a redacted event (actor via entry.event.locals). See Audit log.
models Record<string, ModelConfig> {} Per-model overrides — hiding/readonly fields and list columns (see Model Configuration), relation rendering (see Relations), and search/filter behavior (see Search & Filters). A model can also define scope(ctx) for tenant isolation; it is applied to every read and mutation.
exclude string[] [] Model names to hide from the admin entirely.
hidePivotTables boolean true Automatically hides Prisma’s implicit many-to-many pivot/junction tables from the admin. Explicit pivot models (ones you defined yourself with their own fields) are never auto-hidden.
relationDefaults { selectThreshold?: number; labelFields?: string[] } { selectThreshold: 200, labelFields: ['name','title','label','email','username','slug'] } Defaults for relation rendering. selectThreshold is the option count above which a foreign-key <select> falls back to a raw ID input (see Relations); labelFields is the field-name preference order used to auto-pick a relation’s display label.
listFilterDefaults { linkThreshold?: number; autoDetect?: boolean } { linkThreshold: 20, autoDetect: true } Defaults for the list-view filter sidebar. linkThreshold is the option count above which a filter renders as a <select> instead of a list of links (see Search & Filters); autoDetect controls whether Boolean/enum fields get an automatic sidebar filter when a model has no explicit listFilter config.
search { mode?: 'auto' 'insensitive' 'default' } { mode: 'auto' } Global free-text search behavior. Controls whether contains search is forced case-insensitive; see Search & Filters for the full provider-support breakdown.
branding { title?: string; primaryColor?: string } template defaults Cosmetic overrides for the admin UI.
plugins AdminPlugin[] [] Extra pages and per-record links. See Plugins.

Tenant scoping

Use scope when a model must be isolated by the current tenant. The function receives the same request context as authCheck and must return a non-empty condition. Flat equality maps are recommended because they can also be applied automatically on create:

createAdminHandler({
  prisma,
  models: {
    Invoice: {
      scope: ({ locals }) => ({ organizationId: locals.organizationId })
    }
  }
});
createAdminHandler({
  prisma,
  models: {
    Invoice: {
      scope: ({ locals }) => ({ organizationId: locals.organizationId })
    }
  }
});

The scope is composed with AND for lists, search, dashboard counts, detail views, relation options, plugin reads, update, and delete. A missing tenant or an empty scope fails closed: a condition that matched every row would fail open exactly when it matters most, so it throws instead.

On create and update, the scope’s equality fields are imposed on the record after foreign-key validation has run — which matters because the tenant column is usually a relation scalar such as organizationId. A value submitted for a scope column that conflicts with the scope is rejected, not overwritten: it is server-determined, so a mismatch means either a forged POST or a form offering a choice it should not offer. A scope column absent from the form, or present but left empty — what a create form renders — is simply set.

Complex filters such as OR are allowed for reads but rejected on create, because they cannot determine a single tenant-owned value.

Put the tenant model itself in exclude. Foreign-key validation checks a submitted target against that target model’s scope, so an unscoped, visible tenant table makes every existing tenant id an acceptable value:

createAdminHandler({
  prisma,
  exclude: ['Organization'],
  models: {
    Invoice: { scope: ({ locals }) => ({ organizationId: locals.organizationId }) }
  }
});
createAdminHandler({
  prisma,
  exclude: ['Organization'],
  models: {
    Invoice: { scope: ({ locals }) => ({ organizationId: locals.organizationId }) }
  }
});

Migrating from listWhere

If you used listWhere for tenant isolation, move that condition to scope. The two are not equivalent, and the difference is the whole point:

listWhere scope
List, search, sidebar filters, pagination count yes yes
Detail and edit screens no yes
Update and delete no yes
Dashboard counts no yes
Relation dropdowns and relation writes no yes
Imposed on create no yes (equality fields)

listWhere hides rows from the list. It never stopped anyone who knew a record id from opening /admin/<model>/<id>, editing it, or deleting it. If that was your tenant boundary, treat it as having been open and audit accordingly.

// Before — list-only, direct-id access was not covered
models: {
  Invoice: { listWhere: ({ locals }) => ({ organizationId: locals.organizationId }) }
}

// After — every read and write
models: {
  Invoice: { scope: ({ locals }) => ({ organizationId: locals.organizationId }) }
}
// Before — list-only, direct-id access was not covered
models: {
  Invoice: { listWhere: ({ locals }) => ({ organizationId: locals.organizationId }) }
}

// After — every read and write
models: {
  Invoice: { scope: ({ locals }) => ({ organizationId: locals.organizationId }) }
}

Keep listWhere for what it is good at: narrowing a list view for reasons that are not authorization — a default “only my drafts” or “archived hidden” view. The two compose with AND when both are set.

What scope does not cover

scope applies to the requests this handler serves — everything under basePath. It does not reach the rest of your SvelteKit app: a prisma.invoice.findMany() in one of your own +page.server.ts files is not scoped by anything here. To isolate the whole application, enforce it at the data layer instead — a Prisma Client extension, or PostgreSQL row-level security.

Concurrency

Relation targets submitted by a POST are re-checked inside the write transaction. On PostgreSQL that check takes a FOR SHARE row lock, because SERIALIZABLE alone does not stop a concurrent transaction from moving the target out of scope between the check and the write. Writes are retried up to three times on a serialization failure or deadlock, which are transient by definition; nothing else is retried.

Routes handled

The handler intercepts every request under basePath and generates HTML on the fly — there are no SvelteKit route files to create:

Route Behavior
/admin Dashboard
/admin/user List all User records
/admin/user/new Create form
/admin/user/123 Edit form for record 123

See How It Works for the request-handling pipeline behind these routes.