Server-side data (load on demand)

When the rows live behind an API and there are too many to ship to the browser, the server does the work: sorting, filtering, and paging become query params, and it returns only the current page plus a total count. The headless engine wraps that page and nothing else - it never sees the rest of the dataset.

Try it: search, click a header to sort, page through. Each change fires exactly one request (watch the counter climb):

The shape of it

There is no local pipeline here - just coreRowModel. You own three things:

  1. The query state (pageIndex, pageSize, sort, desc, q) - the inputs your API takes.
  2. A fetch that re-runs when that state changes - an $effect is enough.
  3. The engine, wrapping only the page you got back.
<script lang="ts">
  import { createSvGrid, createCoreRowModel, type ColumnDef } from '@svgrid/grid'

  type Row = { id: number; name: string; dept: string; salary: number }

  // 1. Query state - the inputs your endpoint takes.
  const pageSize = 8
  let pageIndex = $state(0)
  let sort = $state<'name' | 'dept' | 'salary'>('name')
  let desc = $state(false)
  let q = $state('')

  // Server response.
  let pageRows = $state<Row[]>([])
  let total = $state(0)
  let loading = $state(false)
  let reqSeq = 0

  // 2. Re-fetch whenever any query input changes. The sequence guard drops
  //    stale responses if a newer request resolves first (race-safe).
  $effect(() => {
    const query = { pageIndex, pageSize, sort, desc, q }
    const mine = ++reqSeq
    loading = true
    fetch(`/api/employees?${new URLSearchParams(query as never)}`)
      .then((r) => r.json())
      .then((res: { rows: Row[]; total: number }) => {
        if (mine !== reqSeq) return
        pageRows = res.rows
        total = res.total
        loading = false
      })
  })

  // 3. The engine wraps ONLY the returned page - no filtered/sorted/paginated
  //    row model, because the server already did all of that.
  const columns: ColumnDef<Record<string, never>, Row>[] = [
    { field: 'name', header: 'Name' },
    { field: 'dept', header: 'Department' },
    { field: 'salary', header: 'Salary' },
  ]
  const table = $derived.by(() =>
    createSvGrid({
      _rowModels: { coreRowModel: createCoreRowModel<Row>() },
      data: pageRows,
      columns,
    } as never),
  )
  const rows = $derived(table.getRowModel().rows)
  const pageCount = $derived(Math.max(1, Math.ceil(total / pageSize)))
</script>

Render rows however you like, and drive the query state from your controls:

<input value={q} oninput={(e) => { q = e.currentTarget.value; pageIndex = 0 }} />

<table>
  <thead>
    <tr>
      {#each columns as col (col.field)}
        <th onclick={() => {
          if (sort === col.field) desc = !desc
          else { sort = col.field; desc = false }
          pageIndex = 0
        }}>
          {col.header}{sort === col.field ? (desc ? ' ▼' : ' ▲') : ''}
        </th>
      {/each}
    </tr>
  </thead>
  <tbody>
    {#each rows as r (r.id)}
      {@const row = r.original as Row}
      <tr><td>{row.name}</td><td>{row.dept}</td><td>{row.salary}</td></tr>
    {/each}
  </tbody>
</table>

<button onclick={() => (pageIndex -= 1)} disabled={pageIndex === 0 || loading}>Prev</button>
Page {pageIndex + 1} of {pageCount}
<button onclick={() => (pageIndex += 1)} disabled={pageIndex >= pageCount - 1 || loading}>Next</button>

Why only coreRowModel?

Because the pipeline already ran - on the server. Adding filteredRowModel / sortedRowModel / paginatedRowModel would make the engine try to filter, sort and slice the page you already fetched, which is wrong: it only has 8 of the 483 rows. Feed the engine the finished page and let it do the one job left - wrap each item as a Row so your markup and any cell logic work the same as everywhere else.

Three things worth getting right

See also