Data Fetching
Data fetching is powered by TanStack React Query using useInfiniteQuery for cursor-based pagination. A factory function creates the query options so you don't have to wire up cursors, serialization, or caching manually. See the /infinite demo for a working example.
Query Options Factory
createDataTableQueryOptions generates infiniteQueryOptions for your table. It handles cursor management, search param serialization, and SuperJSON deserialization:
import { createDataTableQueryOptions } from "@/lib/data-table";
const _dataOptions = createDataTableQueryOptions<ColumnSchema[], MyMeta>({
queryKeyPrefix: "my-table",
apiEndpoint: "/my-table/api",
searchParamsSerializer: searchParamsSerializer,
});
export const dataOptions = (search: SearchParamsType) =>
_dataOptions(search as unknown as Record<string, unknown>);The factory configures:
- Query key — derived from serialized search params (excludes
cursor,direction,uuid,livefor stable cache keys) - Initial cursor —
Date.now()(most recent data first) - Page params —
getNextPageParam/getPreviousPageParamfromnextCursor/prevCursor - Caching —
keepPreviousDatafor smooth filter transitions, 5-minute stale time, no refetch on window focus
Skipping Metadata on Pagination
meta (chart data, facets, your own metadata) is computed over the whole filtered set, so it is identical on every page — yet a naive setup recomputes and re-sends it on every infinite-scroll fetch.
Set skipMetaOnPagination to append _meta=false to pagination requests so your route can skip that work:
const _dataOptions = createDataTableQueryOptions<ColumnSchema[], MyMeta>({
queryKeyPrefix: "my-table",
apiEndpoint: "/my-table/api",
searchParamsSerializer: searchParamsSerializer,
skipMetaOnPagination: true,
});It is opt-in because it only works if both halves are in place:
-
Your route honors it. Read the raw param and skip the aggregation:
const skipMeta = req.nextUrl.searchParams.get("_meta") === "false"; const chartData = skipMeta ? [] : groupChartData(filteredData, date); const facets = skipMeta ? {} : getFacetsFromData(filteredData);Only
metais skippable. Per-row fields still have to be returned on every page — skipping one leaves the rows fetched by scrolling missing a value the table renders. -
Your client reads meta from the right page. Use
getMetaPage, not the last page — live mode prepends pages withfetchPreviousPage, so the page carrying meta is not always at index0:import { getMetaPage } from "@/lib/data-table"; const metaPage = getMetaPage(data); const chartData = metaPage?.meta?.chartData; const facets = metaPage?.meta?.facets;
getMetaPage identifies that page from React Query's pageParams, and falls back to the last page — so it is safe to use whether or not meta skipping is on.
_metais appended to the URL after your serializer runs, so it works even with an allow-list serializer that drops unknown keys. It never enters the query key.
useInfiniteQuery Pattern
function DataTableContent() {
const search = useFilterState<FilterState>();
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery(
dataOptions(search),
);
// Flatten pages into a single array
const flatData = React.useMemo(
() => data?.pages?.flatMap((page) => page.data ?? []) ?? [],
[data?.pages],
);
// Derive column filters from state (exclude non-filter fields)
const { sort, cursor, direction, uuid, live, size, ...filter } = search;
const defaultColumnFilters = React.useMemo(() => {
return Object.entries(filter)
.map(([key, value]) => ({ id: key, value }))
.filter(({ value }) => {
if (value === null || value === undefined) return false;
if (Array.isArray(value) && value.length === 0) return false;
return true;
});
}, [filter]);
return (
<DataTableInfinite
data={flatData}
defaultColumnFilters={defaultColumnFilters}
defaultColumnSorting={sort ? [sort] : undefined}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetching={isFetching}
// ...
/>
);
}When the user scrolls to the bottom, DataTableInfinite calls fetchNextPage. React Query fetches the next page using the nextCursor from the last page's response and appends it to data.pages.
Server Prefetch
Server-side prefetching with React Query's HydrationBoundary avoids a loading spinner on first render:
// page.tsx
import { getQueryClient } from "@/providers/get-query-client";
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
export default async function Page({ searchParams }) {
const search = await searchParamsCache.parse(searchParams);
const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery(dataOptions(search));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Client />
</HydrationBoundary>
);
}The query is prefetched on the server and dehydrated into the HTML. On the client, useInfiniteQuery picks up the cached data immediately — no extra network request.
Transport
createDataTableQueryOptions defaults to a same-origin fetch and a
SuperJSON payload — right for an
endpoint you ship alongside the app, and wrong for anything else. The
transport option covers the rest:
import {
createDataTableQueryOptions,
schemaJsonParser,
} from "@/lib/data-table";
const _dataOptions = createDataTableQueryOptions<ColumnSchema[], MyMeta>({
queryKeyPrefix: "my-table",
apiEndpoint: "/logs",
searchParamsSerializer,
transport: {
baseUrl: "https://api.example.com",
// A function, so an auth token is read fresh on every request rather than
// captured once at module scope. It may be async, to await a refresh.
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
credentials: "include",
// The endpoint returns ordinary JSON; revive its timestamp columns from
// the schema instead of asking it to adopt SuperJSON.
parseResponse: schemaJsonParser(schema),
},
});| Option | Default |
|---|---|
baseUrl | Same origin (or VERCEL_URL on the server) — a string, or a function called per request |
headers | None — a value, or a sync/async function |
credentials | The fetch default |
fetch | Global fetch |
parseResponse | superjsonParser() |
Three parsers ship: superjsonParser() (the default), jsonParser() (the body
verbatim), and schemaJsonParser(schema) — which reads the schema to find the
timestamp columns and revives them from ISO strings, so a third-party endpoint
works without changing its serializer.
A cross-origin baseUrl needs CORS on the endpoint.
Errors
A non-2xx response, or a body that cannot be parsed, throws
DataTableFetchError rather than letting a 500's HTML reach SuperJSON.parse
and surface as an opaque syntax error:
const { error } = useInfiniteQuery(dataOptions(search));
if (error instanceof DataTableFetchError) {
// error.status, error.url, and the first 500 chars of the body
}React Query's AbortSignal is forwarded to fetch, so a superseded request is
cancelled rather than left in flight.
Pagination
The default addresses pages by a millisecond timestamp read from a date column, paging both ways — the right default for an append-only log and wrong for anything else. It is one strategy among three:
import {
offsetPagination,
opaqueCursorPagination,
timestampCursorPagination,
} from "@/lib/data-table";
createDataTableQueryOptions({
// …
pagination: offsetPagination({ size: 50 }),
});| Strategy | Wire format |
|---|---|
timestampCursorPagination() | ?cursor=<epoch ms>&direction=next|prev — the default, bidirectional |
opaqueCursorPagination() | ?cursor=<token> — the server's nextCursor echoed back verbatim, forward-only |
offsetPagination({ size }) | ?offset=<n>&size=<n> — honours nextCursor as the next offset when the server sends one, otherwise advances until a page comes back short |
Each strategy owns its parameter names (cursorKey, offsetKey, …) and clears
them from the cache key, so every page of one filter state shares a single query
key.
The page param React Query carries is { page, _meta } — the strategy owns
page, and the meta-skipping flag sits
outside it so the same flag works whether pages are addressed by cursor, opaque
token, or offset. getMetaPage reads _meta and is unaffected by which
strategy is in use.
To point the whole table at an endpoint rather than wiring this by hand, see Headless Tables.
