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.
How it works
Section titled “How it works”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.
Offset pagination
Section titled “Offset pagination”Best for stable lists with a known size where users browse by page:
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 }[]>;Cursor pagination
Section titled “Cursor pagination”Best for large lists, infinite scroll, or anywhere new rows are appended frequently:
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.
Signed cursors
Section titled “Signed cursors”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.
What’s next
Section titled “What’s next”- defineEntity — generated routes already use cursor pagination
- OpenAPI — how
cursorResponseregisters in the spec - Routes and Handlers — full route schema surface