Offset Pagination
Skip a chosen number of records and get the next batch with a total count.
Use paginateOffset() when your API accepts offset and limit instead of a page number.
offset= the number of matching items to skip (starting from zero).limit= the maximum number of matching items to return.
For example, offset: 7 with limit: 3 skips the first seven matching items and asks for items at positions 8, 9, and 10. Unlike numbered pagination, the offset doesn't need to be a multiple of the limit.
Use it directly with Drizzle
import { asc } from "drizzle-orm"
import { paginateOffset } from "@evensurface/paginate-drizzle"
import { db } from "./db"
import { users } from "./schema"
const result = await paginateOffset({
query: () => db.select().from(users).orderBy(asc(users.id)),
countQuery: db.$count(users),
offset: 40,
limit: 20,
})This asks Drizzle to count users, then skip 40 and return up to 20 more. The Drizzle integration takes care of the count() and fetch() methods internally. No custom adapter is needed. See the Quick Start to learn how the select factory and count query work.
Framework-independent core example
If you use another ORM or a custom data source, the core package takes an adapter with count() and fetch() methods:
import { paginateOffset } from "@evensurface/paginate"
const result = await paginateOffset({
adapter: userAdapter,
query: { active: true },
countQuery: { active: true },
offset: 40,
limit: 20,
})userAdapter is an object with count() and fetch() methods; see the custom adapter guide. Here query tells the adapter which users to return, and countQuery makes sure the total is based on the same filter.
If the adapter counts 95 matching users, the call:
- Validates
offset: 40andlimit: 20. - Calls
count()and gets95. - Calls
fetch()with{ limit: 20, offset: 40 }. - Returns up to 20 matching users (positions 41–60) with the following metadata.
Core API inputs
The table below describes the core paginateOffset() API used with custom adapters. With Drizzle, use DrizzleOffsetPaginationOptions, which has query, countQuery, offset, limit, and optional pageSizeOptions—but no adapter field.
All inputs are defined in OffsetPaginationOptions<TQuery, TCountQuery, TItem>.
| Input | Type | Required? | Meaning |
|---|---|---|---|
adapter | PaginationAdapter<TQuery, TCountQuery, TItem> | Yes | Counts matching rows and fetches the requested slice. |
query | TQuery | Yes | Sent to adapter.fetch(). |
countQuery | TCountQuery | Yes | Sent to adapter.count(). |
offset | PaginationInput | No | Number of matches to skip. Default 0. |
limit | PaginationInput | No | Maximum rows requested. Default 20, normally capped at 100. |
pageSizeOptions | PageSizeOptions | No | Custom limit defaults and boundaries. |
offset may be 40 or "40". Negative, fractional, missing, or otherwise invalid offsets become 0. The limit follows the same normalization as page mode.
Outputs
The return type is Promise<OffsetPaginatedResult<TItem>>. With 95 matching users:
{
items: [/* up to 20 User objects */],
pagination: {
total: 95,
limit: 20,
offset: 40,
hasNextPage: true,
hasPreviousPage: true,
},
}Its metadata type is OffsetPaginationMetadata.
| Output | Type | Meaning here |
|---|---|---|
items | TItem[] | Returned users (up to 20). |
pagination.total | number | 95: all matching users. |
pagination.limit | number | 20: requested maximum. |
pagination.offset | number | 40: number skipped. |
pagination.hasNextPage | boolean | true: matching items exist after the requested batch. |
pagination.hasPreviousPage | boolean | true: offset is greater than zero. |
There is no page or totalPages field in this mode. Those fields belong to numbered pagination.
Move forward or backward
You can calculate offsets for navigation using the returned metadata:
const { offset, limit } = result.pagination
const previousOffset = Math.max(0, offset - limit) // 20
const nextOffset = offset + limit // 60Use hasPreviousPage and hasNextPage to decide whether to show the corresponding buttons. nextOffset is just a number you calculate for the next request; paginateOffset() does not automatically issue that request.
If the offset is past the end
Suppose there are 95 matching users, but the request is:
{ offset: 200, limit: 20 }Paginate performs the count, sees the offset is beyond the total, and doesn't call fetch():
{
items: [],
pagination: {
total: 95,
limit: 20,
offset: 200,
hasNextPage: false,
hasPreviousPage: true,
},
}It keeps offset: 200 exactly as requested instead of moving it back to the last possible offset. hasPreviousPage simply means the offset is greater than zero; it does not guarantee the previous offset contains results.
If total is 0, the array is empty and the fetch is skipped too.
Return the fields in your own API shape
You can keep the standard result:
return Response.json(result)Or flatten the metadata when your API already uses top-level total, limit, and offset fields:
return Response.json({
users: result.items,
...result.pagination,
})Both options send the same information. The second only changes how your API response is formatted.