gridcn1.2.2

URL state

Sync sort, filter, search, column layout, and pagination to the URL via nuqs.

data-grid-url-state syncs the sort, filter, search, and pagination state of the grid with the URL using nuqs.

pnpm dlx shadcn@latest add @gridcn/data-grid-url-state

Usage

Mount <DataGridUrlState> once inside <DataGridProvider>, under a NuqsAdapter for your framework somewhere above it in the tree:

"use client";

import { NuqsAdapter } from "nuqs/adapters/next/app";
import { DataGridUrlState } from "@/components/data-grid-url-state/data-grid-url-state";
import { DataGridToolbar, DataGridSearch, DataGridFilterMenu } from "@/components/data-grid-toolbar/data-grid-toolbar";

function OrdersGrid() {
  return (
    <NuqsAdapter>
      <DataGridProvider {...grid} columns={columns}>
        <DataGridUrlState prefix="orders" />
        <DataGridToolbar>
          <DataGridSearch />
          <DataGridFilterMenu />
        </DataGridToolbar>
        <DataGridRoot>
          <DataGridHeader />
          <DataGridBody />
        </DataGridRoot>
      </DataGridProvider>
    </NuqsAdapter>
  );
}

nuqs/adapters/next/app is the Next.js App Router adapter shown above. Outside the App Router, import NuqsAdapter from your framework's adapter instead:

// Next.js Pages Router
import { NuqsAdapter } from "nuqs/adapters/next/pages";
// React Router v7 (Vite)
import { NuqsAdapter } from "nuqs/adapters/react-router/v7";
// TanStack Router
import { NuqsAdapter } from "nuqs/adapters/tanstack-router";
// Plain React without a router (Vite)
import { NuqsAdapter } from "nuqs/adapters/react";

The nuqs adapter docs list every adapter and its setup.

NuqsAdapter must not be nested

If your app already renders a NuqsAdapter once near the root, the common case, drop the inner one shown above. DataGridUrlState only needs one NuqsAdapter somewhere above it in the tree.

Suspense required when statically prerendering

Because the adapter reads useSearchParams(), wrap the grid in <Suspense> if you statically prerender the page. Next.js requires this.

prefix

This namespaces the sort, filter, join, q, cols, hide, widths, and pins URL keys, so multiple grids can coexist on one page without colliding:

<DataGridUrlState prefix="orders" />
// -> ?orders_sort=...&orders_filter=...&orders_join=...&orders_q=...

The filter menu's And/Or toggle writes the join param (omitted while the operator is the default). It has its own key instead of folding into filter, so a URL generated before the toggle existed still parses identically.

Behavior

  • Reload the page or share a link, and the view comes back exactly as it was.
  • URL params apply to the store exactly once, on mount. After that, the store is the source of truth and drives the URL, not the other way around. Call applyFromUrl() from useDataGridUrlState() when the URL changed outside the grid's own writes: back/forward navigation, a framework setSearchParams, or a query-only route change. The grid then mirrors the URL: a param the URL no longer has clears its sort, filter, join, or search. On mount, a missing param keeps the grid's initial state.
  • Sort, filter, and join writes are immediate, since they are menu-driven, not keystroke-driven. Search writes are debounced (300ms), so typing does not spam history.replaceState.
  • Every URL write uses history: "replace", never push, so typing in search or changing a filter does not pollute the browser's back and forward history.
  • The core stays router-agnostic. data-grid-url-state is the only piece that knows about a router, and it is entirely optional.
  • A URL cannot express an unbounded view state. The filter param parses at most 64 filters and reads at most 256 segments. Each isAnyOf list holds at most max(number of choices, 64) values for a column with choices. A column with missing or empty choices allows 1000 values. An isNoneOf New list has the same bound, and a longer list drops the whole filter segment. An empty list entry (the blank value) is written as a single %. The sort param keeps one direction per column (the first segment wins), reads at most 256 segments, and parses at most 64 sorts. A hand-edited URL beyond these limits degrades to fewer filters and sorts.

The useDataGridUrlState hook reads the store, so a small child component inside the provider owns the wiring:

"use client";

import { useEffect } from "react";
import { useDataGridUrlState } from "@/components/data-grid-url-state/data-grid-url-state";

function UrlStateWithPopstate({ prefix }: { prefix?: string }) {
  const { applyFromUrl } = useDataGridUrlState({ prefix });

  useEffect(() => {
    const onPopstate = () => applyFromUrl();
    window.addEventListener("popstate", onPopstate);
    return () => window.removeEventListener("popstate", onPopstate);
  }, [applyFromUrl]);

  return null;
}

Mount <UrlStateWithPopstate prefix="orders" /> as a child of <DataGridProvider> in place of <DataGridUrlState>. The hook already syncs the URL, so a second sync instance is not needed. A framework router that exposes its own navigation event (for example useSearchParams changes) can call applyFromUrl() from that event instead of popstate.

A URL-applied sort is invisible in the default config

?sort=name:asc reorders the rows, but the built-in sort indicator only renders in headerClickBehavior="sort" mode, which is not the default. So in the default config a deep-linked sort changes the row order with no visible arrow and no built-in UI to clear it (the sort-list toolbar is a separate opt-in add-on). The sync works. The affordance is missing. Make it visible and clearable:

<DataGridProvider headerClickBehavior="sort" {...grid} columns={columns}>

or install data-grid-sort-list for a toolbar sort list.

Incompatible with data-grid-lazy

DataGridUrlState applies the URL's sort, filter, and search into the store on mount. A lazy grid keeps that view state out of the store, because the server applies the spec. Mounting the add-on on a lazy grid re-sorts the partial data client-side. Keep the spec in your own state instead. See Sorting a lazy grid.

Column layout

New

The layout option picks which parts of the column layout go into the URL. The default is ["order", "hidden"]: a shared link restores the column order and the hidden columns. Widths depend on the screen of the viewer, so widths and pins are opt-in. layout={[]} turns layout sync off. This sample adds the widths to the default parts:

<DataGridUrlState layout={["order", "hidden", "widths"]} />
// -> ?cols=city,name,age&hide=age&widths=name:240
PartKeyFormat
ordercolsColumn ids, comma-separated.
hiddenhideColumn ids, comma-separated. - means no hidden column.
widthswidthsid:px pairs, whole pixels.
pinspinsid:l or id:r pairs. - means no pinned column.

The demo at the top of this page syncs the widths and the pins. Its keys start with demo_ (the prefix), as in demo_widths. New

  • A part is in the URL only while it differs from the layout the grid started with. The grid starts with the column defs and defaultColumnLayout. A layout equal to it removes the key.
  • The URL changes once per committed gesture: a drop, a resize release, a pin, or a hide. A resize drag does not write the URL while the pointer moves.
  • On mount, a missing key keeps the grid's initial layout. applyFromUrl() resets a part whose key is gone.
  • Ids that match no column are dropped, and a column missing from cols keeps its place at the end. In development, one warning names the number of dropped entries.
  • A key longer than 16,384 characters is ignored. At most 1,024 entries of a key are read.

Pagination

useDataGridUrlPagination syncs page and pageSize with the URL, separately from <DataGridUrlState>. Pagination has no store slot, so this is a standalone hook rather than something <DataGridUrlState> mounts for you:

"use client";

import { useDataGridUrlPagination } from "@/components/data-grid-url-state/data-grid-url-state";
import { useDataGridPagination, DataGridPaginationBar } from "@/components/data-grid-pagination/data-grid-pagination";

function OrdersGrid({ rows, total }: { rows: Order[]; total: number }) {
  const url = useDataGridUrlPagination();
  const pager = useDataGridPagination({ total, ...url });

  return (
    <>
      <DataGrid data={rows} columns={columns} getRowId={(row) => row.id} />
      <DataGridPaginationBar {...pager.controls} />
    </>
  );
}

page (1-based) is omitted from the URL at 1, and a page beyond 1,000,000 falls back to 1 like any other invalid value. pageSize is omitted when it equals the configured default. Both writes use history: "replace", same as this add-on's other params. Options: prefix (namespaces page/pageSize the same way as sort/filter/q), defaultPageSize (default 25), pageSizeOptions (an out-of-range URL pageSize falls back to the default instead of being trusted; omitted, the pager's default list 10/25/50/100 is the gate), and total (the row count; see below). The result carries the resolved pageSizeOptions. Pass them to useDataGridPagination so the bar's page-size select and the URL's allow-list stay the same list.

When you pass total, an out-of-range deep-linked page clamps to the last page once total is positive. This happens on mount or when a late fetch resolves. The hook writes the clamped value back to the URL, so a shared link always matches the rendered view:

const url = useDataGridUrlPagination({ total: rows.length });

See Pagination for the full composition, including why server mode (not client mode) is the fit.

Changing pageSize resets the URL's page to 1. A page number is only valid for the size it was chosen at, so the reset is the contract of this URL composition. Note that client mode of useDataGridPagination keeps the first visible row on a page-size change instead. The two surfaces differ deliberately.

On this page