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:

  1. Validates offset: 40 and limit: 20.
  2. Calls count() and gets 95.
  3. Calls fetch() with { limit: 20, offset: 40 }.
  4. 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>.

InputTypeRequired?Meaning
adapterPaginationAdapter<TQuery, TCountQuery, TItem>YesCounts matching rows and fetches the requested slice.
queryTQueryYesSent to adapter.fetch().
countQueryTCountQueryYesSent to adapter.count().
offsetPaginationInputNoNumber of matches to skip. Default 0.
limitPaginationInputNoMaximum rows requested. Default 20, normally capped at 100.
pageSizeOptionsPageSizeOptionsNoCustom 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.

OutputTypeMeaning here
itemsTItem[]Returned users (up to 20).
pagination.totalnumber95: all matching users.
pagination.limitnumber20: requested maximum.
pagination.offsetnumber40: number skipped.
pagination.hasNextPagebooleantrue: matching items exist after the requested batch.
pagination.hasPreviousPagebooleantrue: 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                 // 60

Use 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.

On this page