* Advanced Table with URL State Management using nuqs
* This example demonstrates:
* - URL state persistence using nuqs (state survives page refreshes)
* - Shareable URLs with filters, sorting, and pagination
* - Both standard filter menu and inline filter modes
* - Tabs to switch between filter modes
* - Complete state visibility with debug panel
* - Pagination state in URL (?page=1&perPage=10)
* - Sorting state in URL (?sort=name.asc)
* - Filter state in URL (?filters=...)
* - Join operator in URL (?joinOperator=and)
* - Browser back/forward navigation support
* - React transitions for smooth updates
* - URL length monitoring with warnings (alerts at 1,500+ chars, critical at 1,900+ chars)
* - Chrome: ~2 MB (practical limit ~2,000 characters)
* - Firefox: ~65,000 characters
* - Safari: ~80,000 characters (more restrictive)
* - Social media/messaging apps may have much lower limits
* The component automatically monitors URL length and displays warnings when
* approaching limits. Consider reducing filters or simplifying state if URLs
* exceed 2,000 characters.
* IMPORTANT SETUP REQUIRED:
* This component requires NuqsAdapter to be set up in your app:
* For Next.js App Router:
* 1. Wrap your app with NuqsAdapter in app/layout.tsx:
* import { NuqsAdapter } from 'nuqs/adapters/next/app'
* export default function RootLayout({ children }) {
* <NuqsAdapter>{children}</NuqsAdapter>
* For Next.js Pages Router:
* 1. Wrap your app with NuqsAdapter in pages/_app.tsx:
* import { NuqsAdapter } from 'nuqs/adapters/next/pages'
* export default function App({ Component, pageProps }) {
* <Component {...pageProps} />
* For React SPA (Vite, CRA, etc.):
* 1. Wrap your app with NuqsAdapter in src/main.tsx:
* import { NuqsAdapter } from 'nuqs/adapters/react'
* createRoot(document.getElementById('root')!).render(
* Try it: Add filters, sort, paginate, then refresh the page or share the URL!
import { NuqsAdapter } from "nuqs/adapters/react"
} from "@tanstack/react-table"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
} from "@/components/niko-table/core/data-table-structure"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"
import { DataTableColumnFacetedFilterMenu } from "@/components/niko-table/components/data-table-column-faceted-filter"
import { DataTableColumnSliderFilterMenu } from "@/components/niko-table/components/data-table-column-slider-filter-options"
import { DataTableColumnDateFilterMenu } from "@/components/niko-table/components/data-table-column-date-filter-options"
import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"
DataTableEmptyFilteredMessage,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"
import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"
import { DataTableSortMenu } from "@/components/niko-table/components/data-table-sort-menu"
import { DataTableFilterMenu } from "@/components/niko-table/components/data-table-filter-menu"
import { DataTableInlineFilter } from "@/components/niko-table/components/data-table-inline-filter"
import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"
import { daysAgo, formatQueryString } from "@/components/niko-table/lib/format"
} from "@/components/niko-table/lib/constants"
import { processFiltersForLogic } from "@/components/niko-table/lib/data-table"
import { serializeFiltersForUrl } from "@/components/niko-table/filters/table-filter-menu"
} from "@/components/niko-table/types"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
} from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { AlertTriangle, Info, UserSearch, SearchX } from "lucide-react"
const categoryOptions = [
{ label: "Electronics", value: "electronics" },
{ label: "Clothing", value: "clothing" },
{ label: "Home & Garden", value: "home-garden" },
{ label: "Sports", value: "sports" },
{ label: "Books", value: "books" },
{ label: "Apple", value: "apple" },
{ label: "Samsung", value: "samsung" },
{ label: "Nike", value: "nike" },
{ label: "Adidas", value: "adidas" },
{ label: "Sony", value: "sony" },
{ label: "LG", value: "lg" },
{ label: "Dell", value: "dell" },
{ label: "HP", value: "hp" },
const columns: DataTableColumnDef<Product>[] = [
<DataTableColumnSortMenu />
variant: FILTER_VARIANTS.TEXT,
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />
<DataTableColumnFacetedFilterMenu />
variant: FILTER_VARIANTS.SELECT,
options: categoryOptions,
const category = row.getValue("category") as string
const option = categoryOptions.find(opt => opt.value === category)
return <span>{option?.label || category}</span>
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />
<DataTableColumnFacetedFilterMenu />
variant: FILTER_VARIANTS.SELECT,
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />
<DataTableColumnSliderFilterMenu />
variant: FILTER_VARIANTS.NUMBER,
const price = parseFloat(row.getValue("price"))
return <div className="font-medium">${price.toFixed(2)}</div>
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />
variant: FILTER_VARIANTS.NUMBER,
const stock = Number(row.getValue("stock"))
<div className={stock < 10 ? "font-medium text-red-600" : ""}>
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />
variant: FILTER_VARIANTS.NUMBER,
const rating = Number(row.getValue("rating"))
<div className="flex items-center gap-1">
<span className="text-yellow-500">★</span>
enableColumnFilter: true,
<DataTableColumnSortMenu />
<DataTableColumnFacetedFilterMenu />
variant: FILTER_VARIANTS.BOOLEAN,
const inStock = Boolean(row.getValue("inStock"))
<Badge variant={inStock ? "default" : "secondary"}>
enableColumnFilter: true,
accessorKey: "releaseDate",
<DataTableColumnSortMenu />
<DataTableColumnDateFilterMenu />
variant: FILTER_VARIANTS.DATE,
const date = row.getValue("releaseDate") as Date
return <span>{date.toLocaleDateString()}</span>
enableColumnFilter: true,
const initialData: Product[] = [
name: "Galaxy S24 Ultra",
releaseDate: daysAgo(10),
releaseDate: daysAgo(25),
releaseDate: daysAgo(50),
releaseDate: daysAgo(365),
releaseDate: daysAgo(90),
releaseDate: daysAgo(120),
releaseDate: daysAgo(15),
releaseDate: daysAgo(30),
releaseDate: daysAgo(180),
releaseDate: daysAgo(60),
releaseDate: daysAgo(45),
name: "Garden Tools Set",
releaseDate: daysAgo(75),
name: "Programming Book",
releaseDate: daysAgo(200),
releaseDate: daysAgo(150),
function StandardFilterToolbar({
filters: ExtendedColumnFilter<Product>[]
onFiltersChange: (filters: ExtendedColumnFilter<Product>[] | null) => void
onSearchChange: (value: string) => void
<DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
placeholder="Search products..."
onChange={onSearchChange}
</DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
<DataTableSortMenu className="ml-auto" />
onFiltersChange={onFiltersChange}
</DataTableToolbarSection>
</DataTableToolbarSection>
function InlineFilterToolbar({
filters: ExtendedColumnFilter<Product>[]
onFiltersChange: (filters: ExtendedColumnFilter<Product>[]) => void
onSearchChange: (value: string) => void
<DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
placeholder="Search products..."
onChange={onSearchChange}
</DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
onFiltersChange={onFiltersChange}
</DataTableToolbarSection>
</DataTableToolbarSection>
// Define parsers for URL state management (following nuqs best practices)
const tableStateParsers = {
pageIndex: parseAsInteger.withDefault(0),
pageSize: parseAsInteger.withDefault(10),
sort: parseAsJson<SortingState>(value => value as SortingState).withDefault(
filters: parseAsJson<ExtendedColumnFilter<Product>[]>(
value => value as ExtendedColumnFilter<Product>[],
search: parseAsString.withDefault(""),
// globalFilter should only be used for complex filter objects (OR/MIXED logic)
// Simple text search uses the "search" param instead
// When null/empty, nuqs will remove it from the URL
globalFilter: parseAsJson<{ filters: unknown[]; joinOperator: string }>(
// Only accept objects with filters (complex filter logic)
if (value && typeof value === "object" && "filters" in value) {
return value as { filters: unknown[]; joinOperator: string }
// Reject everything else (strings, empty strings, etc.)
// Return undefined to trigger default, which will be null
return undefined as unknown as {
null as unknown as { filters: unknown[]; joinOperator: string },
columnVisibility: parseAsJson<VisibilityState>(
value => value as VisibilityState,
inlineFilters: parseAsJson<ExtendedColumnFilter<Product>[]>(
value => value as ExtendedColumnFilter<Product>[],
filterMode: parseAsString.withDefault("standard"),
pin: parseAsJson<ColumnPinningState>(
value => value as ColumnPinningState,
).withDefault({ left: [], right: [] }),
// Map internal state keys to URL query parameter names
const tableStateUrlKeys = {
columnVisibility: "cols",
* Normalize filters to ensure they have unique filterIds
* This is critical when loading filters from URL, as they may not have filterIds
* or may have duplicate IDs when multiple filters share the same column
* Normalize filters to ensure they have unique filterIds
* This is critical when loading filters from URL, as they may not have filterIds
* or may have duplicate IDs when multiple filters share the same column
* IMPORTANT: This function preserves filter object references when possible
* to prevent unnecessary re-renders and focus loss in input fields.
function normalizeFiltersWithUniqueIds<TData>(
| Omit<ExtendedColumnFilter<TData>, "filterId">
| ExtendedColumnFilter<TData>
): ExtendedColumnFilter<TData>[] {
// Quick check: if all filters already have unique filterIds, return as-is
// This preserves object references and prevents unnecessary re-renders
const hasAllIds = filters.every(
(f): f is ExtendedColumnFilter<TData> => "filterId" in f && !!f.filterId,
filters.map(f => (f as ExtendedColumnFilter<TData>).filterId),
// If all IDs are unique, return filters unchanged (preserve references)
if (ids.size === filters.length) {
return filters as ExtendedColumnFilter<TData>[]
// Need to normalize - some filters missing IDs or have duplicates
const seenIds = new Set<string>()
return filters.map((filter, index) => {
// If filter already has a filterId, check if it's unique
if ("filterId" in filter && filter.filterId) {
// If this ID was already seen, regenerate it to ensure uniqueness
if (seenIds.has(filter.filterId)) {
// Generate a new unique ID based on index (not value) to keep it stable
const uniqueId = `filter-${filter.id}-${index}-dup${seenIds.size}`
.replace(/[^a-z0-9-]/g, "-")
} as ExtendedColumnFilter<TData>
// ID is unique, preserve it (and the filter object reference)
seenIds.add(filter.filterId)
return filter as ExtendedColumnFilter<TData>
// Filter doesn't have a filterId, generate one
// IMPORTANT: Use index as the primary uniqueness factor, not value
// This ensures filterId stays stable when only the value changes,
// preventing React from treating it as a new filter and losing focus
const uniqueId = `filter-${filter.id}-${index}`
.replace(/[^a-z0-9-]/g, "-")
// Ensure the generated ID is unique (in case of collisions)
while (seenIds.has(finalId)) {
finalId = `${uniqueId}-${counter}`
} as ExtendedColumnFilter<TData>
* Convert a URL-stored filter entry back into a TanStack `ColumnFilter`.
* URL entries are `{id, value}` objects (see `serializeColumnFiltersForUrl`).
* The `value` may be either a raw TanStack value (tuple/array/string/boolean)
* or an ExtendedColumnFilter sans `filterId` — TanStack only needs `{id, value}`.
function urlEntryToColumnFilter(
): { id: string; value: unknown } | null {
if (!entry || typeof entry !== "object") return null
const e = entry as { id?: string; value?: unknown }
if (typeof e.id === "string") return { id: e.id, value: e.value }
// Legacy: entry IS an ExtendedColumnFilter
if ("operator" in (e as Record<string, unknown>)) {
const f = e as ExtendedColumnFilter<unknown>
return { id: f.id, value: f }
* Pull an `ExtendedColumnFilter` out of a URL entry, regardless of which
* shape was written. Used by filter-menu memos to ignore raw column-filter
* entries (slider tuples, etc.) that don't belong to the menu.
function extractExtendedFilter<TData>(
): ExtendedColumnFilter<TData> | null {
if (!entry || typeof entry !== "object") return null
const e = entry as { id?: string; value?: unknown }
if (typeof e.id === "string" && e.value && typeof e.value === "object") {
if ("operator" in (e.value as Record<string, unknown>)) {
return e.value as ExtendedColumnFilter<TData>
if ("operator" in (e as Record<string, unknown>)) {
return e as unknown as ExtendedColumnFilter<TData>
function AdvancedNuqsTableContent() {
const [data] = useState<Product[]>(initialData)
// URL state management with nuqs - using built-in parsers and URL key mapping
const [urlParams, setUrlParams] = useQueryStates(tableStateParsers, {
urlKeys: tableStateUrlKeys,
// Check if global param actually exists in URL (not just default value)
// If globalFilter is an object with filters, it exists in URL
// If it's null, it might be default or was removed
urlParams.globalFilter !== null &&
typeof urlParams.globalFilter === "object" &&
"filters" in urlParams.globalFilter
// Get filter mode from URL
const filterMode = (urlParams.filterMode || "standard") as
// Global filter from URL - separate search (text) from globalFilter (complex filters)
// - search: simple text search string
// - globalFilter: complex filter object (OR/MIXED logic)
const globalFilter = useMemo(() => {
// If globalFilter is stored in URL as object (OR/MIXED logic), use it
urlParams.globalFilter &&
typeof urlParams.globalFilter === "object" &&
"filters" in urlParams.globalFilter
return urlParams.globalFilter
// Otherwise use search string (simple text search)
// Don't fall back to globalFilter if it's a string - that's a legacy format
return urlParams.search || ""
}, [urlParams.globalFilter, urlParams.search])
// Convert URL state to TanStack Table format (using pageIndex from URL)
const pagination: PaginationState = useMemo(
pageIndex: urlParams.pageIndex,
pageSize: urlParams.pageSize,
[urlParams.pageIndex, urlParams.pageSize],
// Parse sorting from URL (now supports multiple sorts as JSON array)
const sorting: SortingState = useMemo(() => {
return urlParams.sort || []
// Standard mode filters - convert from URL format to ColumnFiltersState
// Follow the same pattern as advanced-state.tsx
const standardColumnFilters: ColumnFiltersState = useMemo(() => {
// If globalFilter has OR/mixed filters, keep columnFilters EMPTY
// The globalFilterFn will process the filters from globalFilter object
typeof globalFilter === "object" &&
"filters" in globalFilter &&
filterMode === "standard"
return [] // Empty - filters are in globalFilter
return ((urlParams.filters as unknown[]) || [])
.map(urlEntryToColumnFilter)
.filter((f): f is { id: string; value: unknown } => f !== null)
}, [urlParams.filters, globalFilter, filterMode])
// Inline mode filters - convert from URL format to ColumnFiltersState
// Follow the same pattern as advanced-state.tsx
const inlineColumnFilters: ColumnFiltersState = useMemo(() => {
// If globalFilter has OR/mixed filters, keep columnFilters EMPTY
// The globalFilterFn will process the filters from globalFilter object
typeof globalFilter === "object" &&
"filters" in globalFilter &&
return [] // Empty - filters are in globalFilter
return ((urlParams.inlineFilters as unknown[]) || [])
.map(urlEntryToColumnFilter)
.filter((f): f is { id: string; value: unknown } => f !== null)
}, [urlParams.inlineFilters, globalFilter, filterMode])
// Column pinning state from URL
const columnPinning: ColumnPinningState = useMemo(() => {
return urlParams.pin || { left: [], right: [] }
// Handlers for pagination
const handlePaginationChange = useCallback(
(updater: Updater<PaginationState>) => {
typeof updater === "function" ? updater(pagination) : updater
pageIndex: newPagination.pageIndex,
pageSize: newPagination.pageSize,
[pagination, setUrlParams],
// Handlers for sorting (now supports multiple sorts)
const handleSortingChange = useCallback(
(updater: Updater<SortingState>) => {
typeof updater === "function" ? updater(sorting) : updater
// Store entire sorting array in URL (empty array clears sorts)
void setUrlParams({ sort: newSorting.length > 0 ? newSorting : null })
// Handlers for column pinning
const handleColumnPinningChange = useCallback(
(updater: Updater<ColumnPinningState>) => {
typeof updater === "function" ? updater(columnPinning) : updater
void setUrlParams({ pin: newPinning })
[columnPinning, setUrlParams],
// See `serializeColumnFiltersForUrl` doc in server-side-nuqs-state.tsx.
// Same round-trip: write `{id, value}` shape, strip `filterId` if value is
// an ExtendedColumnFilter (filter-menu case).
const serializeColumnFiltersForUrl = useCallback(
(newFilters: ColumnFiltersState) => {
return newFilters.map(filter => {
const value = filter.value
typeof value === "object" &&
"filterId" in (value as Record<string, unknown>)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { filterId, ...rest } = value as ExtendedColumnFilter<Product>
return { id: filter.id, value: rest }
return { id: filter.id, value }
}) as unknown as ExtendedColumnFilter<Product>[]
// Handlers for filters (standard mode)
const handleStandardColumnFiltersChange = useCallback(
(updater: Updater<ColumnFiltersState>) => {
typeof updater === "function" ? updater(standardColumnFilters) : updater
filters: serializeColumnFiltersForUrl(newFilters),
[standardColumnFilters, setUrlParams, serializeColumnFiltersForUrl],
// Handlers for filters (inline mode)
const handleInlineColumnFiltersChange = useCallback(
(updater: Updater<ColumnFiltersState>) => {
typeof updater === "function" ? updater(inlineColumnFilters) : updater
inlineFilters: serializeColumnFiltersForUrl(newFilters),
[inlineColumnFilters, setUrlParams, serializeColumnFiltersForUrl],
// Track previous globalFilter value to prevent infinite loops
const prevGlobalFilterRef = useRef<string | object | undefined>(undefined)
// Handlers for global filter (handles both search string and OR filter object)
const handleGlobalFilterChange = useCallback(
(value: string | object) => {
// Prevent infinite loops - check if value actually changed
const valueStr = JSON.stringify(value)
const prevStr = JSON.stringify(prevGlobalFilterRef.current)
if (valueStr === prevStr) {
// Update ref before calling setUrlParams
prevGlobalFilterRef.current = value
if (typeof value === "string") {
// Simple search string - only set search param
// Build params conditionally to omit globalFilter entirely
const params: Record<string, unknown> = {
search: value || null, // null removes from URL if empty
// Only include globalFilter: null if it actually exists in URL
// This ensures it gets removed, but we don't add it if it wasn't there
params.globalFilter = null
void setUrlParams(params)
// OR filter object - store in globalFilter
// Exclude filterId from filters to keep URLs shorter
const filterObj = value as {
filters: ExtendedColumnFilter<Product>[]
const serializedFilters = serializeFiltersForUrl(
) as ExtendedColumnFilter<Product>[]
// OR filter object - store in globalFilter
// Keep search param independent - both can coexist
filters: serializedFilters,
joinOperator: filterObj.joinOperator,
// Don't clear search - it's independent from globalFilter
[setUrlParams, hasGlobalParam],
// Direct filter change handlers - sync filter UI changes directly to URL
// Follow the same pattern as advanced-state.tsx: check for OR operators and same-column filters
const handleStandardFiltersChange = useCallback(
(filters: ExtendedColumnFilter<Product>[] | null) => {
// When clearing filters (null or empty array), also clear globalFilter and search
if (!filters || filters.length === 0) {
globalFilter: null, // null removes from URL
search: null, // null removes from URL
// Use core utility to process filters and determine routing
const result = processFiltersForLogic(filters)
// Exclude filterId from URL to keep URLs shorter
const urlFilters = serializeFiltersForUrl(
) as ExtendedColumnFilter<Product>[]
if (result.shouldUseGlobalFilter) {
// Use globalFilter for OR/MIXED logic
joinOperator: result.joinOperator,
// Use filters param for AND logic
// Only include globalFilter: null if it actually exists in URL to remove it
const params: Record<string, unknown> = {
params.globalFilter = null
void setUrlParams(params)
[setUrlParams, hasGlobalParam],
const handleInlineFiltersChange = useCallback(
(filters: ExtendedColumnFilter<Product>[]) => {
// When clearing filters (empty array), also clear globalFilter and search
if (filters.length === 0) {
globalFilter: null, // null removes from URL
search: null, // null removes from URL
// Use core utility to process filters and determine routing
const result = processFiltersForLogic(filters)
// Exclude filterId from URL to keep URLs shorter
const urlFilters = serializeFiltersForUrl(
) as ExtendedColumnFilter<Product>[]
if (result.shouldUseGlobalFilter) {
// Use globalFilter for OR/MIXED logic
joinOperator: result.joinOperator,
// Use inlineFilters param for AND logic
// Only include globalFilter: null if it actually exists in URL to remove it
const params: Record<string, unknown> = {
inlineFilters: urlFilters,
params.globalFilter = null
void setUrlParams(params)
[setUrlParams, hasGlobalParam],
// Handlers for column visibility
const handleColumnVisibilityChange = useCallback(
(updater: Updater<VisibilityState>) => {
typeof updater === "function"
? updater(urlParams.columnVisibility)
void setUrlParams({ columnVisibility: newVisibility })
[urlParams.columnVisibility, setUrlParams],
// Filter statistics (matches advanced-state.tsx logic)
const filterStats = useMemo(() => {
// Check if using OR logic (stored in globalFilter as object)
typeof globalFilter === "object" &&
"filters" in globalFilter
const filterObj = globalFilter as {
const filters = filterObj.filters || []
const hasAndFilters = filters.some(
index === 0 || filter.joinOperator === JOIN_OPERATORS.AND,
const hasOrFilters = filters.some(
index > 0 && filter.joinOperator === JOIN_OPERATORS.OR,
totalFilters: filters.length,
effectiveJoinOperator: hasOrFilters
activeFilters: filters.filter(f => f.value && f.value !== "").length,
// For AND logic (stored in columnFilters/filters)
filterMode === "inline" ? urlParams.inlineFilters : urlParams.filters
const hasAndFilters = activeFilters.length > 0
const hasOrFilters = activeFilters.some(
(filter: ExtendedColumnFilter<Product>) =>
filter.joinOperator === JOIN_OPERATORS.OR,
totalFilters: activeFilters.length,
effectiveJoinOperator: hasOrFilters
activeFilters: activeFilters.filter(
(f: ExtendedColumnFilter<Product>) => f.value && f.value !== "",
}, [urlParams.inlineFilters, urlParams.filters, filterMode, globalFilter])
const resetAllState = useCallback(() => {
// Extract ExtendedColumnFilter list for the filter menu UI.
// URL entries are `{id, value}` shape; only entries whose `value` is itself
// an ExtendedColumnFilter (has `operator`) belong to the filter menu — raw
// column-header values (slider tuples, etc.) are filtered out here.
const currentStandardFilters = useMemo(() => {
urlParams.globalFilter &&
typeof urlParams.globalFilter === "object" &&
"filters" in urlParams.globalFilter &&
filterMode === "standard"
const filterObj = urlParams.globalFilter as {
filters: ExtendedColumnFilter<Product>[]
return filterObj.filters || []
return ((urlParams.filters as unknown[]) || [])
.map(entry => extractExtendedFilter<Product>(entry))
.filter((f): f is ExtendedColumnFilter<Product> => f !== null)
}, [urlParams.filters, urlParams.globalFilter, filterMode])
// Normalize filters to ensure they have unique filterIds
// The normalization function is deterministic (uses index-based IDs), so it produces
// stable results when filters haven't changed, preventing unnecessary re-renders
const normalizedStandardFilters = useMemo(
() => normalizeFiltersWithUniqueIds(currentStandardFilters),
[currentStandardFilters],
// Same as currentStandardFilters, but for the inline filter mode.
const currentInlineFilters = useMemo(() => {
urlParams.globalFilter &&
typeof urlParams.globalFilter === "object" &&
"filters" in urlParams.globalFilter &&
const filterObj = urlParams.globalFilter as {
filters: ExtendedColumnFilter<Product>[]
return filterObj.filters || []
return ((urlParams.inlineFilters as unknown[]) || [])
.map(entry => extractExtendedFilter<Product>(entry))
.filter((f): f is ExtendedColumnFilter<Product> => f !== null)
}, [urlParams.inlineFilters, urlParams.globalFilter, filterMode])
// Normalize filters to ensure they have unique filterIds
// The normalization function is deterministic (uses index-based IDs), so it produces
// stable results when filters haven't changed, preventing unnecessary re-renders
const normalizedInlineFilters = useMemo(
() => normalizeFiltersWithUniqueIds(currentInlineFilters),
// Construct query string from urlParams (nuqs handles URL state)
// This prevents hydration mismatch by deriving from state instead of reading window
const queryString = useMemo(() => {
const params = new URLSearchParams()
// Add all non-empty params using the URL key mapping
if (urlParams.pageIndex !== 0) {
params.set(tableStateUrlKeys.pageIndex, String(urlParams.pageIndex))
if (urlParams.pageSize !== 10) {
params.set(tableStateUrlKeys.pageSize, String(urlParams.pageSize))
if (urlParams.sort && urlParams.sort.length > 0) {
params.set(tableStateUrlKeys.sort, JSON.stringify(urlParams.sort))
if (urlParams.filters && urlParams.filters.length > 0) {
params.set(tableStateUrlKeys.filters, JSON.stringify(urlParams.filters))
params.set(tableStateUrlKeys.search, urlParams.search)
// Only include globalFilter if it's an object (complex filters)
// Don't include it if it's a string (that's legacy - use search instead)
urlParams.globalFilter &&
typeof urlParams.globalFilter === "object" &&
"filters" in urlParams.globalFilter
tableStateUrlKeys.globalFilter,
JSON.stringify(urlParams.globalFilter),
urlParams.columnVisibility &&
Object.keys(urlParams.columnVisibility).length > 0
tableStateUrlKeys.columnVisibility,
JSON.stringify(urlParams.columnVisibility),
if (urlParams.inlineFilters && urlParams.inlineFilters.length > 0) {
tableStateUrlKeys.inlineFilters,
JSON.stringify(urlParams.inlineFilters),
if (urlParams.filterMode !== "standard") {
params.set(tableStateUrlKeys.filterMode, urlParams.filterMode)
// Prettify query string for display - decode and format JSON values
const prettifiedQueryString = useMemo(
() => formatQueryString(urlParams, tableStateUrlKeys),
// Calculate URL length (query string + base URL estimate)
// We estimate base URL length to avoid reading window.location during render
// This prevents hydration mismatch while still providing accurate length warnings
const urlLength = useMemo(() => {
// Base URL estimate: protocol (7) + domain (~20) + path (~10) + "?" (1) = ~38
// This is conservative - actual base URLs are typically 30-50 chars
const baseUrlEstimate = 40
return baseUrlEstimate + queryString.length
const URL_LENGTH_WARNING = 1500 // Warning threshold (75% of 2000)
const URL_LENGTH_CRITICAL = 1900 // Critical threshold (95% of 2000)
const urlLengthStatus = useMemo(() => {
if (urlLength >= URL_LENGTH_CRITICAL) {
return "critical" as const
if (urlLength >= URL_LENGTH_WARNING) {
return "warning" as const
// Track previous status to show/hide alert with delay
const prevStatusRef = useRef<typeof urlLengthStatus>("ok")
const [showUrlLengthAlert, setShowUrlLengthAlert] = useState(false)
// Show alert immediately when status becomes warning/critical
// Using startTransition to batch updates and satisfy React Compiler
if (urlLengthStatus !== "ok" && prevStatusRef.current === "ok") {
setShowUrlLengthAlert(true)
// Hide alert with delay when status returns to ok
if (urlLengthStatus === "ok" && prevStatusRef.current !== "ok") {
const timer = setTimeout(() => {
setShowUrlLengthAlert(false)
prevStatusRef.current = urlLengthStatus
return () => clearTimeout(timer)
prevStatusRef.current = urlLengthStatus
<div className="w-full space-y-4">
{/* URL Length Warning Alert */}
{showUrlLengthAlert && urlLengthStatus !== "ok" && (
variant={urlLengthStatus === "critical" ? "destructive" : "default"}
urlLengthStatus === "critical"
? "border-orange-500 bg-orange-50 dark:bg-orange-950"
: "border-yellow-500 bg-yellow-50 dark:bg-yellow-950"
{urlLengthStatus === "critical" ? (
<AlertTriangle className="text-orange-600 dark:text-orange-400" />
<Info className="text-yellow-600 dark:text-yellow-400" />
<AlertTitle className="text-orange-900 dark:text-orange-100">
{urlLengthStatus === "critical"
? "URL Length Approaching Limit"
urlLengthStatus === "critical"
? "text-orange-800 dark:text-orange-200"
: "text-yellow-800 dark:text-yellow-200"
Your URL is currently <strong>{urlLength} characters</strong>{" "}
{urlLengthStatus === "critical" ? (
You're approaching the practical limit of ~2,000
characters. Some browsers or sharing platforms may truncate or
Consider reducing the number of filters or simplifying your
search criteria to keep URLs shareable.
<div className="mt-2 space-y-1 text-xs">
<strong>Browser Limits:</strong>
<ul className="list-inside list-disc space-y-0.5">
<li>Chrome: ~2 MB (practical limit ~2,000 chars)</li>
<li>Firefox: ~65,000 characters</li>
<li>Safari: ~80,000 characters (more restrictive)</li>
<strong>Tip:</strong> Remove some filters or clear the search to
shorten the URL. Not all application state should be stored in
<div className="space-y-4">
onValueChange={value => {
const newMode = value as "standard" | "inline"
if (newMode === "standard") {
// Switching to standard: clear inline filters
// Switching to inline: clear standard filters
<div className="flex items-center justify-between">
<TabsTrigger value="standard">Standard Filters</TabsTrigger>
<TabsTrigger value="inline">Inline Filters</TabsTrigger>
<TabsContent value="standard" className="space-y-4">
columnFilters: standardColumnFilters,
columnVisibility: urlParams.columnVisibility,
onGlobalFilterChange={handleGlobalFilterChange}
onSortingChange={handleSortingChange}
onColumnFiltersChange={handleStandardColumnFiltersChange}
onColumnVisibilityChange={handleColumnVisibilityChange}
onColumnPinningChange={handleColumnPinningChange}
onPaginationChange={handlePaginationChange}
filters={normalizedStandardFilters}
onFiltersChange={handleStandardFiltersChange}
search={urlParams.search}
onSearchChange={value => {
search: value || null, // null removes from URL if empty
<UserSearch className="size-12" />
<DataTableEmptyDescription>
There are no customers to display at this time.
</DataTableEmptyDescription>
<DataTableEmptyFilteredMessage>
<SearchX className="size-12" />
<DataTableEmptyDescription>
Try adjusting your filters or search to find what
</DataTableEmptyDescription>
</DataTableEmptyFilteredMessage>
<TabsContent value="inline" className="space-y-4">
columnFilters: inlineColumnFilters,
columnVisibility: urlParams.columnVisibility,
onGlobalFilterChange={handleGlobalFilterChange}
onSortingChange={handleSortingChange}
onColumnFiltersChange={handleInlineColumnFiltersChange}
onColumnVisibilityChange={handleColumnVisibilityChange}
onPaginationChange={handlePaginationChange}
filters={normalizedInlineFilters}
onFiltersChange={handleInlineFiltersChange}
search={urlParams.search}
onSearchChange={value => {
search: value || null, // null removes from URL if empty
<UserSearch className="size-12" />
<DataTableEmptyDescription>
There are no customers to display at this time.
</DataTableEmptyDescription>
<DataTableEmptyFilteredMessage>
<SearchX className="size-12" />
<DataTableEmptyDescription>
Try adjusting your filters or search to find what
</DataTableEmptyDescription>
</DataTableEmptyFilteredMessage>
<CardTitle>Current Table State (URL Synced)</CardTitle>
All state is persisted in the URL and survives page refreshes
<Button variant="outline" size="sm" onClick={resetAllState}>
<CardContent className="space-y-4">
<div className="grid gap-2 text-xs text-muted-foreground">
<div className="flex flex-col gap-1">
<span className="font-medium">Current URL:</span>
<code className="overflow-wrap-anywhere block rounded bg-muted px-2 py-1 font-mono text-xs break-all whitespace-pre-wrap">
<div className="flex justify-between">
<span className="font-medium">URL Length:</span>
className={`text-foreground ${
urlLength >= URL_LENGTH_CRITICAL
? "font-bold text-orange-600 dark:text-orange-400"
: urlLength >= URL_LENGTH_WARNING
? "font-semibold text-yellow-600 dark:text-yellow-400"
{urlLength >= URL_LENGTH_CRITICAL && " ⚠️ Critical"}
{urlLength >= URL_LENGTH_WARNING &&
urlLength < URL_LENGTH_CRITICAL &&
<div className="flex justify-between">
<span className="font-medium">Filter Mode:</span>
<span className="text-foreground">{filterMode}</span>
<div className="flex justify-between">
<span className="font-medium">Global Filter Type:</span>
<span className="text-foreground">
{typeof globalFilter === "string"
: typeof globalFilter === "object" && globalFilter
<div className="flex justify-between">
<span className="font-medium">Search Query:</span>
<span className="text-foreground">
{typeof globalFilter === "string"
<div className="flex justify-between">
<span className="font-medium">Total Items:</span>
<span className="text-foreground">{data.length}</span>
<div className="flex justify-between">
<span className="font-medium">Active Filters:</span>
<span className="text-foreground">
{filterStats.activeFilters}
<div className="flex justify-between">
<span className="font-medium">Column Filters Count:</span>
<span className="text-foreground">
{filterMode === "standard"
? standardColumnFilters.length
: inlineColumnFilters.length}
<div className="flex justify-between">
<span className="font-medium">Join Logic:</span>
<span className="text-foreground">
{filterStats.effectiveJoinOperator}
<div className="flex justify-between">
<span className="font-medium">Sorting:</span>
<span className="text-foreground">
.map(s => `${s.id} ${s.desc ? "desc" : "asc"}`)
<div className="flex justify-between">
<span className="font-medium">Page:</span>
<span className="text-foreground">
{pagination.pageIndex + 1} (Size: {pagination.pageSize})
<div className="flex justify-between">
<span className="font-medium">Hidden Columns:</span>
<span className="text-foreground">
Object.values(urlParams.columnVisibility).filter(
<details className="border-t pt-4">
<summary className="cursor-pointer text-xs font-medium hover:text-foreground">
<div className="mt-4 space-y-3 text-xs">
<strong>Enhanced Filters:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
// Show filters from globalFilter if it's an object (OR logic)
typeof globalFilter === "object" &&
"filters" in globalFilter
const filterObj = globalFilter as { filters: unknown[] }
return JSON.stringify(filterObj.filters, null, 2)
// Otherwise show from columnFilters (AND logic)
? urlParams.inlineFilters
return activeFilters.length > 0
? JSON.stringify(activeFilters, null, 2)
<strong>URL Filters (AND logic):</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(urlParams.filters, null, 2)}
<strong>Inline Filters:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(urlParams.inlineFilters, null, 2)}
<strong>Global Filter (OR logic / Search):</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(globalFilter, null, 2)}
<strong>Filter Stats:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(filterStats, null, 2)}
<strong>URL Pagination:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
pageIndex: urlParams.pageIndex,
pageSize: urlParams.pageSize,
<strong>URL Sorting:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(urlParams.sort, null, 2)}
<strong>Search Query:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(urlParams.search, null, 2)}
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong>💡 Tip:</strong> Try adding filters, sorting, or changing
pages, then copy the URL and paste it in a new tab. All your table
* Main component wrapped in NuqsAdapter
* This example includes NuqsAdapter at the component level since it's a standalone example.
* For production apps, it's recommended to add NuqsAdapter at your root layout instead:
* - Next.js App Router: Wrap in app/layout.tsx
* - Next.js Pages Router: Wrap in pages/_app.tsx
* - React SPA: Wrap in src/main.tsx
* See the component documentation at the top of this file for detailed setup instructions.
export default function AdvancedNuqsTableExample() {
<AdvancedNuqsTableContent />