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.
Open the live example: Server-Side Row Model: paged and infinite (Server-Side Data)
Scope.
createServerDataSourceis the free, flat row model: one page at a time inpagemode, or one long block-cached list ininfinitemode. 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:
- Flat rows only.
createServerDataSourcesendsgroupBy: []. Lazy grouping per level, tree data and pivot arecreateServerRowModelin@svgrid/enterprise- the samegetRows, withgroupBy,groupKeys,aggregationsandpivotByfilled in. See Server grouping. - A selection is the loaded rows. Select-all over rows the grid never fetched, and a bulk edit sent as a rule, are the Enterprise model's selection.
- Writes refetch. A write re-reads the page (or the held blocks); rows are not spliced in by hand. Route-addressed transactions are Enterprise.
- Writes are non-optimistic unless you opt in (above).
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:
adaptCallbackDatasource(ds)wraps a datasource object of that shape forcreateServerRowModelorcreateServerDataSource.toCallbackRequest(request)turns aServerRequestinto that request JSON, for agetRowsthat is afetchagainst an endpoint which already parses it;fromCallbackRequestreads it, for an endpoint of yours that has to serve such a client too.toCallbackSelectionState/fromCallbackSelectionStatedo the same for a saved selection rule (toggledNodesinstead oftoggled); see Server selection.
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
- Server-side infinite scroll -
mode: 'infinite': blocks, placeholders, retry, unknown counts. - Tutorial: a Postgres CRUD grid - a complete server route + query builder.
- Tutorial: a REST CRUD grid - the same contract over an existing JSON API.
- Data binding - the ServerDataSource contract across every backend.
- Server-side data - the wider server-mode overview.
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
- Inside SvGrid: The Row Model and Sorting - How sorting shaped SvGrid's row-model pipeline - the decisions made early that every later feature inherited.
- Using SvGrid with TanStack Query in Svelte - Wire TanStack Query's caching and background refetch into SvGrid for a server-driven grid that pages instantly and never shows a blank screen.
- A Svelte Data Grid with SvelteKit and Supabase - Wire SvGrid to a Supabase Postgres backend with server-side pagination, sorting, and filtering - keeping credentials on the server and queries fast with proper indexing.