Skip to main content

Advanced

Search & Filters

Free-text search and faceted filters on list views — query string format, configuration, and the field-type security model.


List views support a whitelisted free-text search and faceted-filter query string.

Free-text search: ?q=

/admin/post?q=svelte
/admin/post?q=svelte

Matches with contains (case-insensitive on PostgreSQL, CockroachDB, and MongoDB; case sensitivity elsewhere follows the database’s own collation — see below) across a configurable set of fields, OR’d together.

Which fields are searched

models: {
  Post: {
    searchFields: ['title', 'content']
  }
}
models: {
  Post: {
    searchFields: ['title', 'content']
  }
}

Without searchFields, sveltekit-admin falls back to a conservative heuristic: String fields that aren’t sensitive, aren’t relations/lists, and whose name matches a small list of common label/content field names (name, title, label, email, username, slug, description, content, body, text). This list is separate from the one used to label relations (see Relations) — a field name can be searchable without being picked as a relation’s display label. If no field matches, no search box is rendered — there’s no fallback that scans every String column.

searchFields — explicit or heuristic — never includes sensitive fields (see Field Types) or fields listed in hidden, even if you list them explicitly; a hidden field being searchable would make it a value-confirmation oracle via ?q=.

Faceted filters: ?f.<field>= and ?f.<field>__<op>=

/admin/post?f.published=true&f.authorId=12
/admin/order?f.total__gte=100&f.total__lte=500
/admin/user?f.deletedAt__isnull=1
/admin/post?f.published=true&f.authorId=12
/admin/order?f.total__gte=100&f.total__lte=500
/admin/user?f.deletedAt__isnull=1

Each filter targets exactly one field. Operators are looked up from a fixed table keyed by the field’s Prisma type — the query string never supplies a Prisma operator directly, only a string that’s checked against this whitelist:

Field type Allowed operators
String equals (default), contains, startsWith
Int, BigInt, Float, Decimal equals (default), gte, lte
Boolean equals (default)
DateTime equals (default), gte, lte
enum equals (default)
any optional field isnull (1/true = is null, 0/false = is not null)

A filter targeting an unknown field, a sensitive field, a hidden field, or an operator outside that field’s row is silently ignored — never a server error — and surfaced in the UI as an ignored-filter notice so a mistyped URL doesn’t fail mysteriously. Sensitive and hidden fields are rejected with the same message as an unknown field, so a forged URL can’t use the response to confirm a sensitive field’s existence.

DateTime shortcuts

?f.createdAt=today
?f.createdAt=7d
?f.createdAt=month
?f.createdAt=year
?f.createdAt=2026-08-09      (a specific day)
?f.createdAt__gte=2026-08-01
?f.createdAt__lte=2026-08-31
?f.createdAt=today
?f.createdAt=7d
?f.createdAt=month
?f.createdAt=year
?f.createdAt=2026-08-09      (a specific day)
?f.createdAt__gte=2026-08-01
?f.createdAt__lte=2026-08-31

equals on a bare day is translated into a day-long interval ([00:00, next day)), never a literal equality check — a DateTime column stores a time component, so an exact-equals on a date-only value would almost never match anything.

Beyond building where clauses, sveltekit-admin can render a filter sidebar on list views:

models: {
  Post: {
    listFilter: [
      'published',                                  // short form
      { field: 'status' },
      { field: 'createdAt', presets: ['today', '7d', 'month', 'year'] },
      { field: 'authorId', label: 'Author' },
      { field: 'price', range: true }                // renders gte + lte inputs
    ]
  }
}
models: {
  Post: {
    listFilter: [
      'published',                                  // short form
      { field: 'status' },
      { field: 'createdAt', presets: ['today', '7d', 'month', 'year'] },
      { field: 'authorId', label: 'Author' },
      { field: 'price', range: true }                // renders gte + lte inputs
    ]
  }
}

Without explicit listFilter, Boolean and enum fields are auto-detected (their value domain is known statically from the schema, so rendering the sidebar costs zero extra queries). DateTime, numeric ranges, and foreign keys are never auto-detected — they require explicit config, since a DateTime’s shortcut set is an editorial choice, a range needs two inputs, and a foreign-key filter needs a query to load its options.

An invalid listFilter entry (unknown field, sensitive field, relation, unsupported type) throws at startup rather than silently producing a dead filter — a config mistake is a developer error and should fail loud.

Foreign-key filters

{ field: 'authorId', label: 'Author' }
{ field: 'authorId', label: 'Author' }

Renders using the same relation-label resolution as Relations — options and the currently-active filter’s label are both resolved through the target model’s scoping where, so a filter can never leak a record from outside that scope. Below a configurable link threshold (listFilterDefaults.linkThreshold, default 20) options render as sidebar links; up to the FK select threshold (relationDefaults.selectThreshold, default 200, shared with relation selects) they render as a <select> in a small form; beyond that, no automatic filter UI is offered for that field.

Sorting: ?sort= and ?dir=

Every column heading in the list view is a link. Clicking one sorts on that column ascending; clicking the same heading again flips to descending. The state lives in the URL, so a sorted list is bookmarkable and shareable.

/admin/user?sort=email          ascending
/admin/user?sort=email&dir=desc descending
/admin/user?sort=email          ascending
/admin/user?sort=email&dir=desc descending

Only the columns the list actually renders can be sorted. The name in ?sort= is looked up in that set and nothing else — it never reaches the query as a raw key. A column removed by models[].hidden, dropped by the sensitive-name heuristic, or simply beyond the six-column cap is not in the set, so ?sort= on it is refused and the list says so. Sorting therefore only ever orders values that are already readable on screen.

Sorting always composes with the rest of the URL state: the active search, every filter, and the page size are preserved. The page number is not — changing the sort resets you to page one, since they are no longer the same rows.

Results are always broken ties by primary key, descending. Without that, two rows sharing a value could swap places between two requests, and a skip/take window laid over an unstable order shows one row twice and another never. Sorting by the primary key does not add a redundant second key.

An unrecognised ?dir= is read as ascending rather than refused: unlike a column name, a direction designates nothing and cannot reveal anything.

A default order per model

Without configuration, a list arrives ordered by primary key, descending — newest first. models[].defaultSort changes what a visitor sees before touching anything:

createAdminHandler({
  adapter,
  models: {
    User: { defaultSort: { field: 'name' } },          // ascending
    Post: { defaultSort: { field: 'title', dir: 'desc' } }
  }
})
createAdminHandler({
  adapter,
  models: {
    User: { defaultSort: { field: 'name' } },          // ascending
    Post: { defaultSort: { field: 'title', dir: 'desc' } }
  }
})

A ?sort= in the URL always wins over it. A ?sort= on a column that cannot be sorted is still refused and reported, and the list falls back to this default rather than to the primary key.

field must name a column the list displays. It is checked when the handler is created, not per request: a column that is hidden, dropped by the name heuristic, or beyond the six-column cap would give a sort that no heading can announce and that a visitor has no way to leave, so it fails at boot instead.

There is deliberately no automatic “sort by name if the model has one”. Guessing would silently reorder every existing list, and the guess would drift from what the view actually shows.

Page size: ?perPage=

A list shows 20 rows per page. perPage changes that, and pageSizeOptions controls what a visitor can pick from:

createAdminHandler({
  adapter,
  perPage: 25,                       // 1..200, checked at boot
  pageSizeOptions: [25, 50, 100]     // rendered as links under the pagination
})
createAdminHandler({
  adapter,
  perPage: 25,                       // 1..200, checked at boot
  pageSizeOptions: [25, 50, 100]     // rendered as links under the pagination
})

The configured perPage is added to the options if it is missing, so the active size always appears in the selector. pageSizeOptions: [] turns the whole thing off — no selector, and ?perPage= has no effect.

?perPage= is honoured only when the value is one of the offered sizes. Anything else falls back to the configured size. Without that rule ?perPage=100000 is an unbounded take — a denial of service one query parameter away, and on a large table a request that holds the connection open. The same reason caps perPage itself at 200: past that it is an export, not a page.

Changing the size returns you to page one, since they are no longer the same rows.

Bulk delete

Each row in the list carries a checkbox, plus a “select all on this page” control in the header. Delete selected posts them to the list URL and removes them in a single operation.

It is deliberately one operation rather than a loop. A loop that fails on the seventh row because of a foreign-key constraint leaves six rows gone and no way back; here there are only two possible outcomes — everything selected is deleted, or nothing is and the list explains that one of the rows is still referenced.

Three things it will not do:

  • Reach outside models[].scope. The scope is composed with the selected ids inside the query, not checked separately, so an out-of-scope id simply matches nothing. No error is raised for it — nothing distinguishes “does not exist” from “belongs to another tenant”. The confirmation reports how many rows were actually deleted.
  • Accept an unbounded selection. More than 200 ids in one request is refused: the UI can only tick what it displays, and a very large IN (…) is a load vector on its own.
  • Skip the audit log. When audit is configured, one delete entry is emitted per row, with its before snapshot read using the same scope — so the log records exactly the rows that went, and no others.

The selection itself works without JavaScript; only the “select all on this page” checkbox needs it.

Case sensitivity

contains search is only made explicitly case-insensitive (mode: 'insensitive') on providers that support it in Prisma — PostgreSQL, CockroachDB, and MongoDB. The provider is auto-detected from your Prisma schema’s datasource block; override with:

search: {
  mode: 'auto' | 'insensitive' | 'default'   // default: 'auto'
}
search: {
  mode: 'auto' | 'insensitive' | 'default'   // default: 'auto'
}

Use insensitive/default explicitly if your schema’s provider is set via an environment variable (so it can’t be read from the schema file) or you have a specific index (e.g. citext, a functional lower() index) that a forced mode: 'insensitive' would defeat. On SQLite and MySQL, mode: 'insensitive' isn’t supported by Prisma at all — case sensitivity there follows the database’s own collation instead.

The legacy ?filter= parameter

An older, single-field ?filter=field:value parameter (e.g. ?filter=role:admin) is still accepted and routed through the exact same whitelist and type-coercion pipeline described above — it gets the same security guarantees as ?f.field=, just limited to one field and the default operator for that field’s type. If both ?filter= and ?f.<samefield>= are present for the same field, ?f. wins.