Search Docs

Search through documentation...

OpenStatus Logo

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, live for stable cache keys)
  • Initial cursorDate.now() (most recent data first)
  • Page paramsgetNextPageParam / getPreviousPageParam from nextCursor / prevCursor
  • CachingkeepPreviousData for 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:

  1. 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 meta is 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.

  2. Your client reads meta from the right page. Use getMetaPage, not the last page — live mode prepends pages with fetchPreviousPage, so the page carrying meta is not always at index 0:

    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.

_meta is 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),
  },
});
OptionDefault
baseUrlSame origin (or VERCEL_URL on the server) — a string, or a function called per request
headersNone — a value, or a sync/async function
credentialsThe fetch default
fetchGlobal fetch
parseResponsesuperjsonParser()

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