Skip to main content
node

The API Response Shape That Makes Tables Easier

Paginated table UIs expose every shortcut taken in API design. Here is the response shape that removes friction and exactly why it works.


5 min read

Data tables are where API design mistakes surface most visibly. If the paginated response does not include a total count, the frontend cannot render a page count. If it does not echo back the active filters, the frontend has to reconstruct state from the URL or hold it in memory. If there is no stable sort indicator, the table does not know which column header to highlight. These are not frontend problems — they are API contract problems.


The Response Shape That Works

Here is the TypeScript type for a paginated response designed to feed a table without requiring the frontend to make additional requests or maintain derived state:

type PaginatedResponse<T> = {
  data: T[];

  // Pagination metadata
  pagination: {
    total: number;       // total matching records (for page count)
    page: number;        // current page (1-indexed)
    pageSize: number;    // items per page
    hasNextPage: boolean;
    hasPrevPage: boolean;
  };

  // Echo back the active query state
  filters: Record<string, string | string[] | null>;
  sort: {
    field: string;
    direction: "asc" | "desc";
  } | null;
};

A response that includes pagination.total lets the frontend calculate Math.ceil(total / pageSize) for the page count without a separate COUNT(*) request. A response that includes filters means the table can initialize its filter UI from the response itself — useful when users land on a filtered URL they did not construct themselves. A response that includes sort means the table knows which column is active without parsing URL params redundantly.

What Happens Without the Metadata

When the API omits total, the frontend has two bad options: (1) request the same data twice — once for records, once for the count — or (2) implement infinite scroll and give up on page numbers entirely. The second is not always wrong, but it should be a product decision, not a forced consequence of an incomplete API.

When the API omits filter echo, the frontend has to maintain its own filter state separately from the server response. This creates a sync problem: the URL params, the local state, and the server response all need to agree. Any mismatch produces a table that shows filtered results but has the wrong filter chips highlighted, or vice versa.

// Bad: frontend reconstructing what the API should have told it
function useTableState(response: { data: Project[] }) {
  const [sortField, setSortField] = useState("createdAt"); // guessing
  const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); // guessing
  const searchParams = useSearchParams();
  const activeFilter = searchParams.get("status"); // parsing URL again

  // Now you have three sources of truth that can disagree
}
// Good: response is the source of truth
function useTableState(response: PaginatedResponse<Project>) {
  // Everything needed to render the table is in the response
  const { data, pagination, filters, sort } = response;
  // No additional state needed for current filter/sort/page
}

A Concrete Node.js Handler

// GET /api/projects?page=2&pageSize=25&status=active&sort=updatedAt&dir=desc

import type { Request, Response } from "express";
import { db } from "../db";

export async function listProjects(req: Request, res: Response) {
  const page = Math.max(1, Number(req.query.page) || 1);
  const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize) || 25));
  const status = req.query.status as string | undefined;
  const sortField = (req.query.sort as string) || "createdAt";
  const sortDir = req.query.dir === "asc" ? "asc" : "desc";

  const where = status ? { status } : {};

  const [total, projects] = await Promise.all([
    db.project.count({ where }),
    db.project.findMany({
      where,
      orderBy: { [sortField]: sortDir },
      skip: (page - 1) * pageSize,
      take: pageSize,
    }),
  ]);

  res.json({
    data: projects,
    pagination: {
      total,
      page,
      pageSize,
      hasNextPage: page * pageSize < total,
      hasPrevPage: page > 1,
    },
    filters: { status: status ?? null },
    sort: { field: sortField, direction: sortDir },
  });
}

The Promise.all runs the count and the data fetch in parallel — one round trip to the database instead of two sequential queries. The response echoes back every query parameter the frontend sent so the table can verify its own state against the server's interpretation.

The Case for Cursor-Based Pagination

For very large datasets, offset pagination (SKIP n TAKE m) has a performance cliff. At high offsets, the database still scans all skipped rows before returning the window. Cursor-based pagination avoids this:

type CursorPaginatedResponse<T> = {
  data: T[];
  nextCursor: string | null;
  prevCursor: string | null;
  // total is usually not available — this is the tradeoff
};

The tradeoff is real: cursor pagination cannot tell you the total count without a separate query, so page numbers become impossible. Cursor pagination fits feeds and infinite scroll. Offset pagination fits tables where users jump to specific pages.

Choose based on the UI the product needs, and design the API response shape for that UI — not for the easiest implementation on the server side.