Pagination

Create numbered and cursor pages for pagers and infinite scroll.

On this page

A long list is easier to browse when the server sends a small group of items at a time. A numbered page lets readers choose a page; a cursor page uses a marker to fetch the next or previous group. Start with a list already in memory:

Text
builder.Services.AddInertia();
var app = builder.Build();
app.UseInertia();

// posts is an IReadOnlyList<PostDto> in a stable order.
app.MapGet("/posts", (PageRequest request) => Render("Posts/Index", new InertiaProps
{
    ["posts"] = Pagination.Paginate(posts, request),
}));

Visiting /posts?page=2&perPage=5 returns the second group in posts.data. The default page size is 20 and the maximum is 100; configure both in Pagination options. For infinite scroll, wrap that value in Scroll(...) and render it with InfiniteScroll as shown in Infinite scroll.

Pagination creates page values; Scroll adds infinite-scroll metadata and merging. Use a page directly for a classic pager.

You writeYou get
Pagination.Paginate(posts, request)A numbered Page<T> for a pager
Scroll(Pagination.Paginate(posts, request))Numbered infinite scroll
Scroll(await Pagination.PaginateAsync(request, key, fetch, ct: ct))Cursor-based infinite scroll using your data source
Scroll(ct => Pagination.PaginateAsync(request, fetch, ct: ct)).Defer()A deferred page; other prop modifiers also work

Minimal API handlers can take PageRequest or CursorRequest directly, as in Infinite scroll. MVC and FastEndpoints can call PageRequest.From(Request.Query) and CursorRequest.From(HttpContext). Page numbers below 1 become 1; invalid page sizes use the default and sizes above the maximum are clamped. Set defaults and parameter names with o.Pagination in AddInertia. From and BindAsync retain the query parameter name for the returned page. An explicit paginator name overrides it.

Pagination.Paginate slices an already materialized IReadOnlyList<T>. For a database, search SDK or HTTP API, Pagination.PaginateAsync accepts a fetch(skip, take, ct) delegate and an optional count delegate. It requests one extra item to determine hasMore; without a count, total and lastPage are null. Delegate offsets above int.MaxValue return an empty page without calling fetch; the optional count still runs. Supply a stable, fully unique ordering to your data source. For a classic pager, use currentPage, lastPage, and pageName to build links; pages do not contain URL links.

Text
{ "data": [ { "id": 21 } ], "currentPage": 2, "perPage": 20, "total": 95, "lastPage": 5, "from": 21, "to": 40, "hasMore": true, "pageName": "page" }

from and to are one-based and null on an empty page. The examples abbreviate the data array.

For example, a data source can implement numbered pages without exposing IQueryable:

Text
var page = await Pagination.PaginateAsync(
    request,
    (skip, take, ct) => store.FetchPostsAsync(skip, take, ct),
    ct => store.CountPostsAsync(ct),
    ct: cancellationToken);

For a React pager, use Inertia links and preserve any other filters your app needs:

Text
import { Link } from '@inertiajs/react'

{page.lastPage != null && Array.from({ length: page.lastPage }, (_, i) => i + 1).map((n) => (
  <Link key={n} href={`?${page.pageName}=${n}`} aria-current={n === page.currentPage ? 'page' : undefined}>
    {n}
  </Link>
))}

Cursors

A cursor is a marker for the position of an item, useful when readers move forward and backward rather than jump to page numbers. The cursor overload of PaginateAsync takes a key selector and a fetch delegate. The delegate receives a decoded cursor with Direction (After or Before) and Value, a take count, and a cancellation token. Return rows in the requested direction, including up to one extra; the helper trims and reverses Before results to display order. Choose a stable unique non-null key. hasMore indicates more rows in the requested direction.

Text
{ "data": [ { "id": 21 } ], "perPage": 20, "nextCursor": "eyJ...", "previousCursor": null, "hasMore": true, "cursorName": "cursor" }

Cursors are URL-safe base64 of a compact JSON array: direction ("a" or "b") followed by the key values. Supported keys are int, long, Guid, string, DateTime, DateTimeOffset, decimal, double, and bool. Cursors are not tamper-proof; apps that need tamper protection should wrap them with IDataProtector.

A malformed or foreign nonempty cursor falls back to the first page. When CursorRequest.Logger is set, the paginator logs this at Debug without cursor values or exception details. Minimal API binding uses configured pagination options and supplies a logger in the Inertia.Net.Pagination category when a logger factory is available. In MVC or FastEndpoints, CursorRequest.From(HttpContext) does the same; optional arguments override the configured defaults and parameter names. For a query-only request or a request created outside HTTP, supply an app logger explicitly:

Text
var request = CursorRequest.From(query) with { Logger = logger };

Logger is ignored by JSON serialization and does not affect request equality.

Defer or cache a page

A page can use the same prop modifiers as other values. For example, Scroll(ct => Pagination.PaginateAsync(request, fetch, ct: ct)).Defer() loads it after the first response; a lazy prop can also use .Cache(...). See Deferred props and Props for loader and cache options.

With EF Core

If your app uses EF Core, install the optional package alongside your database provider:

Text
dotnet add package Inertia.Net.EntityFrameworkCore --version 1.7.0

The following uses an existing AppDb as db, with a Posts set. request is a PageRequest, cursorRequest is a CursorRequest, and ct is the request cancellation token:

Text
using Inertia.Net;
using Microsoft.EntityFrameworkCore;

// Numbered pages: order first, including a unique tie-breaker if needed.
var page = await db.Posts.OrderByDescending(p => p.Id)
    .ToPageAsync(request, ct: ct);

// Cursor pages: pass an unordered query. The helper applies the ordering.
var cursorPage = await db.Posts
    .ToCursorPageAsync(p => p.CreatedAt, p => p.Id, cursorRequest,
        descending: true, ct: ct);

return Render("Feed", new InertiaProps { ["posts"] = Scroll(cursorPage) });

Both results contain the selected group in data. A cursor is a marker: send it back unchanged to request the next or previous group. ToPageAsync requires an ordered query and counts rows by default; pass countTotal: false to skip the count and return null total and lastPage. ToCursorPageAsync rejects an already ordered query and orders by the supplied key and optional tie-breaker itself. Both guards throw InvalidOperationException. The cursor helper fetches in both directions without counting.

Choose a stable, fully unique ordering: use a unique key, or add a unique tie-breaker when the first key can repeat. Nullable keys are unsupported. The cursor codec supports the types listed above, but EF translation and ordering for each type depend on the database provider; for example, SQLite needs a suitable value conversion to order DateTimeOffset. The name from a bound request or From(...) is used for the corresponding page parameter; an explicit pageName or cursorName argument takes precedence.

The EF Core package is not marked Native AOT compatible. EF Core has its own compiled-model requirements; for Native AOT applications, use the core delegate paginators with an AOT-compatible data source instead.

Native AOT

Register each closed page type in your app's JsonSerializerContext along with the item types:

Text
[JsonSerializable(typeof(Page<PostDto>))]
[JsonSerializable(typeof(CursorPage<PostDto>))]

See Native AOT.

Caching and scroll props

A cached page still writes its scroll metadata. Use cache keys that include the page or cursor and all user or tenant dependencies. Scroll(...) can be deferred or cached; do not reuse its mutable prop instance across requests. Root Append() and Prepend() cannot be combined on the same prop. See Props and Infinite scroll.