Skip to content

Pagination

Slingshot ships first-class pagination helpers in two flavors: offset-based for small, stable lists where users want to jump to a page number, and cursor-based for large, churning lists where you need consistent results across writes. Both styles return Zod schemas you compose into your route definitions, so the OpenAPI spec stays accurate.

The helpers split into three pieces per strategy: a query-param schema (offsetParams(), cursorParams()) you put in your request schema, a parser (parseOffsetParams(), parseCursorParams()) you call inside the handler to clamp values, and a response wrapper (paginatedResponse(), cursorResponse()) that registers the response shape with the OpenAPI registry under a name of your choice.

Cursors are opaque strings. When security.signing.cursors is enabled the helpers HMAC-sign cursors before returning them and verify on the way back in — clients cannot fabricate or tamper with them.

Best for stable lists with a known size where users browse by page:

src/catalog/package.ts
import { z } from 'zod';
import {
definePackage,
domain,
offsetParams,
paginatedResponse,
parseOffsetParams,
route,
} from '@lastshotlabs/slingshot';
const Product = z.object({ id: z.string(), name: z.string() });
export const catalogPackage = definePackage({
name: 'catalog',
domains: [
domain({
name: 'products',
basePath: '/products',
routes: [
route.get({
path: '/',
request: { query: offsetParams() },
responses: {
200: {
description: 'Page of products',
schema: paginatedResponse(Product, 'ProductPage'),
},
},
handler: async ({ query, respond }) => {
const { limit, offset } = parseOffsetParams(query);
const items = await listProducts({ skip: offset, take: limit });
return respond.json({ items, total: items.length, limit, offset });
},
}),
],
}),
],
});
declare function listProducts(opts: {
skip: number;
take: number;
}): Promise<{ id: string; name: string }[]>;

Best for large lists, infinite scroll, or anywhere new rows are appended frequently:

src/feed/package.ts
import { z } from 'zod';
import {
cursorParams,
cursorResponse,
definePackage,
domain,
parseCursorParams,
route,
} from '@lastshotlabs/slingshot';
const Post = z.object({ id: z.string(), title: z.string(), createdAt: z.string() });
export const feedPackage = definePackage({
name: 'feed',
domains: [
domain({
name: 'posts',
basePath: '/posts',
routes: [
route.get({
path: '/',
request: { query: cursorParams({ limit: 25, maxLimit: 100 }) },
responses: {
200: { description: 'Page of posts', schema: cursorResponse(Post, 'PostPage') },
},
handler: async ({ query, respond }) => {
const { limit, cursor } = parseCursorParams(query);
const page = await loadPosts({ limit, cursor });
return respond.json({
items: page.items,
nextCursor: page.nextCursor,
hasMore: page.hasMore,
});
},
}),
],
}),
],
});
declare function loadPosts(opts: { limit: number; cursor?: string }): Promise<{
items: { id: string; title: string; createdAt: string }[];
nextCursor: string | null;
hasMore: boolean;
}>;

The cursor opaque to clients — encode whatever the adapter needs (a row ID, a timestamp, a composite key). The encodeCursor and decodeCursor helpers from @lastshotlabs/slingshot-entity handle the base64url plumbing if you are encoding by hand.

Set security.signing.cursors: true and security.signing.secret and cursors are HMAC’d on the way out and verified on the way in. parseCursorParams returns invalidCursor: true when verification fails — handle it as a 400, not as “first page”, so a tampered cursor never silently restarts the iteration.