<!-- https://svgrid.com/docs/help/server/server-paging/ - SvGrid documentation as markdown. Index of every page: https://svgrid.com/llms.txt -->

# Server paging

When the [Server-Side Row Model](https://svgrid.com/docs/help/server/server-row-model/) holds one page in memory,
paging is just the act of choosing which page. The `createServerDataSource`
controller turns a page index into an offset/limit request, asks your backend for
exactly those rows plus a total, and hands both back so a pager can draw itself.
This page is the deep dive on that loop: how the numbers are computed, how to wire
a "Load more" or numbered pager, how to change page size, and what to do when the
backend cannot cheaply count the total.

> Live demo: Server-Side Row Model: paged and infinite - https://svgrid.com/demos/148-server-row-model/

![A page round-trip: the pager calls ctl.setPage, the controller calls getRows with startRow and endRow, the backend runs LIMIT and OFFSET, and returns rows plus rowCount for the grid to render one page.](https://svgrid.com/docs-media/server-paging.svg)

## Offset / limit, computed for you

You never compute the offset yourself. When you call `setPage(i)` or
`setPageSize(n)`, the controller derives the row window from `pageIndex` and
`pageSize` and passes all four numbers into every `getRows` request:

```ts
type ServerRequest = {
  startRow: number   // pageIndex * pageSize        -> SQL OFFSET
  endRow: number     // startRow + pageSize (exclusive) -> OFFSET + LIMIT
  pageIndex: number
  pageSize: number
  sortModel: ServerSortModel
  filterModel: ServerFilterModel
}
```

`startRow` is inclusive and `endRow` is one past the last row wanted, so the page
size is exactly `endRow - startRow`. In SQL that is a plain window:

```sql
SELECT * FROM people
ORDER BY id
LIMIT  $pageSize          -- endRow - startRow
OFFSET $startRow;         -- pageIndex * pageSize
```

Your `getRows` returns the page and the total:

```ts
import { createServerDataSource, type ServerDataSource } from '@svgrid/grid'

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

const source: ServerDataSource<Row> = {
  async getRows({ startRow, endRow }) {
    const limit = endRow - startRow
    const res = await fetch(`/api/people?offset=${startRow}&limit=${limit}`)
    const { rows, total } = await res.json()
    return { rows, rowCount: total } // rowCount = total AFTER filtering
  },
}
```

## rowCount is the total after filtering

`rowCount` is not the number of rows you returned on this page - it is the total
number of rows the current filter matches across the whole table. The controller
stores it as `state.total` and, on every emit, computes the page count from it:

```ts
// inside the controller, on each state change:
state.pageCount = Math.max(1, Math.ceil(state.total / state.pageSize))
```

So `pageCount` is `ceil(total / pageSize)`, clamped to a minimum of 1, and it is
handed to you ready-made on `ServerState`. When a filter narrows the result set,
your next `getRows` returns a smaller `rowCount`, `pageCount` shrinks, and the
pager redraws with fewer pages. That is why `setSort` and `setFilter` both jump
back to page 0: the old page index may no longer exist.

## Reading the paging state

`onChange` fires on every transition with the full snapshot; the paging-relevant
fields are:

```ts
type ServerState<Row> = {
  rows: ReadonlyArray<Row>
  total: number       // rowCount from the last fetch (after filtering)
  loading: boolean    // a getRows fetch is in flight
  pageIndex: number   // zero-based
  pageSize: number
  pageCount: number   // ceil(total / pageSize), min 1
  // ...sortModel, filterModel, saving, error
}
```

Wire it up in Svelte with runes:

```svelte
<script lang="ts">
  import { SvGrid } from '@svgrid/grid'
  import { createServerDataSource, type ServerState } from '@svgrid/grid'
  import { source, columns } from './people'

  let view = $state<ServerState<Row>>()
  const ctl = createServerDataSource(source, {
    pageSize: 50,
    onChange: (s) => (view = s),
  })
  ctl.refresh()
</script>

{#if view}
  <SvGrid
    data={view.rows}
    {columns}
    loading={view.loading}
    pageable={false}
  />
{/if}
```

`pageable={false}` hands paging to your own pager plus the controller - the grid
does not slice or count the data itself, because the server already did.

The shorter form hands the controller to the grid and keeps the built-in
footer pager: the model supplies `pageIndex`, `pageSize` and the server's
`rowCount`, and the footer's page buttons and size selector call
`setPage` / `setPageSize` on it.

```svelte
<SvGrid rowModel={ctl} {columns} pageable />
```

No pages at all? `createServerDataSource(source, { mode: 'infinite' })` is
one block-cached list the user scrolls; see
[Server-side infinite scroll](https://svgrid.com/docs/help/server/server-infinite-scroll/). Paging a
grouped tree - a page of top-level groups, or of the flattened rows - is
the Enterprise row model's `pagination` option, in
[Server grouping](https://svgrid.com/docs/help/server/server-grouping/#paging-the-tree).

## A numbered pager

With `pageIndex` and `pageCount` in hand, a numbered pager is `setPage` calls
against a clamped range. `setPage` ignores a no-op (the same index) and clamps a
negative index to 0, so you can wire buttons directly:

```svelte
{#if view}
  <nav aria-label="Pagination">
    <button disabled={view.pageIndex === 0}
            onclick={() => ctl.setPage(view.pageIndex - 1)}>Prev</button>

    <span>Page {view.pageIndex + 1} of {view.pageCount}</span>

    <button disabled={view.pageIndex >= view.pageCount - 1}
            onclick={() => ctl.setPage(view.pageIndex + 1)}>Next</button>
  </nav>
{/if}
```

`setPage` does not clamp against `pageCount` for you - it only floors at 0 - so
disable the Next button (or clamp the argument) at the last page as shown.

## "Load more" instead of pages

A "Load more" button is the same controller, accumulating rows in your component
instead of replacing them. Keep a running list, and on each `getRows` append the
new page rather than swapping it:

```svelte
<script lang="ts">
  let all = $state<Row[]>([])
  let view = $state<ServerState<Row>>()

  const ctl = createServerDataSource(source, {
    pageSize: 50,
    onChange: (s) => {
      view = s
      // Append the freshly loaded page to what we already have.
      all = s.pageIndex === 0 ? [...s.rows] : [...all, ...s.rows]
    },
  })
  ctl.refresh()

  const hasMore = $derived(!!view && view.pageIndex < view.pageCount - 1)
  const loadMore = () => view && ctl.setPage(view.pageIndex + 1)
</script>

<SvGrid data={all} {columns} loading={view?.loading} pageable={false} />

{#if hasMore}
  <button onclick={loadMore} disabled={view?.loading}>Load more</button>
{/if}
```

Note that a `setSort` or `setFilter` returns to page 0, at which point the
`s.pageIndex === 0` branch resets the accumulator - which is exactly what you
want, because the ordering or the matching set just changed.

## Changing page size

`setPageSize(n)` sets a new size, resets to page 0, and re-fetches. It floors the
size at 1, so a bad input can never produce an empty request:

```svelte
<label>
  Rows per page
  <select onchange={(e) => ctl.setPageSize(Number(e.currentTarget.value))}>
    {#each [25, 50, 100] as n}
      <option value={n} selected={view?.pageSize === n}>{n}</option>
    {/each}
  </select>
</label>
```

Because `pageCount` is derived from `total / pageSize`, changing the size
immediately reshapes the pager on the next emit - no extra bookkeeping.

## The unknown-total case

Some backends cannot cheaply produce an exact `COUNT(*)` for the filtered set -
a large table, an expensive join, or a search API that only reports "there are
more". `rowCount` still has to be a number, so you have two honest options.

**Return an estimate.** If your database can give a cheap approximate count (for
example Postgres `reltuples`), pass it as `rowCount`. `pageCount` will be an
estimate too, which is fine for a "Page 4 of ~2,000" label - just don't hard-disable
Next purely on the estimate.

**Drive "Load more" from a `hasMore` flag.** When you truly cannot count, over-fetch
by one row to learn whether another page exists, and report an open-ended total so
the controller never thinks it has reached the end:

```ts
const source: ServerDataSource<Row> = {
  async getRows({ startRow, endRow }) {
    const limit = endRow - startRow
    // Ask for one extra row than the page needs.
    const res = await fetch(`/api/search?offset=${startRow}&limit=${limit + 1}`)
    const rows: Row[] = await res.json()

    const hasMore = rows.length > limit
    const page = hasMore ? rows.slice(0, limit) : rows

    // No real total: report "at least this many, plus one more page" while
    // there is more, so pageCount always leaves a next page to load.
    const rowCount = hasMore ? startRow + page.length + 1 : startRow + page.length
    return { rows: page, rowCount }
  },
}
```

Now pair it with the "Load more" pattern above: `hasMore` (derived from
`pageIndex < pageCount - 1`) stays true until the last, short page arrives, at
which point `rowCount` settles to the real running total and the button hides.
Avoid a numbered pager in this mode - the page numbers would be fictional.

In `infinite` mode there is a third option: return `rowCount: -1` and the
controller runs without a total, growing the scrollbar a block past the
highest loaded block until the first short block marks the end.

## A paged grid against a fake endpoint

`externalPagination` means the footer stops slicing and starts reporting. You
give it `rowCount`, `pageIndex` and `pageSize`; it gives you
`onPaginationChange`, and everything else - the range text, the page count, the
disabled arrows - it works out from those three numbers.

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

  type Row = { id: number; name: string; city: string }

  const CITIES = ['London', 'New York', 'Portland', 'Seattle', 'Berlin']
  const ALL: Row[] = Array.from({ length: 137 }, (_, i) => ({
    id: i + 1,
    name: 'Person ' + (i + 1),
    city: CITIES[i % CITIES.length]!,
  }))

  // Stands in for GET /people?offset=&limit=
  async function fetchPage(pageIndex: number, pageSize: number) {
    await new Promise((r) => setTimeout(r, 150))
    const offset = pageIndex * pageSize
    return { rows: ALL.slice(offset, offset + pageSize), total: ALL.length }
  }

  let rows = $state<Row[]>([])
  let total = $state(0)
  let pageIndex = $state(0)
  let pageSize = $state(10)
  let loading = $state(false)

  const columns: GridColumns<Row> = [
    { field: 'id',   header: 'ID',   width: 80 },
    { field: 'name', header: 'Name', width: 180 },
    { field: 'city', header: 'City', width: 150 },
  ]

  async function load() {
    loading = true
    const page = await fetchPage(pageIndex, pageSize)
    rows = page.rows
    total = page.total
    loading = false
  }

  $effect(() => {
    void pageIndex
    void pageSize
    load()
  })
</script>

<p>{loading ? 'Loading...' : total + ' rows on the server'}</p>

<SvGrid
  data={rows}
  {columns}
  showPagination
  externalPagination
  rowCount={total}
  {pageIndex}
  {pageSize}
  onPaginationChange={(p) => {
    pageIndex = p.pageIndex
    pageSize = p.pageSize
  }}
/>
```

## Load more, when a page number is the wrong idea

A feed has no page 7. Keep the grid unpaged, append to the array, and let the
button carry the state - the grid never needs to know there is a server behind
it. The trade is that you lose the range text, so show a count yourself.

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

  type Event_ = { id: number; at: string; kind: string; actor: string }

  const KINDS = ['deploy', 'rollback', 'scale', 'alert']
  const ALL: Event_[] = Array.from({ length: 60 }, (_, i) => ({
    id: i + 1,
    at: '2026-06-' + String((i % 28) + 1).padStart(2, '0'),
    kind: KINDS[i % KINDS.length]!,
    actor: 'svc-' + ((i % 5) + 1),
  }))

  const PAGE = 12

  let rows = $state<Event_[]>(ALL.slice(0, PAGE))
  let loading = $state(false)

  const done = $derived(rows.length >= ALL.length)

  const columns: GridColumns<Event_> = [
    { field: 'at',    header: 'Date',  width: 130 },
    { field: 'kind',  header: 'Event', width: 130 },
    { field: 'actor', header: 'Actor', width: 130 },
  ]

  async function more() {
    loading = true
    await new Promise((r) => setTimeout(r, 200))
    rows = [...rows, ...ALL.slice(rows.length, rows.length + PAGE)]
    loading = false
  }
</script>

<SvGrid data={rows} {columns} containerHeight={220} />

<div>
  <button type="button" onclick={more} disabled={loading || done}>
    {done ? 'All loaded' : loading ? 'Loading...' : 'Load more'}
  </button>
  <span>{rows.length} of {ALL.length}</span>
</div>
```

## Paging a grouped tree

The Enterprise row model pages a grouped tree too: a page of top-level
groups with their children, or of the flattened rows with
`paginateChildRows`. The demo switches between scroll, "load more" and
paged for the same tree:

> Live demo: Server grouping (row model) - https://svgrid.com/demos/496-server-grouping-model/

## See also

- [Server-Side Row Model](https://svgrid.com/docs/help/server/server-row-model/) - the full datasource contract, controller methods, writes, and race safety.
- [Server-side infinite scroll](https://svgrid.com/docs/help/server/server-infinite-scroll/) - the same controller with blocks instead of pages.
- [Server sorting](https://svgrid.com/docs/help/server/server-sorting/) - the sibling deep dive on the sort model and ORDER BY.

---

SvGrid is the Svelte 5 data grid (`npm install @svgrid/grid`, MIT). This page: https://svgrid.com/docs/help/server/server-paging/ . All docs: https://svgrid.com/llms.txt
