.agents/skills/bknd-pagination/SKILL.md
Use when implementing paginated data retrieval in Bknd. Covers limit/offset pagination, page calculation, pagination metadata (total, hasNext, hasPrev), pagination helper functions, infinite scroll, and React integration patterns.
npx skillsauth add cameronapak/freedom-stack-v3 bknd-paginationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Implement paginated data retrieval for lists, tables, and infinite scroll using Bknd's limit/offset pagination.
bknd-create-entity, bknd-seed-data)UI steps: Admin Panel > Data > Select Entity > Use pagination controls at bottom
Bknd uses offset-based pagination with two parameters:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| limit | number | 10 | Records per page |
| offset | number | 0 | Records to skip |
// Page N (1-indexed) with pageSize records:
{
limit: pageSize,
offset: (page - 1) * pageSize
}
// Examples:
// Page 1: { limit: 20, offset: 0 } -> records 0-19
// Page 2: { limit: 20, offset: 20 } -> records 20-39
// Page 3: { limit: 20, offset: 40 } -> records 40-59
import { Api } from "bknd";
const api = new Api({ host: "http://localhost:7654" });
const page = 1;
const pageSize = 20;
const { ok, data, meta } = await api.data.readMany("posts", {
where: { status: { $eq: "published" } },
sort: { created_at: "desc" },
limit: pageSize,
offset: (page - 1) * pageSize,
});
console.log(`Page ${page}: ${data.length} records`);
console.log(`Total: ${meta.total}`);
The meta object contains pagination info:
type PaginationMeta = {
total: number; // Total matching records
limit: number; // Current page size
offset: number; // Current offset
};
const { data, meta } = await api.data.readMany("posts", {
limit: 20,
offset: 0,
});
const totalPages = Math.ceil(meta.total / meta.limit);
const currentPage = Math.floor(meta.offset / meta.limit) + 1;
const hasNextPage = meta.offset + meta.limit < meta.total;
const hasPrevPage = meta.offset > 0;
console.log(`Page ${currentPage} of ${totalPages}`);
console.log(`Has next: ${hasNextPage}, Has prev: ${hasPrevPage}`);
type PaginationResult<T> = {
data: T[];
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
async function paginate<T>(
entity: string,
page: number,
pageSize: number,
query: object = {}
): Promise<PaginationResult<T>> {
const { data, meta } = await api.data.readMany(entity, {
...query,
limit: pageSize,
offset: (page - 1) * pageSize,
});
return {
data: data as T[],
page,
pageSize,
total: meta.total,
totalPages: Math.ceil(meta.total / pageSize),
hasNext: page * pageSize < meta.total,
hasPrev: page > 1,
};
}
// Usage
const result = await paginate("posts", 1, 20, {
where: { status: { $eq: "published" } },
sort: { created_at: "desc" },
});
console.log(result.data); // Posts array
console.log(result.totalPages); // Total pages
console.log(result.hasNext); // true/false
Combine pagination with where clause:
async function paginatedSearch(
entity: string,
page: number,
pageSize: number,
filters: object,
sort: object = {}
) {
const { data, meta } = await api.data.readMany(entity, {
where: filters,
sort,
limit: pageSize,
offset: (page - 1) * pageSize,
});
return {
data,
pagination: {
page,
pageSize,
total: meta.total,
totalPages: Math.ceil(meta.total / pageSize),
hasNext: page * pageSize < meta.total,
hasPrev: page > 1,
},
};
}
// Search with pagination
const result = await paginatedSearch(
"posts",
2, // page 2
10, // 10 per page
{
status: { $eq: "published" },
title: { $ilike: "%react%" },
},
{ created_at: "desc" }
);
# Page 1, 20 per page
curl "http://localhost:7654/api/data/posts?limit=20&offset=0"
# Page 2
curl "http://localhost:7654/api/data/posts?limit=20&offset=20"
# With sorting
curl "http://localhost:7654/api/data/posts?limit=20&offset=0&sort=-created_at"
# With filters (URL-encoded JSON)
curl "http://localhost:7654/api/data/posts?limit=20&offset=0&where=%7B%22status%22%3A%22published%22%7D"
curl -X POST http://localhost:7654/api/data/posts/query \
-H "Content-Type: application/json" \
-d '{
"where": {"status": {"$eq": "published"}},
"sort": {"created_at": "desc"},
"limit": 20,
"offset": 0
}'
{
"ok": true,
"data": [...],
"meta": {
"total": 150,
"limit": 20,
"offset": 0
}
}
import { useApp } from "bknd/react";
import { useState, useEffect } from "react";
function PaginatedPosts() {
const { api } = useApp();
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [loading, setLoading] = useState(true);
const pageSize = 10;
useEffect(() => {
setLoading(true);
api.data.readMany("posts", {
sort: { created_at: "desc" },
limit: pageSize,
offset: (page - 1) * pageSize,
}).then(({ data, meta }) => {
setPosts(data);
setTotalPages(Math.ceil(meta.total / pageSize));
setLoading(false);
});
}, [page]);
return (
<div>
{loading ? (
<p>Loading...</p>
) : (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
<div className="pagination">
<button
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Next
</button>
</div>
</div>
);
}
import { useApp } from "bknd/react";
import { useState } from "react";
import useSWR from "swr";
function PaginatedPosts() {
const { api } = useApp();
const [page, setPage] = useState(1);
const pageSize = 10;
const { data: result, isLoading } = useSWR(
["posts", page, pageSize],
() => api.data.readMany("posts", {
sort: { created_at: "desc" },
limit: pageSize,
offset: (page - 1) * pageSize,
})
);
const posts = result?.data ?? [];
const total = result?.meta?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
return (
<div>
{isLoading ? <p>Loading...</p> : (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
);
}
function Pagination({ page, totalPages, onPageChange }) {
return (
<div className="flex gap-2">
<button
disabled={page === 1}
onClick={() => onPageChange(page - 1)}
>
Prev
</button>
{/* Page numbers */}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => Math.abs(p - page) <= 2 || p === 1 || p === totalPages)
.map((p, i, arr) => (
<>
{i > 0 && arr[i - 1] !== p - 1 && <span>...</span>}
<button
key={p}
onClick={() => onPageChange(p)}
className={p === page ? "active" : ""}
>
{p}
</button>
</>
))}
<button
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Next
</button>
</div>
);
}
import { useApp } from "bknd/react";
import { useState, useCallback } from "react";
function InfinitePostsList() {
const { api } = useApp();
const [posts, setPosts] = useState([]);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const pageSize = 20;
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const { data, meta } = await api.data.readMany("posts", {
sort: { created_at: "desc" },
limit: pageSize,
offset: posts.length, // Use current length as offset
});
setPosts((prev) => [...prev, ...data]);
setHasMore(posts.length + data.length < meta.total);
setLoading(false);
}, [posts.length, loading, hasMore]);
// Initial load
useEffect(() => {
loadMore();
}, []);
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
{hasMore && (
<button onClick={loadMore} disabled={loading}>
{loading ? "Loading..." : "Load More"}
</button>
)}
{!hasMore && <p>No more posts</p>}
</div>
);
}
import { useApp } from "bknd/react";
import { useState, useEffect, useRef, useCallback } from "react";
function InfiniteScrollPosts() {
const { api } = useApp();
const [posts, setPosts] = useState([]);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const loaderRef = useRef(null);
const pageSize = 20;
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const { data, meta } = await api.data.readMany("posts", {
sort: { created_at: "desc" },
limit: pageSize,
offset: posts.length,
});
setPosts((prev) => [...prev, ...data]);
setHasMore(posts.length + data.length < meta.total);
setLoading(false);
}, [posts.length, loading, hasMore]);
// Intersection Observer for auto-load
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.1 }
);
if (loaderRef.current) {
observer.observe(loaderRef.current);
}
return () => observer.disconnect();
}, [loadMore]);
// Initial load
useEffect(() => {
loadMore();
}, []);
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<div ref={loaderRef} style={{ height: 20 }}>
{loading && <p>Loading...</p>}
{!hasMore && <p>End of list</p>}
</div>
</div>
);
}
import { useApp } from "bknd/react";
import { useSearchParams } from "react-router-dom";
import useSWR from "swr";
function URLPaginatedPosts() {
const { api } = useApp();
const [searchParams, setSearchParams] = useSearchParams();
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = 20;
const { data: result, isLoading } = useSWR(
["posts", page],
() => api.data.readMany("posts", {
limit: pageSize,
offset: (page - 1) * pageSize,
})
);
const setPage = (newPage: number) => {
setSearchParams({ page: String(newPage) });
};
const totalPages = result
? Math.ceil(result.meta.total / pageSize)
: 0;
return (
<div>
{/* ... render posts ... */}
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
);
}
Configure default page size in SDK:
const api = new Api({
host: "http://localhost:7654",
data: {
defaultQuery: {
limit: 25, // Default if not specified
},
},
});
// app/posts/page.tsx
export default async function PostsPage({
searchParams,
}: {
searchParams: { page?: string };
}) {
const page = parseInt(searchParams.page || "1", 10);
const pageSize = 20;
const response = await fetch(
`${process.env.BKND_URL}/api/data/posts?` +
`limit=${pageSize}&offset=${(page - 1) * pageSize}&sort=-created_at`
);
const { data, meta } = await response.json();
const totalPages = Math.ceil(meta.total / pageSize);
return (
<>
<PostsList posts={data} />
<PaginationLinks
page={page}
totalPages={totalPages}
basePath="/posts"
/>
</>
);
}
const result = await paginate("posts", page, pageSize, {
where: { status: { $eq: "published" } },
sort: { created_at: "desc" },
with: {
author: { select: ["id", "name", "avatar"] },
},
});
If you only need count (e.g., for showing total):
const { data } = await api.data.count("posts", {
status: { $eq: "published" },
});
console.log(`${data.count} total posts`);
Problem: Loading all records causes performance issues.
Fix: Always paginate large datasets:
// Wrong - loads everything
const { data } = await api.data.readMany("posts");
// Correct - paginate
const { data } = await api.data.readMany("posts", {
limit: 20,
offset: 0,
});
Problem: Using 0-indexed pages.
Fix: Use 1-indexed pages with correct offset formula:
// Wrong (if page is 1-indexed)
offset: page * pageSize // Skips first page!
// Correct
offset: (page - 1) * pageSize
Problem: Can't show "Page X of Y" without total.
Fix: Always use meta.total from response:
const { data, meta } = await api.data.readMany("posts", query);
const totalPages = Math.ceil(meta.total / pageSize);
Problem: Same items appear multiple times when data changes.
Fix: Use unique keys and deduplicate:
setPosts((prev) => {
const ids = new Set(prev.map(p => p.id));
const newPosts = data.filter(p => !ids.has(p.id));
return [...prev, ...newPosts];
});
Problem: Navigating to non-existent page.
Fix: Clamp page number:
const safePage = Math.min(page, totalPages);
const safeOffset = (safePage - 1) * pageSize;
Check first page returns correct count:
const { data, meta } = await api.data.readMany("posts", { limit: 10 });
console.log(data.length, "of", meta.total);
Verify last page doesn't error:
const lastPage = Math.ceil(meta.total / pageSize);
const { data } = await api.data.readMany("posts", {
limit: pageSize,
offset: (lastPage - 1) * pageSize,
});
Test empty results:
const { data } = await api.data.readMany("posts", {
where: { title: { $eq: "nonexistent" } },
limit: 10,
});
console.log("Empty:", data.length === 0);
DO:
meta.total for page calculationsDON'T:
development
Use btca (Better Context App) to efficiently query and learn from the bknd backend framework. Use when working with bknd for (1) Understanding data module and schema definitions, (2) Implementing authentication and authorization, (3) Setting up media file handling, (4) Configuring adapters (Node, Cloudflare, etc.), (5) Learning from bknd source code and examples, (6) Debugging bknd-specific issues
development
Use when configuring webhook integrations in Bknd. Covers receiving incoming webhooks via HTTP triggers, sending outgoing webhooks with FetchTask, event-triggered webhooks on data changes, signature verification, retry patterns, and async processing.
development
Use when encountering Bknd errors, getting error messages, something not working, or needing quick fixes. Covers error code reference, quick solutions, and common mistake patterns.
tools
Use when writing tests for Bknd applications, setting up test infrastructure, creating unit/integration tests, or testing API endpoints. Covers in-memory database setup, test helpers, mocking, and test patterns.