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.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Server-Side Data). See the SvGrid documentation for the full API.
What this example shows
First-class server-side grouping through ONE getRows contract. The request carries groupBy + groupKeys; createServerGroupModel owns the group tree (lazy expand per level, aggregation, per-node cache, race-safety) and hands back a flat displayRows list to render. Here a 60,000-row in-memory "server" behind 200ms latency - the grid only ever holds what you expand.
Imports, features and API used
Imports: @svgrid/grid
Columns: region (Group), n (Rows), amount (Amount)
Source code (344-server-grouping-model.svelte)
<!-- Documented in: docs/help/server/server-grouping.md -->
<script lang="ts">
/**
* 344. Server grouping (first-class)
* ----------------------------------
* First-class server-side grouping through ONE getRows contract. The
* request carries groupBy + groupKeys; createServerGroupModel owns the group
* tree (lazy expand per level, aggregation, per-node cache, race-safety) and
* hands back a flat displayRows list to render. Here a 60,000-row in-memory
* "server" behind 200ms latency - the grid only ever holds what you expand.
*/
import {
SvGrid,
createServerGroupModel,
serverGroupRows,
serverGroupNav,
SvGroupCell,
SvRowGroupPanel,
renderComponent,
tableFeatures,
type ColumnDef,
type ServerDataSource,
type ServerGroupState,
type ServerGroupGridRow,
} from '@svgrid/grid'
const features = tableFeatures({})
type Sale = { region: string; country: string; rep: string; amount: number }
const REGIONS: Record<string, string[]> = {
Americas: ['US', 'BR', 'CA'],
EMEA: ['DE', 'UK', 'FR'],
APAC: ['JP', 'AU', 'IN'],
}
// The "database": 63,000 rows that never touch the grid wholesale.
const DB: Sale[] = (() => {
const out: Sale[] = []
let i = 0
for (const [region, countries] of Object.entries(REGIONS))
for (const country of countries)
for (let k = 0; k < 7000; k++)
out.push({ region, country, rep: `Rep ${i % 50}`, amount: 500 + ((i++ * 7919) % 9500) })
return out
})()
// The server: GROUP BY the requested level within groupKeys, or return leaves.
const source: ServerDataSource<Sale> = {
async getRows(req) {
await new Promise((r) => setTimeout(r, 200)) // simulated latency
// `groupBy` / `groupKeys` are optional on ServerRequest (a flat source may
// omit them); this grouped source is always called with both.
const subset = DB.filter((r) =>
req.groupKeys!.every((k, i) => String((r as Record<string, unknown>)[req.groupBy![i]!]) === k),
)
const level = req.groupKeys!.length
if (level < req.groupBy!.length) {
const field = req.groupBy![level]!
const map = new Map<string, Record<string, unknown>>()
for (const r of subset) {
const key = String((r as Record<string, unknown>)[field])
const g = map.get(key) ?? { [field]: (r as Record<string, unknown>)[field], amount: 0, n: 0 }
g.amount = (g.amount as number) + r.amount
g.n = (g.n as number) + 1
map.set(key, g)
}
const rows = [...map.values()] as unknown as Sale[]
return { rows, rowCount: rows.length }
}
// Honor the requested block so intra-group paging returns one page at a time.
return { rows: subset.slice(req.startRow, req.endRow), rowCount: subset.length }
},
}
let view = $state<ServerGroupState<Sale>>()
const ctl = createServerGroupModel<Sale>(source, {
groupBy: ['region', 'country'],
aggregations: [{ col: 'amount', fn: 'sum' }],
pageSize: 20, // small block so intra-group "Load more" is visible on the leaves
groupFooters: true, // subtotal row after each expanded group
onChange: (s) => (view = s),
})
// Columns the row-group panel lets you group / regroup by (drives ctl.setGroupBy).
const groupCols = [
{ id: 'region', label: 'Region' },
{ id: 'country', label: 'Country' },
{ id: 'rep', label: 'Rep' },
]
ctl.refresh()
// One handler drives both the SvGroupCell clicks and the grid's keyboard.
const nav = serverGroupNav(ctl)
// serverGroupRows maps the controller's displayRows to grid rows; the built-in
// SvGroupCell draws the expander + indent. No hand-written cell recipe.
type GridRow = ServerGroupGridRow<Sale> & { n?: number }
const rows = $derived<GridRow[]>(serverGroupRows(view))
const usd = { type: 'number' as const, options: { style: 'currency' as const, currency: 'USD', maximumFractionDigits: 0 } }
const columns: ColumnDef<typeof features, GridRow>[] = [
{
field: 'region',
header: 'Group',
width: 300,
cell: (ctx) => renderComponent(SvGroupCell, { row: ctx.row.original, onToggle: () => nav.onToggle(ctx.row.original), leafField: 'rep' }),
},
{ field: 'n', header: 'Rows', width: 110, align: 'right' },
{ field: 'amount', header: 'Amount', width: 170, align: 'right', format: usd },
]
</script>
<div class="wrap">
<p class="hint">
Click a region to drill into its countries, then into the raw rows. Each expand is one
<code>getRows</code> with a longer <code>groupKeys</code> - 63,000 rows on the "server", the grid
holds only what you expand.
</p>
<SvRowGroupPanel columns={groupCols} groupBy={view?.groupBy ?? []} onChange={(g) => ctl.setGroupBy(g)} />
<div class="grid"><SvGrid responsive={true}
columnResize data={rows} {columns} {features} serverGroup={nav} /></div>
</div>
<style>
.wrap { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }
.hint { font-size: 13px; color: var(--sg-muted, #64748b); margin: 0; }
.grid { flex: 1; min-height: 0; }
</style>Related documentation
Related articles
- Inside SvGrid: Grouping, Trees, and Master-Detail - Three different ways to show hierarchy in a data grid, unified under one expansion model in SvGrid - the design decision and how each feature actually works.
- 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.
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-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.
- 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.