Server-side data

Sort/filter/page round-tripped to a mock endpoint with debounce + cancel.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Server-Side Data). See the SvGrid documentation for the full API.

About this example

Server-side sorting, filtering and paging for the Svelte 5 data grid, the pattern a SvelteKit app uses against an API. The grid runs with externalSort and externalFilter, so its built-in sort and filter UI only updates query state in the component; an effect debounces changes by 250 ms, turns the state into a request and an AbortController cancels stale ones. Only the current page is held in memory; the mock endpoint sees the full 100k-row dataset and is a drop-in for fetch('/api/people?...').

Sort, filter, and page are pushed to a mock "server" (an async function over a large seeded dataset). Only the visible page is held in memory. The dev-loop pattern: 1. owning state is in this component 2. an effect debounces (250 ms) and turns state into a query 3. an AbortController cancels stale requests

The grid runs in externalSort + externalFilter mode so its built-in sort/filter UI only updates the *query state* - the actual fetch goes back to the endpoint, which sees the full 100k-row dataset.

Replace mockEndpoint with fetch('/api/people?...') and the rest of the structure stays the same.

Imports, features and API used

Imports: @svgrid/grid, ../shared/seed

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: firstName (First name), lastName (Last name), department (Department), country (Country), age (Age), salary (Salary)

Frequently asked questions

What do externalSort and externalFilter do?

They tell the grid not to sort or filter the rows it holds. The header clicks and filter menus still work, but they only fire onSortingChange and onFiltersChange; your code sends that state to the server and passes the returned page as data.

How are stale responses avoided?

Each request gets an AbortController stored in the component; the next request aborts the previous one before starting, so a slow older response can never overwrite a newer page. The 250 ms debounce keeps typing in a filter from firing a request per keystroke.

How do I swap the mock for my API?

Replace the mockEndpoint function with fetch against your route, passing the sort clauses, column filters, page and page size as query params. The effect, the debounce and the abort logic stay as they are.

Related documentation

Related articles

  • A Svelte Data Grid with Prisma - Wire SvGrid to a Prisma backend in SvelteKit - server-side pagination, sorting, and filtering that scales to millions of rows without a custom query builder.
  • A Svelte Data Grid with a Plain REST API - Wire SvGrid to any paginated REST endpoint - serializing sort, filter, and page state into query params, handling debounce, and cancelling stale requests before they land.

Source code (09-server-side.svelte)

<script lang="ts">
  /**
   * 09. Server-side data
   * --------------------
   * Sort, filter, and page are pushed to a mock "server" (an async function
   * over a large seeded dataset). Only the visible page is held in memory.
   * The dev-loop pattern:
   *   1. owning state is in this component
   *   2. an effect debounces (250 ms) and turns state into a query
   *   3. an AbortController cancels stale requests
   *
   * The grid runs in `externalSort` + `externalFilter` mode so its built-in
   * sort/filter UI only updates the *query state* - the actual fetch goes
   * back to the endpoint, which sees the full 100k-row dataset.
   *
   * Replace `mockEndpoint` with `fetch('/api/people?...')` and the rest of
   * the structure stays the same.
   */
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
    type SvGridApi,
  } from '@svgrid/grid'
  import { makePeople, type Person } from '../shared/seed'

  const features = tableFeatures({
    rowSortingFeature,
    columnFilteringFeature,
  })

  const columns: GridColumns<Person> = [
    { field: 'firstName',  header: 'First name', editorType: 'text' },
    { field: 'lastName',   header: 'Last name',  editorType: 'text' },
    { field: 'department', header: 'Department', editorType: 'text' },
    { field: 'country',    header: 'Country',    editorType: 'text' },
    { field: 'age',        header: 'Age',        editorType: 'number' },
    {
      field: 'salary',
      header: 'Salary',
      editorType: 'number',
      format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },
    },
  ]

  // The "remote" dataset. Held in module scope so the demo doesn't regenerate it on every key press.
  const ALL = makePeople(100_000)

  type SortClause = { id: string; desc: boolean }
  type GridFilter = {
    id: string
    operator: string
    value: string
    selectedValues?: Array<string>
  }
  type Query = {
    q: string
    department: string
    page: number
    pageSize: number
    sort: SortClause[]
    gridFilters: GridFilter[]
  }

  let q = $state('')
  let dept = $state('')
  let page = $state(0)
  const pageSize = 25
  let loading = $state(false)
  let total = $state(0)
  let rows = $state<Person[]>([])
  let api = $state<SvGridApi<typeof features, Person> | null>(null)
  let sortClauses = $state<SortClause[]>([])
  let gridFilters = $state<GridFilter[]>([])

  function getField(person: Person, id: string): unknown {
    return (person as unknown as Record<string, unknown>)[id]
  }

  function matchesGridFilter(person: Person, filter: GridFilter): boolean {
    const raw = getField(person, filter.id)
    if (filter.selectedValues && filter.selectedValues.length) {
      if (!filter.selectedValues.includes(String(raw ?? ''))) return false
    }
    const op = filter.operator
    const v = filter.value
    if (!v && op !== 'isBlank') return true
    const text = String(raw ?? '').toLowerCase()
    const needle = v.toLowerCase()
    switch (op) {
      case 'contains':    return text.includes(needle)
      case 'equals':      return text === needle
      case 'startsWith':  return text.startsWith(needle)
      case 'greaterThan': return Number(raw) > Number(v)
      case 'lessThan':    return Number(raw) < Number(v)
      case 'isBlank':     return raw === null || raw === undefined || String(raw) === ''
      default:            return true
    }
  }

  // Faked "network" latency. Kept just long enough to show a "Loading…" flash
  // on slow connections without making the demo feel laggy. Real apps obviously
  // get whatever the wire gives them.
  const NETWORK_LATENCY_MS = 60

  async function mockEndpoint(query: Query, signal: AbortSignal): Promise<{ rows: Person[]; total: number }> {
    await new Promise<void>((resolve, reject) => {
      const t = setTimeout(resolve, NETWORK_LATENCY_MS)
      signal.addEventListener('abort', () => {
        clearTimeout(t)
        reject(new DOMException('aborted', 'AbortError'))
      })
    })
    let matches = ALL.filter((p) => {
      if (query.q && !`${p.firstName} ${p.lastName} ${p.email}`.toLowerCase().includes(query.q.toLowerCase())) return false
      if (query.department && p.department !== query.department) return false
      for (const f of query.gridFilters) if (!matchesGridFilter(p, f)) return false
      return true
    })
    if (query.sort.length) {
      // Raw `<` / `>` is ~10x faster than Intl.Collator over 100k rows and
      // good enough for a demo. A real server would push this to the DB.
      matches = [...matches].sort((a, b) => {
        for (const clause of query.sort) {
          const av = getField(a, clause.id)
          const bv = getField(b, clause.id)
          let r: number
          if (typeof av === 'number' && typeof bv === 'number') r = av - bv
          else {
            const as = String(av ?? '')
            const bs = String(bv ?? '')
            r = as < bs ? -1 : as > bs ? 1 : 0
          }
          if (r !== 0) return clause.desc ? -r : r
        }
        return 0
      })
    }
    const start = query.page * query.pageSize
    return { rows: matches.slice(start, start + query.pageSize), total: matches.length }
  }

  let controller: AbortController | null = null
  let debounceTimer: ReturnType<typeof setTimeout> | null = null

  function runQuery() {
    controller?.abort()
    controller = new AbortController()
    const signal = controller.signal
    loading = true
    mockEndpoint(
      { q, department: dept, page, pageSize, sort: sortClauses, gridFilters },
      signal,
    )
      .then((res) => {
        if (signal.aborted) return
        rows = res.rows
        total = res.total
        loading = false
      })
      .catch((err) => {
        if ((err as Error).name !== 'AbortError') {
          console.error(err)
          loading = false
        }
      })
  }

  // Click-driven inputs (page, department dropdown, column sort) fire
  // immediately - a click should never wait on a debounce timer.
  $effect(() => {
    page; dept; sortClauses
    runQuery()
  })

  // Typed inputs (search box, in-grid column filter value) are debounced so
  // we don't hammer the "server" on every keystroke.
  $effect(() => {
    q; gridFilters
    if (debounceTimer) clearTimeout(debounceTimer)
    debounceTimer = setTimeout(runQuery, 120)
    return () => {
      if (debounceTimer) clearTimeout(debounceTimer)
    }
  })

  const pageCount = $derived(Math.max(1, Math.ceil(total / pageSize)))
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="flex flex-wrap items-end gap-3 text-sm shrink-0">
    <label class="flex flex-col">
      <span class="ss-muted">Search</span>
      <input
        type="text"
        bind:value={q}
        oninput={() => (page = 0)}
        placeholder="name or email"
        class="ss-field rounded px-2 py-1 w-56"
      />
    </label>
    <label class="flex flex-col">
      <span class="ss-muted">Department</span>
      <select
        bind:value={dept}
        onchange={() => (page = 0)}
        class="ss-field rounded px-2 py-1 w-48"
      >
        <option value="">All</option>
        <option>Engineering</option>
        <option>Design</option>
        <option>Product</option>
        <option>Sales</option>
        <option>Support</option>
        <option>Operations</option>
      </select>
    </label>
    <span class="ss-muted ml-auto">
      {#if loading}<span aria-live="polite">Loading…</span>{:else}{total.toLocaleString()} matches{/if}
    </span>
  </div>

  <div class="flex-1 min-h-0">
    <SvGrid responsive={true}
      columnResize
      data={rows}
      columns={columns}
      features={features}
      filterMode="menu"
      selectionMode="cell"
      enableInlineEditing={false}
      enableCellSelection={true}
      rowHeight={36}
      containerHeight="100%"
      fitColumns={true}
      externalSort={true}
      externalFilter={true}
      onSortingChange={(next) => {
        sortClauses = next
        page = 0
      }}
      onFiltersChange={(next) => {
        gridFilters = next.columns
        page = 0
      }}
      onApiReady={(next) => (api = next)}
    />
  </div>

  <nav class="flex items-center justify-between text-sm shrink-0">
    <button
      type="button"
      onclick={() => (page = Math.max(0, page - 1))}
      disabled={page === 0 || loading}
      class="ss-btn rounded px-3 py-1 disabled:opacity-50"
    >‹ Previous</button>
    <span class="ss-muted">Page {page + 1} of {pageCount.toLocaleString()}</span>
    <button
      type="button"
      onclick={() => (page = Math.min(pageCount - 1, page + 1))}
      disabled={page + 1 >= pageCount || loading}
      class="ss-btn rounded px-3 py-1 disabled:opacity-50"
    >Next ›</button>
  </nav>
</section>

<style>
  .ss-muted { color: var(--sg-muted, #64748b); }
  .ss-field {
    border: 1px solid var(--sg-input-border, #cbd5e1);
    background: var(--sg-input-bg, transparent);
    color: var(--sg-fg, #0f172a);
  }
  .ss-btn {
    border: 1px solid var(--sg-border, #cbd5e1);
    color: var(--sg-fg, #0f172a);
    background: transparent;
  }
  .ss-btn:hover:not(:disabled) { background: var(--sg-row-hover-bg, #f1f5f9); }
</style>

View this example on GitHub

More Server-Side Data examples

  • Server-side infinite scroll - 100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.
  • Server-Side Row Model (SSRM) - One datasource contract for server-backed data: implement a single async getRows({ startRow, endRow, sortModel, filterModel }) and createServerDataSource owns the sort/filter/page lifecycle and races stale responses away. Here a 100,000-row in-memory server behind 250ms latency; the grid holds only the current 50-row page. The row model ships in @svgrid/enterprise.
  • Server grouping (first-class) - First-class server-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerGroupModel owns the group tree - lazy expand per level, aggregation, per-node caching, race-safety - handing back a flat displayRows list. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.
  • GraphQL adapter - Server-side sort / filter / page wired to a mock GraphQL resolver. Side panel shows the live query doc so you can compare what the grid sent to the network tab.
  • Live REST (public API) - Real rows over the network from dummyjson.com via the enterprise createRestDataSource + a shape adapter (dummyJsonAdapter): skip/limit paging and sortBy/order sorting mapped to the API dialect. Swap URL + adapter (jsonServerAdapter / offsetLimitAdapter) to point at any public API. Includes an error/retry surface.