Server Integration Guide

REST API Integration

TableCraft is data-source agnostic. You manage fetching in your own component and pass the resulting data to ClientSideTable. The examples below show the two most common REST patterns.

Basic Pattern

Use useState to hold data, pagination metadata, and loading state. A useEffect triggers the fetch whenever the current page or page-size changes. Pass isQueryPagination to tell TableCraft that pagination is controlled externally.

tsx
"use client";

import { useState, useEffect } from "react";
import { ClientSideTable } from "@your-scope/tablecraft";
import { columns } from "./columns";

interface Meta {
  current_page: number;
  last_page: number;
  per_page: number;
  total: number;
}

export default function UsersTable() {
  const [data, setData] = useState<User[]>([]);
  const [meta, setMeta] = useState<Meta | null>(null);
  const [loading, setLoading] = useState(false);
  const [page, setPage] = useState(1);
  const [perPage, setPerPage] = useState(15);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users?page=${page}&per_page=${perPage}`)
      .then((res) => res.json())
      .then((json) => {
        setData(json.data);
        setMeta(json.meta);
      })
      .finally(() => setLoading(false));
  }, [page, perPage]);

  return (
    <ClientSideTable
      columns={columns}
      data={data}
      isLoading={loading}
      isQueryPagination
      currentPage={meta?.current_page ?? 1}
      lastPage={meta?.last_page ?? 1}
      perPage={perPage}
      total={meta?.total ?? 0}
      onPageChange={setPage}
      onPerPageChange={(value) => {
        setPerPage(value);
        setPage(1);
      }}
    />
  );
}

Full Server-Side Pattern

When search and filters also live on the server, wrap the fetch call in useCallback so it can be triggered both from pagination changes and from filter/search change handlers without duplicating logic.

tsx
"use client";

import { useState, useEffect, useCallback } from "react";
import { ClientSideTable } from "@your-scope/tablecraft";
import { columns } from "./columns";

interface Filters {
  status?: string;
  role?: string;
}

export default function UsersTable() {
  const [data, setData] = useState<User[]>([]);
  const [meta, setMeta] = useState<Meta | null>(null);
  const [loading, setLoading] = useState(false);
  const [page, setPage] = useState(1);
  const [perPage, setPerPage] = useState(15);
  const [search, setSearch] = useState("");
  const [filters, setFilters] = useState<Filters>({});

  const fetchData = useCallback(async () => {
    setLoading(true);
    try {
      const params = new URLSearchParams({
        page: String(page),
        per_page: String(perPage),
        ...(search ? { search } : {}),
        ...filters,
      });
      const res = await fetch(`/api/users?${params.toString()}`);
      const json = await res.json();
      setData(json.data);
      setMeta(json.meta);
    } finally {
      setLoading(false);
    }
  }, [page, perPage, search, filters]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return (
    <ClientSideTable
      columns={columns}
      data={data}
      isLoading={loading}
      isQueryPagination
      currentPage={meta?.current_page ?? 1}
      lastPage={meta?.last_page ?? 1}
      perPage={perPage}
      total={meta?.total ?? 0}
      onPageChange={(p) => setPage(p)}
      onPerPageChange={(value) => {
        setPerPage(value);
        setPage(1);
      }}
      onSearchChange={(value) => {
        setSearch(value);
        setPage(1);
      }}
      onFiltersChange={(value) => {
        setFilters(value as Filters);
        setPage(1);
      }}
    />
  );
}

React Query Integration

React Query handles caching, background refetching, and loading/error states for you. Pass the query result directly into ClientSideTable.

tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { ClientSideTable } from "@your-scope/tablecraft";
import { columns } from "./columns";

interface PageParams {
  page: number;
  perPage: number;
}

async function fetchUsers({ page, perPage }: PageParams) {
  const res = await fetch(`/api/users?page=${page}&per_page=${perPage}`);
  if (!res.ok) throw new Error("Failed to fetch users");
  return res.json();
}

export default function UsersTable() {
  const [page, setPage] = useState(1);
  const [perPage, setPerPage] = useState(15);

  const { data, isLoading } = useQuery({
    queryKey: ["users", { page, perPage }],
    queryFn: () => fetchUsers({ page, perPage }),
    placeholderData: (prev) => prev,
  });

  return (
    <ClientSideTable
      columns={columns}
      data={data?.data ?? []}
      isLoading={isLoading}
      isQueryPagination
      currentPage={data?.meta?.current_page ?? 1}
      lastPage={data?.meta?.last_page ?? 1}
      perPage={perPage}
      total={data?.meta?.total ?? 0}
      onPageChange={setPage}
      onPerPageChange={(value) => {
        setPerPage(value);
        setPage(1);
      }}
    />
  );
}

SWR Integration

SWR offers a lightweight alternative to React Query with an identical usage pattern for TableCraft.

tsx
"use client";

import { useState } from "react";
import useSWR from "swr";
import { ClientSideTable } from "@your-scope/tablecraft";
import { columns } from "./columns";

async function fetcher(url: string) {
  const res = await fetch(url);
  if (!res.ok) throw new Error("Network response was not ok");
  return res.json();
}

export default function UsersTable() {
  const [page, setPage] = useState(1);
  const [perPage, setPerPage] = useState(15);

  const { data, isLoading } = useSWR(
    `/api/users?page=${page}&per_page=${perPage}`,
    fetcher,
    { keepPreviousData: true }
  );

  return (
    <ClientSideTable
      columns={columns}
      data={data?.data ?? []}
      isLoading={isLoading}
      isQueryPagination
      currentPage={data?.meta?.current_page ?? 1}
      lastPage={data?.meta?.last_page ?? 1}
      perPage={perPage}
      total={data?.meta?.total ?? 0}
      onPageChange={setPage}
      onPerPageChange={(value) => {
        setPerPage(value);
        setPage(1);
      }}
    />
  );
}

Cursor-Based Pagination

For APIs that use opaque cursors instead of page numbers (GraphQL Relay connections, Stripe list endpoints, keyset pagination), use isCursorPagination instead of isQueryPagination. The pagination footer renders only Prev/Next buttons — no numbered pages — because cursor-based APIs do not support random access.

GraphQL Relay Example

tsx
"use client";

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ClientSideTable } from "@your-scope/tablecraft";
import type { CursorPaginationData } from "@your-scope/tablecraft";
import { columns } from "./columns";

const USERS_QUERY = `
  query Users($first: Int, $after: String, $before: String) {
    users(first: $first, after: $after, before: $before) {
      edges { node { id name email role } }
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
      totalCount
    }
  }
`;

export default function UsersTable() {
  const [pageSize, setPageSize] = useState(15);
  const [cursors, setCursors] = useState<{
    after?: string; before?: string
  }>({});

  const { data, isLoading } = useQuery({
    queryKey: ["users", pageSize, cursors],
    queryFn: () =>
      graphqlClient.request(USERS_QUERY, {
        first: pageSize,
        ...cursors,
      }),
    placeholderData: (prev) => prev,
  });

  const connection = data?.users;

  const cursorPaginationData: CursorPaginationData = {
    pageInfo: {
      hasNextPage:     connection?.pageInfo.hasNextPage ?? false,
      hasPreviousPage: connection?.pageInfo.hasPreviousPage ?? false,
      startCursor:     connection?.pageInfo.startCursor,
      endCursor:       connection?.pageInfo.endCursor,
      totalCount:      connection?.totalCount,
    },
    onNextPage: () =>
      setCursors({ after: connection?.pageInfo.endCursor }),
    onPreviousPage: () =>
      setCursors({ before: connection?.pageInfo.startCursor }),
    onPageSizeChange: (size) => {
      setPageSize(size);
      setCursors({});
    },
    pageSize,
  };

  return (
    <ClientSideTable
      columns={columns}
      data={connection?.edges.map(e => e.node) ?? []}
      isLoading={isLoading}
      isCursorPagination
      cursorPaginationData={cursorPaginationData}
    />
  );
}

Expected Cursor API Response Format

Cursor-based APIs typically return an edges array and a pageInfo object. TableCraft requires only the four pageInfo fields shown below. The optional totalCount is displayed as a record count label when provided.

json
{
  "edges": [
    { "node": { "id": "1", "name": "Alice Johnson" }, "cursor": "abc123" },
    { "node": { "id": "2", "name": "Bob Smith" },    "cursor": "def456" }
  ],
  "pageInfo": {
    "hasNextPage": true,
    "hasPreviousPage": false,
    "startCursor": "abc123",
    "endCursor": "def456"
  },
  "totalCount": 208
}

Expected Offset API Response Format

For offset-based pagination, TableCraft expects a Laravel-style paginated response shape. The data array contains the records for the current page. The meta object carries pagination context. The links object is optional and ignored by TableCraft but included here for completeness since many APIs return it.

json
{
  "data": [
    { "id": 1, "name": "Alice Johnson", "email": "alice@example.com", "role": "admin" },
    { "id": 2, "name": "Bob Smith",    "email": "bob@example.com",   "role": "editor" }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 14,
    "per_page": 15,
    "total": 208,
    "from": 1,
    "to": 15
  },
  "links": {
    "first": "https://api.example.com/users?page=1",
    "last":  "https://api.example.com/users?page=14",
    "prev":  null,
    "next":  "https://api.example.com/users?page=2"
  }
}
Only four fields from the meta object are required by TableCraft: meta.current_page, meta.last_page, meta.per_page, and meta.total. The remaining fields (from, to) and the entire links object are optional and can be omitted if your API does not return them.