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. (requires @svgrid/enterprise)

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

The server-side row model of the Svelte 5 data grid: implement one async getRows({ startRow, endRow, sortModel, filterModel }) and createServerDataSource owns the sort, filter and page lifecycle, races stale responses away and pushes { rows, total, loading } back. The demo's server is a 100,000-row in-memory table behind 250 ms latency; the grid holds only the current 50-row page, shows loadingOverlay while a request is in flight and takes its filter value lists from serverFilterValues.

One datasource contract for server-backed data. You implement a single async getRows({ startRow, endRow, sortModel, filterModel }); createServerDataSource owns the request lifecycle (sort, filter, page), races stale responses away, and pushes { rows, total, loading } back.

Here the "server" is a 100,000-row in-memory table behind a simulated 250ms latency - the grid only ever holds the current 50-row page.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: id (ID), name (Name), team (Team), country (Country), salary (Salary)

Frequently asked questions

What does my getRows have to return?

A promise of { rows, total } for the requested range, after applying the sortModel and filterModel it receives. The data source calls it whenever sort, filter or page changes and ignores any response that arrives after a newer request.

How does the grid connect to the data source?

Pass s.rows as data and s.loading to loading, set externalSort and externalFilter, and forward onSortingChange and onFiltersChange to the controller's setSort and setFilter. The controller re-queries and updates the reactive state.

Where do the filter checklists get their values?

From serverFilterValues, a map of column id to distinct values you fetch once from the server, since the grid never sees the full dataset.

Related documentation

Related articles

Source code (148-server-row-model.svelte)

<!-- Documented in: docs/help/server/server-row-model.md -->
<script lang="ts">
  /**
   * 148. Server-Side Row Model (SSRM)
   * ---------------------------------
   * One datasource contract for server-backed data. You implement a single
   * async `getRows({ startRow, endRow, sortModel, filterModel })`;
   * `createServerDataSource` owns the request lifecycle (sort, filter, page),
   * races stale responses away, and pushes `{ rows, total, loading }` back.
   *
   * Here the "server" is a 100,000-row in-memory table behind a simulated
   * 250ms latency - the grid only ever holds the current 50-row page.
   */
  import {
    SvGrid,
    createServerDataSource,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
    type ServerDataSource,
    type ServerState,
  } from '@svgrid/grid'

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  type Row = { id: number; name: string; team: string; country: string; salary: number }
  const TEAMS = ['Research', 'Compilers', 'Kernel', 'Apollo', 'Web', 'Data']
  const COUNTRIES = ['US', 'DE', 'JP', 'UK', 'BR', 'IN', 'AU']
  const FIRST = ['Ada', 'Grace', 'Alan', 'Linus', 'Donald', 'Brian', 'Margaret', 'Dennis', 'Ken', 'Barbara']
  // The "database": 100k rows that never touch the grid wholesale.
  const DB: Row[] = Array.from({ length: 100_000 }, (_, id) => ({
    id,
    name: `${FIRST[id % FIRST.length]} #${id}`,
    team: TEAMS[id % TEAMS.length]!,
    country: COUNTRIES[id % COUNTRIES.length]!,
    salary: 40_000 + ((id * 7919) % 160_000),
  }))

  // The datasource the consumer implements - sort + filter + slice on the
  // "server", behind a fake latency.
  const source: ServerDataSource<Row> = {
    async getRows(req) {
      await new Promise((r) => setTimeout(r, 250))
      let rows = DB
      const g = req.filterModel.global?.trim().toLowerCase()
      if (g) rows = rows.filter((r) => r.name.toLowerCase().includes(g) || r.team.toLowerCase().includes(g))
      const cols = req.filterModel.columns ?? {}
      for (const [id, f] of Object.entries(cols)) {
        // Facet (checklist) selection: keep rows whose value is selected.
        if (f.selectedValues && f.selectedValues.length) {
          const allowed = new Set(f.selectedValues)
          rows = rows.filter((r) => allowed.has(String((r as any)[id])))
        }
        // Operator (text) filter: substring match.
        const v = f.value.trim().toLowerCase()
        if (v) rows = rows.filter((r) => String((r as any)[id]).toLowerCase().includes(v))
      }
      const sort = req.sortModel[0]
      if (sort) {
        rows = [...rows].sort((a, b) => {
          const av = (a as any)[sort.id]
          const bv = (b as any)[sort.id]
          const c = typeof av === 'number' ? av - bv : String(av).localeCompare(String(bv))
          return sort.desc ? -c : c
        })
      }
      return { rows: rows.slice(req.startRow, req.endRow), rowCount: rows.length }
    },
  }

  // Server-side set-filter values: the checklist shows EVERY distinct value from
  // the 100k-row server, not just the 50 on the current page. Fetched on demand
  // when a column's filter menu opens (and cached by the grid).
  async function distinctValues(columnId: string): Promise<string[]> {
    await new Promise((r) => setTimeout(r, 150)) // simulated server query
    if (columnId !== 'team' && columnId !== 'country') return []
    const set = new Set<string>()
    for (const r of DB) set.add(String((r as Record<string, unknown>)[columnId]))
    return [...set].sort()
  }

  const columns: GridColumns<Row> = [
    { field: 'id', header: 'ID', width: 90, align: 'right' },
    { field: 'name', header: 'Name', width: 200 },
    { field: 'team', header: 'Team', width: 140 },
    { field: 'country', header: 'Country', width: 110 },
    { field: 'salary', header: 'Salary', width: 150, align: 'right', format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
  ]

  let s = $state<ServerState<Row>>({
    rows: [], total: 0, loading: false, saving: false, error: null,
    pageIndex: 0, pageSize: 50, pageCount: 1, sortModel: [], filterModel: {},
  })
  const ctl = createServerDataSource(source, { pageSize: 50, onChange: (next) => (s = next) })
  ctl.refresh()
  $effect(() => () => ctl.dispose())

  const rangeStart = $derived(s.total === 0 ? 0 : s.pageIndex * s.pageSize + 1)
  const rangeEnd = $derived(Math.min(s.total, (s.pageIndex + 1) * s.pageSize))
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="shrink-0 rounded-lg border px-4 py-3" style="border-color: var(--sg-border); background: var(--sg-header-bg);">
    <p class="text-sm font-semibold" style="color: var(--sg-fg);">
      100,000 rows on the "server" via <code>createServerDataSource</code>
    </p>
    <p class="mt-1 text-xs" style="color: var(--sg-muted);">
      The grid holds only the current 50-row page. Sort a header or open a
      column filter - the request goes to the datasource (250ms simulated
      latency), and stale responses are raced away automatically.
    </p>
  </div>

  <div class="flex-1 min-h-0">
    <SvGrid responsive={true}
      columnResize
      data={s.rows}
      columns={columns}
      features={features}
      sortable
      filterable
      filterMode="menu"
      serverFilterValues={distinctValues}
      externalSort
      externalFilter
      loading={s.loading}
      loadingOverlay
      pageable={false}
      selectionMode="none"
      rowHeight={34}
      containerHeight="100%"
      fitColumns={true}
      onSortingChange={(sorting) => ctl.setSort(sorting)}
      onFiltersChange={(f) => ctl.setFilter({
        global: f.global,
        columns: Object.fromEntries(
          f.columns.map((c) => [
            c.id,
            { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues },
          ]),
        ),
      })}
    />
  </div>

  <footer class="shrink-0 flex items-center gap-3 text-sm" style="color: var(--sg-fg);">
    <button class="srm-btn" disabled={s.pageIndex <= 0 || s.loading} onclick={() => ctl.setPage(s.pageIndex - 1)}>‹ Prev</button>
    <button class="srm-btn" disabled={s.pageIndex >= s.pageCount - 1 || s.loading} onclick={() => ctl.setPage(s.pageIndex + 1)}>Next ›</button>
    <span style="color: var(--sg-muted)">
      {rangeStart.toLocaleString()}–{rangeEnd.toLocaleString()} of {s.total.toLocaleString()}
      · page {s.pageIndex + 1}/{s.pageCount}
      {#if s.loading}· <span style="color: var(--site-accent, #2563eb)">loading…</span>{/if}
    </span>
  </footer>
</section>

<style>
  .srm-btn {
    padding: 5px 12px;
    border: 1px solid var(--sg-border);
    border-radius: 6px;
    background: var(--sg-bg);
    color: var(--sg-fg);
    font-size: 13px;
    cursor: pointer;
  }
  .srm-btn:disabled { opacity: 0.45; cursor: default; }
</style>

View this example on GitHub

More Server-Side Data examples

  • Server-side data - Sort/filter/page round-tripped to a mock endpoint with debounce + cancel.
  • 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 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.