Server-Side Row Model (SSRM)

When the data lives on the server - millions of rows in a database - the grid should hold only the page on screen and push sorting, filtering, and paging to the backend. SvGrid packages this as one datasource contract: you implement a single async getRows, and createServerDataSource owns the request lifecycle (paging, sort, filter, race-safety, writes).

New to the model? Server-Side Row Model: a walkthrough builds it up one step at a time against a server written on the page; this page is the contract in full.

The createServerDataSource request lifecycle: setSort, setFilter, and setPage trigger a fetch that tags each getRows call with a monotonic request id, and only the latest request lands as a ServerState of rows, total, loading, saving, and error that the grid renders from.

Open the live example: Server-Side Row Model: paged and infinite (Server-Side Data)

Scope. createServerDataSource is the free, flat row model: one page at a time in page mode, or one long block-cached list in infinite mode. Grouping, tree data, pivot, transactions and selection across unloaded rows are the Enterprise Server-Side Row Model, built on the same contract. See what this does not do.

Quick start

import { createServerDataSource, type ServerDataSource } from '@svgrid/grid'

const source: ServerDataSource<Row> = {
  async getRows({ startRow, endRow, sortModel, filterModel }) {
    const res = await fetch('/api/rows', {
      method: 'POST',
      body: JSON.stringify({ startRow, endRow, sortModel, filterModel }),
    })
    const { rows, total } = await res.json()
    return { rows, rowCount: total } // rowCount = total AFTER filtering
  },
}

let view = $state(/* ServerState */)
const ctl = createServerDataSource(source, { pageSize: 50, onChange: (s) => (view = s) })
ctl.refresh()

The request your server receives

Every fetch calls getRows(request) with this exact shape:

type ServerRequest = {
  startRow: number   // first row wanted, inclusive  (= pageIndex * pageSize)
  endRow: number     // one past the last row wanted, exclusive
  pageIndex: number
  pageSize: number
  sortModel: ServerSortModel
  filterModel: ServerFilterModel
}

startRow / endRow are the page bounds (they map to OFFSET / LIMIT), not block-cache bounds. Return { rows, rowCount } where rowCount is the total count after filtering - the pager needs it to compute the last page.

Every request also carries signal, an AbortSignal the grid aborts when it no longer wants the answer: a newer page superseded this one, a block was purged or scrolled out of the cache, a group was collapsed, the controller was disposed. Hand it to fetch(url, { signal }) and a query the user has scrolled past is cancelled at the server instead of completing for nothing; the grid ignores the rejection the abort produces. The SvelteKit transport does this for you and keeps the signal out of the posted body.

The sort model

type ServerSortModel = Array<{ id: string; desc: boolean }>

Multi-column, in priority order. So this:

[{ "id": "lastName", "desc": false }, { "id": "age", "desc": true }]

maps to ORDER BY last_name ASC, age DESC.

The filter model

type ServerFilterModel = {
  global?: string                 // the quick-filter search box
  columns?: Record<string, {      // keyed by column id
    operator: string              // equals | contains | startsWith | greaterThan | lessThan | between | isBlank
    value: string
    valueTo?: string              // second bound, for `between`
    selectedValues?: string[]     // set-filter (facet checklist) selection
  }>
}

A populated model looks like:

{
  "global": "berlin",
  "columns": {
    "status":  { "operator": "equals",  "value": "active" },
    "age":     { "operator": "between", "value": "18", "valueTo": "65" },
    "country": { "operator": "contains", "value": "", "selectedValues": ["DE", "FR"] }
  }
}

Translating the model to SQL

Map each column filter's operator to a predicate:

operator SQL
equals col = $value
contains `col ILIKE '%'
startsWith `col ILIKE $value
greaterThan col > $value
lessThan col < $value
between col BETWEEN $value AND $valueTo
isBlank col IS NULL OR col = ''
selectedValues present col IN ($selectedValues)

global is a free-text search you OR across your searchable columns. Always bind values as parameters - never string-concatenate them into SQL.

You do not have to hand-write that switch. @svgrid/enterprise ships normalizeFilters(filterModel), which returns { predicates, search } with the operators already normalized to in / contains / startsWith / eq / gt / lt / between - the same helper the built-in REST and SQL sources use. For a complete, runnable server (route + query builder), follow the Postgres CRUD tutorial.

Wiring to the grid

The controller is a row model: hand it to the grid and every seam is wired - the rows, the loading flag, external sort and filter, the pager in page mode, the visible range and the placeholder rows in infinite mode:

<SvGrid rowModel={ctl} {columns} {features} sortable filterable pageable />

A prop written on the grid beats what the model supplies, so you can adopt the model and still override one piece. The long form spells out the same seams by hand, which is also how a model of your own plugs in:

<SvGrid
  data={view.rows}
  {columns} {features}
  sortable filterable
  externalSort externalFilter
  loading={view.loading}
  externalPagination rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}
  onSortingChange={(sorting) => ctl.setSort(sorting)}
  onFiltersChange={(f) => ctl.setFilter(f)}
  onPaginationChange={(p) => (p.pageSize !== view.pageSize ? ctl.setPageSize(p.pageSize) : ctl.setPage(p.pageIndex))}
/>

externalSort / externalFilter tell the grid to emit intent instead of acting locally; externalPagination hands the pager the server's count.

Infinite mode

Pass mode: 'infinite' and there are no pages: state.rows spans the whole result, placeholder rows stand in for blocks nobody has scrolled to, and blocks of blockSize rows load as the viewport reaches them, with an LRU cap, a concurrency cap, retry for failed blocks and an unknown-count mode. Server-side infinite scroll covers it.

The controller

createServerDataSource(source, options) returns:

Method Does
refresh() Re-fetch the current page (after a mutation).
setSort(model) New sort, jump to page 0, fetch.
setFilter(model) New filter, jump to page 0, fetch.
setPage(i) Fetch page i.
setPageSize(n) New page size, page 0, fetch.
createRow(input) Create through the source, then refresh. Throws if the source has no createRow.
updateRow(id, p) Update by id. Optimistic when configured (below).
deleteRow(id) Delete by id. Optimistic when configured.
getState() Snapshot of the current ServerState.
dispose() Stop accepting in-flight responses. Call on unmount.

The state you render from

onChange fires on every transition. The full shape:

type ServerState<Row> = {
  rows: ReadonlyArray<Row>
  total: number        // total after filtering (drives the pager)
  loading: boolean     // a getRows fetch is in flight
  saving: boolean      // a create / update / delete is in flight
  error: unknown       // the rejection from the last failed getRows (else null)
  pageIndex: number
  pageSize: number
  pageCount: number
  sortModel: ServerSortModel
  filterModel: ServerFilterModel
}

Bind loading to the grid's overlay, saving to a toolbar spinner, and check error to render a retry banner.

Writes and optimistic updates

Implement whichever of createRow / updateRow / deleteRow your backend supports; the matching controller method calls through and then refreshes the current page. Calling one whose source counterpart is missing throws a clear error, so a read-only source stays read-only.

By default writes are non-optimistic: the grid reflects the change only after the follow-up refresh lands. For instant feedback, opt in:

const ctl = createServerDataSource(source, {
  pageSize: 50,
  onChange: (s) => (view = s),
  optimistic: true,
  getRowId: (r) => String(r.id), // required, so a row can be located in the page
})

Now updateRow patches the local row immediately and reconciles with the server result (rolling back on error); deleteRow removes it and decrements total, restoring both if the server rejects.

Error handling

If getRows rejects, the controller clears rows, stores the rejection in state.error, and drops loading - it never throws into your render. Recover by re-fetching:

{#if view.error}
  <div role="alert">
    Could not load rows. <button onclick={() => ctl.refresh()}>Retry</button>
  </div>
{/if}

A successful fetch clears error again.

Race safety

Every fetch carries a monotonic id; only the latest request is allowed to land. A slow response for an old sort/filter can never clobber a newer one - the classic SSRM bug, handled for you. dispose() drops everything in flight and clears loading, so an unmounting component can't leave a stuck spinner.

What this does not do

Being honest about the edges so you pick the right tool:

Enterprise: the Server-Side Row Model

createServerRowModel from @svgrid/enterprise is the same contract with the rest of the request filled in, and the same one-prop wiring:

Page What it adds
Server grouping lazy grouping per level, a block cache per level, aggregates, child counts, grand totals, footers, refresh and retry per route, paging over the tree
Server tree data self-referential trees loaded on expand
Server pivot pivot on the server, columns built from pivotResultFields, the pivot designer in server mode
Server transactions add, update and remove rows in a loaded level without a request, sync or batched
Server selection select-all as a rule across unloaded rows, a bulk edit by rule
Worker data source rows already in the browser, queried in a Web Worker so sorting and grouping do not block the page

The flagship demo runs all of it over one million rows through one rowModel prop:

Open the live example: Server-Side Row Model: 1,000,000 rows (Server-Side Row Model)

A backend written for a callback-style datasource

Some server-side row models hand the datasource a getRows(params) that answers through params.success({ rowData, rowCount }), with the request spelt rowGroupCols / valueCols / pivotCols and a sortModel of { colId, sort }. @svgrid/enterprise maps that shape both ways, so a backend built for it needs no rewrite:

More examples

Big data: 10M rows x 10K columns

Up to 10,000,000 rows and 10,000 columns in one SvGrid on the Enterprise server row model. A Web Worker sorts (radix, exact), filters, groups and edits a generated dataset and sends back only the rows on screen; the grid builds just those, so its memory and scroll cost stay flat as the row count grows. Pick a size, sort, filter, group, edit a cell, or run the benchmark: every number on the page is measured live in your browser.

Open the live example: Big data: 10M rows x 10K columns (Server-Side Row Model)

Server-side pivot

The pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request.

Open the live example: Server-side pivot (Server-Side Row Model)

Server tree data (row model)

A file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, per-folder refresh, and a file added or deleted without a refetch.

Open the live example: Server tree data (row model) (Server-Side Row Model)

Server transactions (live feed)

Changes the server already made, applied without a refetch: in-place patches with a flash, batched add and remove transactions addressed by route, and a status for every result.

Open the live example: Server transactions (live feed) (Server-Side Row Model)

Server selection: select all, minus these

Select-all as a rule the server counts, flat and per group, with a bulk edit the server applies by rule.

Open the live example: Server selection: select all, minus these (Server-Side Row Model)

Server row model to SQL

The statements planToSql renders for every request, per dialect: rows, count, grand total and the two-step pivot.

Open the live example: Server row model to SQL (Server-Side Row Model)

Server grouping: totals, sort and refresh rules

Grand total positions, subtotal footers, open-by-default levels, expand-all over unloaded groups, refresh against purge, and what a sort or a filter re-requests, with the request log.

Open the live example: Server grouping: totals, sort and refresh rules (Server-Side Row Model)

Server row model: CRUD

The four writes against a backend that says no: a form under the focused region, inline edits with a version check, a refused delete, an undo, saving, and optimistic mode against a slow server.

Open the live example: Server row model: CRUD (Server-Side Row Model)

Server row model: master-detail

A detail panel under any order of the grouped, virtualized tree, fetched from its own endpoint when it opens, with the region row held under the header.

Open the live example: Server row model: master-detail (Server-Side Row Model)

Server grouping (row model)

Server-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. 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.

Open the live example: Server grouping (row model) (Server-Side Row Model)

Server-side infinite scroll

100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.

Open the live example: Server-side infinite scroll (Server-Side Data)

Server-side grouping + aggregates

GROUP BY + SUM/AVG pushed to the server; pre-aggregated buckets with on-demand drill-in.

Open the live example: Server-side grouping + aggregates (Server-Side Data)

Server-side data

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

Open the live example: Server-side data (Server-Side Data)

Server-side rendering

SvelteKit-style SSR with a sandboxed pre-hydration snapshot.

Open the live example: Server-side rendering (Integrations)

See also

Live examples

  • Server-Side Row Model: paged and infinite - The free half of the row model: implement one async getRows({ startRow, endRow, sortModel, filterModel }) and createServerDataSource owns the sort/filter/page lifecycle and races stale responses away. Paged, the grid holds one 50-row page; infinite, a block cache under the scrollbar with placeholders, retry and an LRU, drawn live as you scroll. Here a 100,000-row in-memory server behind 250ms latency. Grouping, tree, pivot and transactions on top of the same contract are the Enterprise row model (demo 467).
  • Server-Side Row Model: 1,000,000 rows - One grid, one rowModel prop, a million rows that stay on the server. Sort, filter, global search, grouping to any depth (Region > Country > Rep), infinite scroll or paging, inline edits applied back as transactions with the subtotal following, add and delete, select-all across rows the grid never loaded with a bulk edit by rule, failed blocks with Retry, a request log that shows every call to the columnar warehouse behind it, and a live map of the block cache per level. The row model ships in @svgrid/enterprise; the datasource contract is free.
  • Big data: 10M rows x 10K columns - Up to 10,000,000 rows and 10,000 columns in one SvGrid on the Enterprise server row model. A Web Worker sorts (radix, exact), filters, groups and edits a generated dataset and sends back only the rows on screen; the grid builds just those, so its memory and scroll cost stay flat as the row count grows. Pick a size, sort, filter, group, edit a cell, or run the benchmark: every number on the page is measured live in your browser.
  • Server-side pivot - The pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request. The backend answers with one field per pivot key and aggregation and lists them in pivotResultFields; the model builds the column groups from that list. Apply / Cancel hold a slice-and-dice session to one request, a Total column group carries the row totals, and a grand total row is pinned at the bottom.
  • Server tree data (row model) - A file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, answered with that folder's entries one block at a time. createServerRowModel in treeData mode owns the lazy expand, a block cache per folder, open-by-default, expand and collapse all, a per-folder refresh that re-reads one folder in place, and transactions that add or delete a file without a refetch. The server generates each folder from a seeded PRNG on first request, five levels deep.
  • Server transactions (live feed) - A socket-style feed of changes the server already made, applied without a refetch: a price tick patches the loaded row in place with a flash (updateRowData), a new order lands at the top of its warehouse and a shipped one leaves (applyTransactionAsync, batched every 500 ms, addressed by route). Every result carries a status the log shows: applied, cancelled under the veto hook, storeNotFound for a warehouse whose level is not cached. Refresh totals recomputes the sums a transaction leaves alone.
  • Server selection: select all, minus these - The header checkbox selects every row the filter matches, loaded or not, and the selection becomes a rule: all except these ids, or per group under grouping. The panel shows getSelectionState() live, Save and Restore round-trip it, and a bulk action sends the rule to the server as one updateWhere that answers with the count it changed. The selection bar shows the server's number, not the ticks on screen.
  • Server row model to SQL - What the backend runs for each request: planQuery turns the ServerRequest into a QueryPlan against an entity schema, planToSql renders it for Postgres, MySQL or SQLite, and the panel shows the statements createSqlDataSource would hand your executor for the request that just went out - grouped or flat rows, the count, the grand total, and the two statements a pivot needs. The rows come from the in-memory reference source over the same plan.
  • Server grouping: totals, sort and refresh rules - The options around a grouped row model with the request log to show what each costs: a grand total in any of four positions, subtotal footers, levels that open on arrival, expand-all that reaches groups not loaded yet, refresh in place versus purge, and the rules for what a sort or a filter re-requests - a plain column re-fetches leaf levels only, a group column its own level, an aggregated column every level; a filter purges all or only the groups it touches.
  • Server row model: CRUD - The four writes over a grouped server row model, against a backend that says no: a new order through the form lands under the focused region as a transaction, an inline edit is one updateRow carrying the row's version so a lost race is refused, a delete of a shipped order is refused, a delete that went through is undone. saving shows while a write is out; optimistic mode shows the edit before the server answers and puts the row back when it refuses, and the slow-server switch makes the difference visible. The log is what the server received and what it said.
  • Server row model: master-detail - A detail panel under a leaf of a grouped server row model: the chevron on an order asks the model to open its detail (toggleDetail), the model puts a detail row under the leaf, the grid draws it through renderDetailRow at a height the virtualizer knows (detailRowHeight), and the panel fetches the order's line items from a second endpoint the moment it appears. The tree stays virtualized, and the region row holds under the header (stickyGroupRows) while the panels scroll past.
  • Server grouping (row model) - Server-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. 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.
  • 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 grouping + aggregates - GROUP BY + SUM/AVG pushed to the server; pre-aggregated buckets with on-demand drill-in.
  • Server-side data - Sort/filter/page round-tripped to a mock endpoint with debounce + cancel.
  • Server-side rendering - SvelteKit-style SSR with a sandboxed pre-hydration snapshot.

Related articles