Pagination
Pagination
@websideproject/nuxt-auto-api supports offset (page) pagination and cursor (keyset) pagination. Every list is
capped: ?limit defaults to pagination.defaultLimit (20) and can never exceed pagination.maxLimit (100).
Offset pagination
GET /api/users?page=2&limit=20&sort=-createdAt
{
"data": [...],
"meta": { "page": 2, "limit": 20, "total": 156 }
}
total counts exactly the rows the caller may see (tenant, listFilter, soft delete, filters). When the
resource has an objectLevel check the SQL count cannot know which rows the function drops, so total is
omitted rather than wrong, and hasMore says whether another page follows:
{
"data": [...],
"meta": { "page": 2, "limit": 20, "hasMore": true }
}
Pages still hold exactly the rows the caller may see, each row on one page. Under objectLevel, page N checks
every row before it, so deep pages cost more; use cursor pagination for long lists of such a resource.
Cursor pagination
Stable for infinite scroll and large tables: rows inserted or deleted between requests never shift a page.
GET /api/users?limit=20&sort=-createdAt&cursor= ← first page: an EMPTY cursor
GET /api/users?limit=20&sort=-createdAt&cursor=<nextCursor>
{
"data": [...],
"meta": { "limit": 20, "hasMore": true, "nextCursor": "WzE3MDAwMDAwMDAwMCwxNDNd" }
}
Continue until hasMore is false. Send cursor= (empty) on the first request — without a cursor
parameter the list is paginated by offset and has no nextCursor.
How it works
The page order is your sort plus the primary key as a tiebreaker, so the order is total and a page
boundary is never ambiguous — even when many rows share a createdAt. The cursor holds the last row's values
for those columns, and the next page is every row strictly after it:
sort=-createdAt → ORDER BY created_at DESC, id DESC
next page → WHERE created_at < ? OR (created_at = ? AND id < ?)
- Cursors follow the sort — keep the same
sort(andfilter) while paging. - Columns used for sorting with cursors must be NOT NULL; a cursor carrying a NULL is a 400.
- A tampered or malformed cursor is a 400.
Filtering
?filter= is a JSON object; its conditions are ANDed:
GET /api/posts?filter={"status":"published","views":{"$gte":10,"$lte":100}}
| Form | Meaning |
|---|---|
{ "field": value } | equals (null → IS NULL, an array → IN) |
$eq $ne $gt $gte $lt $lte | comparisons |
$like | contains (LIKE '%value%') |
$in $nin | in / not in a list (an array or a comma list, at most 500 values; 50 on D1) |
$null | true → IS NULL, false → IS NOT NULL |
{ "$or": [filter, …] } | any of several filters — top level only, at most 20 |
GET /api/posts?filter={"published":true,"$or":[{"title":{"$like":"nuxt"}},{"body":{"$like":"nuxt"}}]}
On a timestamp column the values are dates: an ISO string or epoch milliseconds ({"createdAt":{"$gte":"2026-01-01"}}).
Only columns the caller may read can be filtered: an unknown, hidden or unreadable field, an unknown operator or a value that is not a date where one is needed is a 400 — a condition is never silently dropped, because that would widen the result.
Sorting
GET /api/posts?sort=-publishedAt,title
A comma list (or repeated sort), - for descending. Only columns the caller may read can be used; an
unknown or hidden column is a 400.
Configuration
// nuxt.config.ts
export default defineNuxtConfig({
autoApi: {
pagination: {
defaultLimit: 20,
maxLimit: 100, // also caps ?include pagination and M2M lists
},
},
})
Frontend usage
Offset:
<script setup lang="ts">
const page = ref(1)
const { data } = useAutoApiList('users', computed(() => ({ page: page.value, limit: 20, sort: '-createdAt' })))
</script>
Cursor (infinite scroll) — useAutoApiInfinite sends the empty first cursor for you:
<script setup lang="ts">
const { data, fetchNextPage, hasNextPage } = useAutoApiInfinite('users', { limit: 20, sort: '-createdAt' })
</script>
Best practices
- Offset for UIs with page numbers; cursor for infinite scroll, feeds and large tables.
- Index the columns you sort by, together with the primary key (e.g.
(created_at, id)). - Don't raise
maxLimitto fetch "everything" — page through it.