bknd-pagination

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Pagination

分页

Implement paginated data retrieval for lists, tables, and infinite scroll using Bknd's limit/offset pagination.
使用Bknd的limit/offset分页功能,为列表、表格和无限滚动实现分页数据检索。

Prerequisites

前提条件

  • Bknd project running (local or deployed)
  • Entity exists with data (use
    bknd-create-entity
    ,
    bknd-seed-data
    )
  • SDK configured or API endpoint known
  • 运行中的Bknd项目(本地或已部署)
  • 存在包含数据的实体(使用
    bknd-create-entity
    bknd-seed-data
  • 已配置SDK或已知API端点

When to Use UI Mode

何时使用UI模式

  • Browsing data in admin panel
  • Quick data exploration
  • Testing pagination manually
UI steps: Admin Panel > Data > Select Entity > Use pagination controls at bottom
  • 在管理面板中浏览数据
  • 快速探索数据
  • 手动测试分页功能
UI操作步骤: 管理面板 > 数据 > 选择实体 > 使用底部的分页控件

When to Use Code Mode

何时使用代码模式

  • Building paginated lists/tables
  • Implementing "Load More" buttons
  • Creating infinite scroll
  • Server-side pagination APIs
  • 构建分页列表/表格
  • 实现“加载更多”按钮
  • 创建无限滚动功能
  • 服务端分页API开发

Pagination Basics

分页基础

Bknd uses offset-based pagination with two parameters:
ParameterTypeDefaultDescription
limit
number10Records per page
offset
number0Records to skip
Bknd使用基于偏移量的分页,包含两个参数:
参数类型默认值描述
limit
数字10每页记录数
offset
数字0要跳过的记录数

Page Formula

页码计算公式

typescript
// 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
typescript
// 第N页(从1开始计数),每页pageSize条记录:
{
  limit: pageSize,
  offset: (page - 1) * pageSize
}

// 示例:
// 第1页: { limit: 20, offset: 0 }   -> 记录0-19
// 第2页: { limit: 20, offset: 20 }  -> 记录20-39
// 第3页: { limit: 20, offset: 40 }  -> 记录40-59

Code Approach

代码实现方式

Step 1: Basic Paginated Query

步骤1:基础分页查询

typescript
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}`);
typescript
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}页:${data.length}条记录`);
console.log(`总数:${meta.total}`);

Step 2: Handle Response Metadata

步骤2:处理响应元数据

The
meta
object contains pagination info:
typescript
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}`);
meta
对象包含分页相关信息:
typescript
type PaginationMeta = {
  total: number;   // 匹配的总记录数
  limit: number;   // 当前页大小
  offset: number;  // 当前偏移量
};

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(`${currentPage}页,共${totalPages}`);
console.log(`是否有下一页:${hasNextPage}, 是否有上一页:${hasPrevPage}`);

Step 3: Create Pagination Helper

步骤3:创建分页辅助函数

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

// 使用示例
const result = await paginate("posts", 1, 20, {
  where: { status: { $eq: "published" } },
  sort: { created_at: "desc" },
});

console.log(result.data);         // 文章数组
console.log(result.totalPages);   // 总页数
console.log(result.hasNext);      // true/false

Step 4: Paginate with Filters

步骤4:结合筛选条件的分页

Combine pagination with where clause:
typescript
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" }
);
将分页与查询条件结合:
typescript
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,
    },
  };
}

// 带分页的搜索示例
const result = await paginatedSearch(
  "posts",
  2,        // 第2页
  10,       // 每页10条
  {
    status: { $eq: "published" },
    title: { $ilike: "%react%" },
  },
  { created_at: "desc" }
);

REST API Approach

REST API实现方式

Query Parameters

查询参数

bash
undefined
bash
undefined

Page 1, 20 per page

第1页,每页20条

Page 2

第2页

With sorting

带排序

With filters (URL-encoded JSON)

带筛选条件(URL编码的JSON)

POST for Complex Queries

复杂查询使用POST请求

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

响应格式

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

React Integration

React集成

Basic Paginated List

基础分页列表

tsx
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>
  );
}
tsx
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>加载中...</p>
      ) : (
        <ul>
          {posts.map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}

      <div className="pagination">
        <button
          disabled={page === 1}
          onClick={() => setPage(page - 1)}
        >
          上一页
        </button>
        <span>{page} 页,共 {totalPages}</span>
        <button
          disabled={page >= totalPages}
          onClick={() => setPage(page + 1)}
        >
          下一页
        </button>
      </div>
    </div>
  );
}

With SWR (Recommended)

结合SWR使用(推荐)

tsx
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>
  );
}
tsx
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>加载中...</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)}
      >
        上一页
      </button>

      {/* 页码按钮 */}
      {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)}
      >
        下一页
      </button>
    </div>
  );
}

Load More / Infinite Scroll

加载更多 / 无限滚动

tsx
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>
  );
}
tsx
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,  // 使用当前列表长度作为偏移量
    });

    setPosts((prev) => [...prev, ...data]);
    setHasMore(posts.length + data.length < meta.total);
    setLoading(false);
  }, [posts.length, loading, hasMore]);

  // 初始加载
  useEffect(() => {
    loadMore();
  }, []);

  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>

      {hasMore && (
        <button onClick={loadMore} disabled={loading}>
          {loading ? "加载中..." : "加载更多"}
        </button>
      )}

      {!hasMore && <p>没有更多文章了</p>}
    </div>
  );
}

Infinite Scroll with Intersection Observer

结合Intersection Observer的无限滚动

tsx
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>
  );
}
tsx
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实现自动加载
  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]);

  // 初始加载
  useEffect(() => {
    loadMore();
  }, []);

  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>

      <div ref={loaderRef} style={{ height: 20 }}>
        {loading && <p>加载中...</p>}
        {!hasMore && <p>已加载全部内容</p>}
      </div>
    </div>
  );
}

URL-Synced Pagination

与URL同步的分页

tsx
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>
  );
}
tsx
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>
      {/* ... 渲染文章列表 ... */}
      <Pagination
        page={page}
        totalPages={totalPages}
        onPageChange={setPage}
      />
    </div>
  );
}

Common Patterns

常见模式

Configurable Default Limit

配置默认每页记录数

Configure default page size in SDK:
typescript
const api = new Api({
  host: "http://localhost:7654",
  data: {
    defaultQuery: {
      limit: 25,  // Default if not specified
    },
  },
});
在SDK中配置默认的每页大小:
typescript
const api = new Api({
  host: "http://localhost:7654",
  data: {
    defaultQuery: {
      limit: 25,  // 未指定时的默认值
    },
  },
});

Server-Side Pagination (Next.js)

服务端分页(Next.js)

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

关联数据的分页

typescript
const result = await paginate("posts", page, pageSize, {
  where: { status: { $eq: "published" } },
  sort: { created_at: "desc" },
  with: {
    author: { select: ["id", "name", "avatar"] },
  },
});
typescript
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):
typescript
const { data } = await api.data.count("posts", {
  status: { $eq: "published" },
});
console.log(`${data.count} total posts`);
如果只需要总数(例如用于显示总数量):
typescript
const { data } = await api.data.count("posts", {
  status: { $eq: "published" },
});
console.log(`总共有${data.count}篇已发布文章`);

Common Pitfalls

常见陷阱

Forgetting Pagination

忘记使用分页

Problem: Loading all records causes performance issues.
Fix: Always paginate large datasets:
typescript
// Wrong - loads everything
const { data } = await api.data.readMany("posts");

// Correct - paginate
const { data } = await api.data.readMany("posts", {
  limit: 20,
  offset: 0,
});
问题: 加载全部记录会导致性能问题。
解决方法: 对于大型数据集,始终使用分页:
typescript
// 错误示例 - 加载全部记录
const { data } = await api.data.readMany("posts");

// 正确示例 - 使用分页
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:
typescript
// Wrong (if page is 1-indexed)
offset: page * pageSize  // Skips first page!

// Correct
offset: (page - 1) * pageSize
问题: 使用从0开始计数的页码。
解决方法: 使用从1开始计数的页码,并使用正确的偏移量公式:
typescript
// 错误示例(如果页码从1开始计数)
offset: page * pageSize  // 会跳过第一页的数据!

// 正确示例
offset: (page - 1) * pageSize

Not Tracking Total

未跟踪总记录数

Problem: Can't show "Page X of Y" without total.
Fix: Always use
meta.total
from response:
typescript
const { data, meta } = await api.data.readMany("posts", query);
const totalPages = Math.ceil(meta.total / pageSize);
问题: 无法显示“第X页,共Y页”的信息。
解决方法: 始终使用响应中的
meta.total
字段:
typescript
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:
typescript
setPosts((prev) => {
  const ids = new Set(prev.map(p => p.id));
  const newPosts = data.filter(p => !ids.has(p.id));
  return [...prev, ...newPosts];
});
问题: 数据更新时,相同的条目多次出现。
解决方法: 使用唯一键并去重:
typescript
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:
typescript
const safePage = Math.min(page, totalPages);
const safeOffset = (safePage - 1) * pageSize;
问题: 导航到超出总页数的页码。
解决方法: 限制页码的范围:
typescript
const safePage = Math.min(page, totalPages);
const safeOffset = (safePage - 1) * pageSize;

Verification

验证方法

  1. Check first page returns correct count:
    typescript
    const { data, meta } = await api.data.readMany("posts", { limit: 10 });
    console.log(data.length, "of", meta.total);
  2. Verify last page doesn't error:
    typescript
    const lastPage = Math.ceil(meta.total / pageSize);
    const { data } = await api.data.readMany("posts", {
      limit: pageSize,
      offset: (lastPage - 1) * pageSize,
    });
  3. Test empty results:
    typescript
    const { data } = await api.data.readMany("posts", {
      where: { title: { $eq: "nonexistent" } },
      limit: 10,
    });
    console.log("Empty:", data.length === 0);
  1. 检查第一页返回的记录数是否正确:
    typescript
    const { data, meta } = await api.data.readMany("posts", { limit: 10 });
    console.log(data.length, "条,总共有", meta.total, "条");
  2. 验证最后一页不会报错:
    typescript
    const lastPage = Math.ceil(meta.total / pageSize);
    const { data } = await api.data.readMany("posts", {
      limit: pageSize,
      offset: (lastPage - 1) * pageSize,
    });
  3. 测试空结果的情况:
    typescript
    const { data } = await api.data.readMany("posts", {
      where: { title: { $eq: "不存在的标题" } },
      limit: 10,
    });
    console.log("是否为空:", 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
建议:
  • 对于大型数据集,始终使用分页
  • 使用
    meta.total
    进行页码计算
  • 处理边界情况(空数据、最后一页)
  • 使用SWR/React Query进行缓存
  • 适当情况下将分页状态与URL同步
  • 预加载下一页以提升用户体验
禁止:
  • 一次性加载全部记录
  • 使用从0开始计数的页码(常规约定是从1开始)
  • 忽略加载状态的处理
  • 忽略空状态的UI展示
  • 在管理/调试视图中跳过分页(仍需使用分页!)
  • 假设页面之间数据不会发生变化

Related Skills

相关技能

  • 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
  • bknd-crud-read - 基础读取操作
  • bknd-query-filter - 结合分页与筛选
  • bknd-bulk-operations - 批量操作的分页
  • bknd-client-setup - 配置带有默认分页的SDK