Back to list
cameronapak

bknd-pagination

by cameronapak

A no-build, un-bloated stack built upon Web Standards that feels freeing to use and can be deployed anywhere.

23🍴 2📅 Jan 21, 2026

SKILL.md


name: bknd-pagination description: 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.

Pagination

Implement paginated data retrieval for lists, tables, and infinite scroll using Bknd's limit/offset pagination.

Prerequisites

  • Bknd project running (local or deployed)
  • Entity exists with data (use bknd-create-entity, bknd-seed-data)
  • SDK configured or API endpoint known

When to Use UI Mode

  • Browsing data in admin panel
  • Quick data exploration
  • Testing pagination manually

UI steps: Admin Panel > Data > Select Entity > Use pagination controls at bottom

When to Use Code Mode

  • Building paginated lists/tables
  • Implementing "Load More" buttons
  • Creating infinite scroll
  • Server-side pagination APIs

Pagination Basics

Bknd uses offset-based pagination with two parameters:

ParameterTypeDefaultDescription
limitnumber10Records per page
offsetnumber0Records to skip

Page Formula

// 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

Code Approach

Step 1: Basic Paginated Query

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}`);

Step 2: Handle Response Metadata

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}`);

Step 3: Create Pagination Helper

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

Step 4: Paginate with Filters

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" }
);

REST API Approach

Query Parameters

# 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"

POST for Complex Queries

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
  }'

Response Format

{
  "ok": true,
  "data": [...],
  "meta": {
    "total": 150,
    "limit": 20,
    "offset": 0
  }
}

React Integration

Basic Paginated List

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>
  );
}

Load More / Infinite Scroll

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>
  );
}

Infinite Scroll with Intersection Observer

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>
  );
}

URL-Synced Pagination

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>
  );
}

Common Patterns

Configurable Default Limit

Configure default page size in SDK:

const api = new Api({
  host: "http://localhost:7654",
  data: {
    defaultQuery: {
      limit: 25,  // Default if not specified
    },
  },
});

Server-Side Pagination (Next.js)

// 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"
      />
    </>
  );
}

Paginate with Relations

const result = await paginate("posts", page, pageSize, {
  where: { status: { $eq: "published" } },
  sort: { created_at: "desc" },
  with: {
    author: { select: ["id", "name", "avatar"] },
  },
});

Count Total Without Data

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`);

Common Pitfalls

Forgetting Pagination

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,
});

Off-by-One Page Calculation

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

Not Tracking Total

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);

Duplicate Items in Infinite Scroll

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];
});

Page Exceeds Total

Problem: Navigating to non-existent page.

Fix: Clamp page number:

const safePage = Math.min(page, totalPages);
const safeOffset = (safePage - 1) * pageSize;

Verification

  1. Check first page returns correct count:

    const { data, meta } = await api.data.readMany("posts", { limit: 10 });
    console.log(data.length, "of", meta.total);
    
  2. 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,
    });
    
  3. Test empty results:

    const { data } = await api.data.readMany("posts", {
      where: { title: { $eq: "nonexistent" } },
      limit: 10,
    });
    console.log("Empty:", data.length === 0);
    

DOs and DON'Ts

DO:

  • Always paginate large datasets
  • Use meta.total for page calculations
  • Handle edge cases (empty, last page)
  • Use SWR/React Query for caching
  • Sync pagination with URL when appropriate
  • Pre-fetch next page for smoother UX

DON'T:

  • Load all records at once
  • Use 0-indexed pages (convention is 1-indexed)
  • Forget to handle loading states
  • Ignore empty state UI
  • Skip pagination in admin/debug views (still paginate!)
  • Assume data won't change between pages
  • bknd-crud-read - Basic read operations
  • bknd-query-filter - Combine pagination with filtering
  • bknd-bulk-operations - Paginate bulk operations
  • bknd-client-setup - Configure SDK with default pagination

Score

Total Score

75/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

+10
人気

GitHub Stars 100以上

0/15
最近の活動

1ヶ月以内に更新

+10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

+5

Reviews

💬

Reviews coming soon