React table URL state — filters, sort, page
▶ See it working: the live demo — filter, sort and page it, then copy the URL: every bit of that state is in the address bar (the kit switcher too).
AdaptTable keeps the table’s state in the URL query string: search (q),
pagination (page, limit), sorting (sortBy/sortDir, or sort for a
multi-sort chain), row grouping (groupBy) and session aggregation choices
(groupAgg), and every filter value (f_<key>). Column layout
(colHide, colPin, colOrder, colW, colName) joins in when you wire
useColumnLayoutUrlState, and saved views capture all of it under a name.
Reloading, sharing the link, or pressing back lands on the exact same slice.
Two conventions keep URLs clean: default values are omitted, and changing search, sort, or a filter resets the page to 1.
Format stability and recovery
Section titled “Format stability and recovery”Every table namespace AdaptTable has written carries an atv=1 marker
(people.atv=1 when urlKey="people"). A link without the marker is also
version 1, so links created before the marker existed keep their exact meaning.
The marker remains when the other values are cleared, so an explicitly empty
state is versioned too. It changes only when a future release needs a real
migration; adding an optional parameter does not reinterpret existing ones.
URL state is untrusted input and recovers by policy:
- Unknown parameters are ignored by the table and preserved on writes.
- Malformed values fall back or are dropped independently, so one bad field cannot erase valid state.
- When a parameter is duplicated, the first value wins.
- An unsupported or malformed
atvignores AdaptTable’s recognized parameters for that table only. Other table namespaces and application parameters remain intact. - One table may carry at most 8,192 encoded characters of recognized state. Oversized incoming state is ignored for that table; a write that would cross the limit keeps the previous valid state instead.
Every recovery path produces the normal default table rather than throwing or rendering a blank result.
Multiple tables on one URL: urlKey
Section titled “Multiple tables on one URL: urlKey”Two tables on one page would clobber each other’s params. Give each a
namespace and every param is prefixed (people.q, orders.f_totalMin, …):
import { DataTable } from "@adapttable/mantine";
type Person = { id: string; name: string; status: string };type Order = { id: string; ref: string; total: number };
export function Dashboard({ people, orders,}: { people: Person[]; orders: Order[];}) { return ( <> <DataTable data={people} columns={[ { key: "name", sortable: true }, { key: "status", filter: { type: "select", options: "auto" } }, ]} rowKey={(r) => r.id} urlKey="people" /> <DataTable data={orders} columns={[{ key: "ref" }, { key: "total", filter: "numberRange" }]} rowKey={(o) => o.id} urlKey="orders" /> </> );}// → ?people.q=avery&people.page=2&people.atv=1&orders.f_totalMin=100&orders.atv=1The same urlKey option exists on useFrontendData, useQuerySource,
useTableUrlState, useColumnLayoutUrlState, and useSavedViews for
headless consumers. Omitting distinct urlKeys on shared-URL tables logs a
development warning.
URL adapters
Section titled “URL adapters”The URL layer is decoupled from any router via a tiny UrlStateAdapter:
interface UrlStateAdapter { getSearch(): string; // current query string (no "?") setSearch(search: string, opts?: { push?: boolean }): void; subscribe(onChange: () => void): () => void;}createHistoryAdapter()— browser History API; the default (one shared instance per window viagetHistoryAdapter()).createMemoryAdapter(initial?)— in-memory; used for SSR, tests, and when URL sync is disabled (the table still gets fully working local state).
Pass a custom adapter as urlAdapter on any <DataTable> (the headless
hooks call the option adapter).
Your router
Section titled “Your router”Every router recipe is the same shape: read the current query string, and
navigate to a new one. routerUrlAdapter is that shape — its
RouterUrlAdapterOptions are the router’s current search and a navigate —
so each router takes two lines instead of twelve — and it depends on no router, which is why it can
ship at all.
Memoize it on the search: the adapter is a value, and rebuilding it is how the table learns the route changed.
react-router
Section titled “react-router”import { useMemo } from "react";import { useNavigate, useSearchParams } from "react-router-dom";import { routerUrlAdapter } from "@adapttable/core";
export function useReactRouterAdapter() { const [params] = useSearchParams(); const navigate = useNavigate(); return useMemo( () => routerUrlAdapter({ search: params.toString(), navigate: (search, { push }) => navigate({ search }, { replace: !push }), }), [params, navigate] );}TanStack Router
Section titled “TanStack Router”import { useMemo } from "react";import { useNavigate, useRouterState } from "@tanstack/react-router";import { routerUrlAdapter } from "@adapttable/core";
export function useTanStackAdapter() { const search = useRouterState({ select: (s) => s.location.searchStr }); const navigate = useNavigate(); return useMemo( () => routerUrlAdapter({ search, navigate: (next, { push }) => navigate({ to: ".", search: next, replace: !push }), }), [search, navigate] );}Next.js (App Router)
Section titled “Next.js (App Router)”"use client";import { useMemo } from "react";import { usePathname, useRouter, useSearchParams } from "next/navigation";import { routerUrlAdapter } from "@adapttable/core";
export function useNextAdapter() { const searchParams = useSearchParams(); const pathname = usePathname(); const router = useRouter(); return useMemo( () => routerUrlAdapter({ search: searchParams.toString(), navigate: (search, { push }) => { const url = search ? `${pathname}?${search}` : pathname; if (push) router.push(url, { scroll: false }); else router.replace(url, { scroll: false }); }, }), [searchParams, pathname, router] );}<DataTable data={data} columns={columns} rowKey={(r) => r.id} urlAdapter={useNextAdapter()}/>push is opt-in throughout: the default is a replace, because a table’s every
keystroke is not a page anyone wants to walk back through.
The adapter reports no external changes on purpose. A router re-renders its
tree on navigation, so the hook holding the adapter runs again and reads the
new search itself — subscribing would deliver the same change twice. The one
way to hold it wrong is to pass a search that does not update, which is why
it takes a value rather than a getter.
Turning URL sync off
Section titled “Turning URL sync off”One prop: urlSync={false}. Search, sort, filters and pagination keep
working identically — state just lives in memory, the address bar never
changes, and any urlAdapter is ignored.
<DataTable data={data} columns={columns} rowKey={(r) => r.id} urlSync={false} />Headless equivalent: useTableUrlState({ urlSync: false }) — handy inside
modals or drawers where the address bar shouldn’t change.
Param reference
Section titled “Param reference”| Param | Example | Meaning |
|---|---|---|
atv |
atv=1 |
AdaptTable URL-state format. Missing also means version 1; unsupported versions recover to defaults for this table only. |
q |
q=avery |
Committed search term. |
find |
find=Ada |
Find-bar query (in-table walk). Empty or closed deletes the param; the current-match index is never stored. |
page |
page=3 |
1-based page; omitted at 1. |
limit |
limit=50 |
Page size, clamped to 1–500; omitted at the default (25). |
sortBy + sortDir |
sortBy=name&sortDir=desc |
Single-column sort (sortDir falls back to asc). |
sort |
sort=name:asc,age:desc |
Multi-sort chain; supersedes sortBy/sortDir while present. |
groupBy |
groupBy=team,status |
Ordered row-grouping column keys, outermost first. Written by groupingPanel() and omitted when no grouping is active. |
groupAgg |
groupAgg=budget:sum,age:none |
Per-column session aggregation overrides: a built-in name, a host operation id, or none (explicit suppression). A missing column preserves the developer’s groupAggregates / aggregatable.default / original query.aggregates. |
f_<key> |
f_status=active |
One filter value; multiSelect arrays are comma-separated with each entry percent-encoded. |
f_<key>From / f_<key>To |
f_hiredAtFrom=2026-01-01 |
dateRange bounds (inclusive; the end bound keeps that whole day). A relative operator stores the token here (today, last:7) instead of a resolved day. |
f_<key>Min / f_<key>Max |
f_salaryMin=50000 |
numberRange bounds (inclusive; parsed as numbers). |
ft |
ft=1.{"combinator":"and"} |
Versioned AND/OR filter tree. Unknown versions are dropped, never reinterpreted. |
colHide |
colHide=email,phone |
Hidden columns (keys percent-encoded). |
colPin |
colPin=name:left |
Pinned columns and their side. |
colOrder |
colOrder=name,role,salary |
Explicit column order. |
colW |
colW=name:220 |
Per-column pixel widths. |
colName |
colName=name:Account%20owner |
User display names by stable column key. |
With a urlKey every param is prefixed: people.q, people.f_status,
people.groupBy, people.groupAgg, people.colHide, ….
groupAgg is intentionally an override map, not a replacement aggregate
configuration. Adding or changing an operation writes that column’s id;
removing a developer-declared aggregate writes none so the default does not
come back; removing a reader-added aggregate deletes the entry. Restore
defaults clears the whole map. Keys are percent-encoded, host-defined ids
survive the round trip, and equivalent maps serialize in stable column-key
order. Row grouping documents the panel, column-menu,
mobile, and keyboard routes that write this state.
Defaults vs. explicit clears
Section titled “Defaults vs. explicit clears”defaults (search, sort, extra filter values) apply only while the URL is
silent about a key. When the user explicitly clears a defaulted value, the
hook records it as an empty-valued param (q=, f_status=) so the default
does not instantly resurrect — a missing param means “default applies”, an
empty one means “explicitly cleared”.
Reading and writing the params yourself
Section titled “Reading and writing the params yourself”The codecs the table uses are published, so a route handler, a saved-view store, or a test can read and write the same URL without mounting a table.
parseTableUrlState(search) reads a whole query string into table state;
updateTableUrlState(search, patch) returns the next query string;
applyTableUrlState and captureTableUrlState move that state on and off a
live table.
Each param has a named constant and, where the value is not a plain string, a reader and a writer:
| Constant | Reader / writer |
|---|---|
PARAM_PAGE, PARAM_LIMIT |
readPage, readLimit |
PARAM_SEARCH, PARAM_FIND |
plain strings |
PARAM_SORT_BY, PARAM_SORT_DIR |
readSortDir; the chain is readSortLevels / writeSortLevels |
PARAM_GROUP_BY, PARAM_GROUP_AGGREGATES |
ordered keys and the per-column overrides |
PARAM_COL_HIDDEN |
readColumnLayout / writeColumnLayout cover the whole column layout |
PARAM_DENSITY, PARAM_FORMULA, PARAM_PIVOT |
the density, formula-column and pivot codecs |
f_<key> |
readExtra / writeExtra |
ft |
readFilterTreeParam / writeFilterTreeParam |
| row pins | readRowPins / writeRowPins |
Writing a value that equals the default deletes the param instead of spelling it out, which is what keeps a shared link short.
The default History-API adapter hydrates from an empty query string (the
server has no window; the real URL applies right after hydration). To
server-render the exact requested slice, pass an explicit router adapter —
it knows the request URL, and the hooks trust an explicit adapter to be
hydration-consistent. getHistoryAdapter() itself returns a memory adapter
when there is no window, so nothing crashes under SSR either way.