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):
Open the live example: Headless server-side (Headless)
The shape of it
There is no local pipeline here - just coreRowModel. You own three things:
- The query state (
pageIndex,pageSize,sort,desc,q) - the inputs your API takes. - A fetch that re-runs when that state changes - an
$effectis enough. - The engine, wrapping only the page you got back.
<script lang="ts">
import { createSvGrid, createCoreRowModel, tableFeatures, type ColumnDef } from '@svgrid/grid/core'
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 features = tableFeatures({}) // the server sorts, filters and pages
const columns: ColumnDef<typeof features, Row>[] = [
{ field: 'name', header: 'Name' },
{ field: 'dept', header: 'Department' },
{ field: 'salary', header: 'Salary' },
]
const table = $derived.by(() =>
createSvGrid({
_features: features,
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
data: pageRows,
columns,
}),
)
// Touch the state this component owns so the derived re-runs:
// the engine's store is framework-free and not a rune.
const rows = $derived.by(() => {
pageIndex
sort
desc
q
pageRows
total
loading
return 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
- Reset the page on a new sort or filter. A different sort or query produces a
different result set, so
pageIndex = 0- otherwise you can land on an out-of-range page. - Guard against out-of-order responses. Fast typing fires overlapping
requests; without the
reqSeqcheck an early response can overwrite a later one. Increment a sequence number per request and ignore any response that is not the latest. - Debounce the search if each keystroke hits a real network. A short
setTimeoutonqkeeps you from firing a request per character.
More examples
Server-side rendering
SvelteKit-style SSR with a sandboxed pre-hydration snapshot.
Open the live example: Server-side rendering (Integrations)
Sort, filter and page on the server
The three external* flags say "report, do not apply". The grid then renders
exactly the rows you hand it, and rowCount is what lets the pager show a total
it cannot count itself.
<script lang="ts">
import { SvGrid, type GridColumns } from '@svgrid/grid'
type Order = { id: string; customer: string; total: number }
// Stands in for the database.
const ALL: Order[] = Array.from({ length: 87 }, (_, i) => ({
id: 'A-' + String(1000 + i),
customer: ['Northwind', 'Contoso', 'Fabrikam'][i % 3]!,
total: 60 + ((i * 53) % 800),
}))
const columns: GridColumns<Order> = [
{ field: 'id', header: 'Ref', width: 110 },
{ field: 'customer', header: 'Customer', width: 170 },
{ field: 'total', header: 'Total', width: 130,
format: { type: 'currency', currency: 'USD' } },
]
let pageIndex = $state(0)
let pageSize = $state(10)
let sorting = $state<Array<{ id: string; desc: boolean }>>([])
// What a real endpoint would do, minus the network.
const view = $derived.by(() => {
let rows = [...ALL]
const s = sorting[0]
if (s) {
rows.sort((a, b) => {
const av = a[s.id as keyof Order], bv = b[s.id as keyof Order]
const cmp = av < bv ? -1 : av > bv ? 1 : 0
return s.desc ? -cmp : cmp
})
}
return rows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)
})
</script>
<SvGrid
data={view}
{columns}
getRowId={(r) => r.id}
sortable
externalSort
onSortingChange={(next) => { sorting = next; pageIndex = 0 }}
pageable
externalPagination
rowCount={ALL.length}
{pageIndex}
{pageSize}
onPaginationChange={(p) => { pageIndex = p.pageIndex; pageSize = p.pageSize }}
/>
<p>Serving rows {pageIndex * pageSize + 1}-{Math.min((pageIndex + 1) * pageSize, ALL.length)} of {ALL.length}</p>
See also
- Row models - the local pipeline, for when the client owns the data
- Controlled state -
statein,onXxxChangeout - Build a table from scratch - the rendering half
- Server-side data (with
<SvGrid>) - the same idea, wired for you
Live examples
- Headless server-side - Paging + sorting + filtering + load on demand. The "server" owns the data and returns one page at a time; each state change fires a single request. The engine wraps only the current page.
- Server-side rendering - SvelteKit-style SSR with a sandboxed pre-hydration snapshot.
Related articles
- 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.
- Client-Side vs Server-Side Data for Tables - The architectural fork that determines your grid's performance ceiling - how to pick the right data mode and wire it correctly in SvGrid.