Server grouping

Grouping a hundred thousand rows in the browser means shipping all hundred thousand rows first. Server grouping flips that: the backend runs the GROUP BY, and the grid receives one pre-aggregated row per group - a key plus its subtotals. Expanding a group then lazily drills into the next level (or the raw rows) for that group only, so the network never carries the full table.

This is first-class in SvGrid: grouping flows through the same ServerDataSource.getRows contract as paging, sorting, and filtering. The request carries groupBy (the columns grouped on) and groupKeys (the path of the group being expanded); createServerGroupModel owns the group tree - lazy fetch per level, caching, race-safety, expand/collapse - and hands you a flat list of display rows to render.

Server grouping flow: the backend runs GROUP BY and returns one pre-aggregated row per group instead of the raw table; the grid renders those group rows; expanding one group drills into the next level for that group only.

Open the live example: Server grouping (first-class) (Server-Side Data)

The contract

One getRows. When groupKeys.length < groupBy.length the server returns group rows (one per distinct key at that level, carrying the group key and its aggregates); when they are equal it returns the leaf rows under that path.

The examples on this page import from @svgrid/grid:

<script lang="ts">
  import { SvGrid, SvRowGroupPanel } from '@svgrid/grid'
</script>
async function getRows(req) {
  const level = req.groupKeys.length
  if (level < req.groupBy.length) {
    // GROUP row level: GROUP BY the column at this level, within groupKeys.
    const col = req.groupBy[level]                       // e.g. 'country', then 'city'
    // SELECT country AS key, SUM(amount) amount, COUNT(*) n
    //   FROM sales WHERE <groupKeys path> GROUP BY country
    return { rows: groupRows, rowCount: groupRows.length }
  }
  // LEAF level: the raw rows under the fully-specified path.
  // SELECT * FROM sales WHERE country = $1 AND city = $2 LIMIT ...
  return { rows: leafRows, rowCount: total }
}

You probably do not have to write that

Mapping the grid's request onto a backend query is the part teams actually find hard, so @svgrid/enterprise ships adapters that already do it - SQL, REST and Supabase - including the grouped case above.

import { planQuery, planToSql } from '@svgrid/enterprise'

// In your endpoint. `schema` is the EntitySchema for the table.
const plan = planQuery(schema, request)
const sql = planToSql(plan, { placeholders: '$', ilike: true }) // Postgres

const rows = await db.query(
  plan.groupBy
    // Grouped level: the plan hands you the SELECT list and GROUP BY.
    ? `SELECT ${sql.select} FROM sales ${sql.whereText} ${sql.groupByText}
       ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`
    // Leaf level: your own columns.
    : `SELECT * FROM sales ${sql.whereText}
       ${sql.orderByText} LIMIT ${sql.limit} OFFSET ${sql.offset}`,
  sql.params,
)

Three details the plan handles that are easy to get wrong by hand:

Only fields declared on the EntitySchema reach the plan, so a client cannot group by or aggregate an identifier you did not declare.

For REST, createRestDataSource sends ?groupBy=region&aggregate=sum:amount plus the path as ordinary filter params. For Postgres via PostgREST, createSupabaseDataSource uses aggregate selects (requires PostgREST 12+ with aggregates enabled). createInMemoryDataSource implements the whole contract in memory and is the reference to test your own backend against.

Each group row is a plain object carrying the group column's value (under that column's field) and the aggregate values (under each aggregation column) - the controller reads them straight off the row.

The display-row pipeline: getRows with groupBy and groupKeys feeds the controller's cached group tree, flatten produces one displayRows list, and each row is one of five kinds - group, leaf, Load more, Total, or skeleton - that SvGroupCell renders.

Wiring the model

import { createServerGroupModel, type ServerGroupState } from '@svgrid/grid'

let view = $state<ServerGroupState<Sale>>()
const ctl = createServerGroupModel<Sale>(source, {
  groupBy: ['country', 'city'],                 // group two levels deep
  aggregations: [{ col: 'amount', fn: 'sum' }], // roll up per group
  onChange: (s) => (view = s),
})
ctl.refresh() // load the top level

view.displayRows is the flattened tree: top-level groups, with each expanded group's children spliced in beneath it. Every group row carries level (for indentation), expanded, loading, key, and aggregates.

Rendering the display rows

Three built-ins do the work, so you write no cell markup. serverGroupRows maps the display rows to grid rows (spreading each row's data, so a value column shows the subtotal on a group row and the cell value on a leaf); the shipped SvGroupCell draws the expander + indentation; and serverGroupNav(ctl) is one handler that drives both the cell clicks and the grid's keyboard:

<script lang="ts">
  import { SvGrid, serverGroupRows, serverGroupNav, SvGroupCell, renderComponent } from '@svgrid/grid'

  const nav = serverGroupNav(ctl)
  const rows = $derived(serverGroupRows(view))
  const columns = [
    { field: 'country', header: 'Group', width: 280,
      cell: (ctx) => renderComponent(SvGroupCell, {
        row: ctx.row.original, onToggle: nav.onToggle, leafField: 'name',
      }) },
    { field: 'amount', header: 'Amount', align: 'right',
      format: { type: 'number', options: { style: 'currency', currency: 'USD' } } },
  ]
</script>

<SvGrid data={rows} {columns} serverGroup={nav} />

SvGroupCell renders the group key with an expander (indented by depth) for group rows and the leafField value for leaves. Want full control? Every grid row carries a __group marker (the ServerDisplayRow), so you can skip SvGroupCell and render your own cell from it.

Keyboard and accessibility

serverGroup={nav} makes the grid handle tree navigation itself - built in, no app key handling:

It works for tree mode the same way.

Load more within a group

By default the controller fetches up to pageSize (200) children per group in one call. When a group has more, serverGroupRows emits a load more row at the end of its loaded children, which SvGroupCell renders as a "Load N more" button; clicking it (or calling ctl.loadMoreChildren(path)) appends the next block. Set pageSize to control the block size:

createServerGroupModel(source, { groupBy: ['country'], pageSize: 50, onChange })

While a group's first block is loading, serverGroupRows emits placeholder skeleton rows (count via skeletonRows, default 3) that SvGroupCell renders as a shimmer, so an expand never shows an empty gap.

Subtotal footers

Turn on groupFooters and each expanded group gets a Total row after its children, carrying the group's aggregates again so the value columns show the subtotal under the detail:

createServerGroupModel(source, { groupBy: ['region', 'country'], aggregations, groupFooters: true, onChange })

A row-group panel (drag to group)

SvRowGroupPanel is a "group by" bar: it shows the current group columns as chips you can remove or drag to reorder, plus a menu to add one, and it accepts a column drop (text/sv-column). Wire its onChange to setGroupBy:

<script lang="ts">
  import { SvRowGroupPanel } from '@svgrid/grid'
  const groupCols = [{ id: 'region', label: 'Region' }, { id: 'country', label: 'Country' }]
</script>

<SvRowGroupPanel columns={groupCols} groupBy={view.groupBy} onChange={(g) => ctl.setGroupBy(g)} />

Multi-level grouping is automatic

Set groupBy: ['region', 'industry', 'quarter'] and the controller fetches each level on demand: the top level returns regions, expanding a region fetches its industries, expanding an industry fetches its quarters, and expanding a quarter returns the raw rows. You never configure the levels - each expand is just another getRows with a longer groupKeys. Change the grouping at runtime with ctl.setGroupBy([...]); sorting and filtering re-fetch the visible tree via ctl.setSort / ctl.setFilter.

The win

For a 100,000-row sales table grouped by three dimensions, the top level returns a handful of group rows instead of 100,000 raw rows. The client groups nothing and holds almost nothing. Grouping 100k rows in JS runs in hundreds of milliseconds; asking the server for the pre-grouped result returns a few rows in tens of milliseconds, and the payload shrinks by orders of magnitude.

Without the controller (manual pattern)

If your backend or UI needs something bespoke, you can still assemble grouping by hand: fetch pre-grouped rows, render them as ordinary rows, and expand each into a second <SvGrid data={detailRows}> or an expandable detail row (isDetailRow + renderDetailRow), keeping the expanded group id and its lazily fetched detail rows in your own state. The tree toggle pattern is a third option for a single flat, indented list. createServerGroupModel is the batteries-included version of exactly this.

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

Try it

Grouping on the client is a prop; the point of the server version is that the rollup happens where the rows are. This runs the same shape locally so the group model is visible - your query returns pre-grouped rows instead.

<SvGrid
  data={people}
  {columns}
  groupBy={['department']}
  groupable
  summary
  sortable
/>

Compare that with what a server has to return: one row per group with its aggregate already computed, plus the leaves for whichever groups are expanded.

Group headers the server computed

When the server does the rollup it sends group rows and leaf rows in one flat list, already ordered. Rendering that is tree data with a parent field - the grid does not need to know a group was computed elsewhere.

<script lang="ts">
  import { SvGrid, type GridColumns } from '@svgrid/grid'

  type Row = { id: string; parentId: string | null; label: string; total: number }

  // What the endpoint returned: two group rows with their totals, plus leaves.
  const fromServer: Row[] = [
    { id: 'g-eng',  parentId: null,    label: 'Engineering', total: 310000 },
    { id: 'p-ada',  parentId: 'g-eng', label: 'Ada Lovelace', total: 142000 },
    { id: 'p-grace',parentId: 'g-eng', label: 'Grace Hopper', total: 168000 },
    { id: 'g-plat', parentId: null,    label: 'Platform',     total: 327000 },
    { id: 'p-linus',parentId: 'g-plat',label: 'Linus Torvalds', total: 155000 },
    { id: 'p-barb', parentId: 'g-plat',label: 'Barbara Liskov', total: 172000 },
  ]

  const columns: GridColumns<Row> = [
    { field: 'label', header: 'Group / person', width: 240 },
    { field: 'total', header: 'Total', width: 140,
      format: { type: 'currency', currency: 'USD' } },
  ]
</script>

<SvGrid
  data={fromServer}
  {columns}
  treeData={{ parentField: 'parentId', idField: 'id', column: 'label' }}
/>

See also

Live examples

  • 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.
  • Server-side grouping + aggregates - GROUP BY + SUM/AVG pushed to the server; pre-aggregated buckets with on-demand drill-in.

Related articles