Skip to Content
FrontendPlatformURL Query Sync

URL Query Sync

Package@repo/url-query-sync
Locationpackages/frontend/url-query-sync/
PatternSchema-driven URL ↔ state synchronization
Built onnuqs , Zod 4 codecs, Jotai, form-atoms

The @repo/url-query-sync package provides bidirectional synchronization between URL query parameters and application state. It handles parsing URL strings into typed values, writing state changes back to the URL, and hydrating Jotai atoms and form-atoms fields — all driven by a single config object per page.

Why This Exists

Most pages on CardNexus (search, explore, daily movers, etc.) have URL state that needs to:

  1. Persist across navigation — filters, sort, pagination survive page refreshes and browser back/forward
  2. Be shareable — copy a URL, send it to someone, they see the same state
  3. Drive API calls — the URL state maps directly to API input parameters
  4. Hydrate UI atoms — form fields, Jotai atoms, and sidebar state need to reflect the URL on first render
  5. Stay clean — default values are elided from the URL, keeping URLs readable

Previously each page had its own ad-hoc parsing logic. This package centralizes all of that into a declarative, type-safe system.

How It Works — The Mental Model

The system separates URL state into two categories:

  • Static params — Known at build time, defined in config.params. Things like page, limit, sort, direction, q, layout. Each has a fixed URL key and an optional default value.
  • Dynamic filters — Derived from game Attributes loaded at runtime. Things like game, rarity, priceEu, finish. Their URL keys are the attribute slugs, and their parser is selected based on attribute.type.

Core API: useQuerySync

useQuerySync is the primary public API for URL state management. It composes two internal hooks:

  1. useUrlSync — Pure URL ↔ nuqs bridge. Parses URL, returns typed state + setters.
  2. useAtomSync — All Jotai atom synchronization: mount-time hydration, continuous URL → atom sync, and reverse atom → URL sync for sync: true params.
import { useQuerySync } from "@repo/url-query-sync" const urlState = useQuerySync(config, attributes, searchParams) // urlState.filters — Parsed dynamic filters from URL // urlState.params — Typed static params // urlState.setParam — Set a single param (typed) // urlState.setFilter — Set a single filter (resets page) // urlState.setFilters — Set all filters (resets page) // urlState.clearFilters — Remove all filters (resets page) // urlState.filterCount — Number of active filters // urlState.toApiInput() — Build API input from current state

What useAtomSync handles automatically

When you use useQuerySync, the atom sync layer handles three concerns:

  1. Mount-time hydration — Uses useHydrateAtoms to synchronously set atom/fieldAtom values during the first render. For fieldAtom, it hydrates both the value and _initialValue sub-atoms so forms treat the URL value as the initial value.
  2. Continuous URL → atom sync — A useEffect that keeps atoms in sync when URL changes after mount (e.g., browser back/forward, programmatic setParam).
  3. Reverse atom → URL sync — For params with sync: true, subscribes to the atom via store.sub and pushes changes to the URL. Used for continuously-updated atoms like debounced text search.

This means you do not need to manually call useHydrateAtoms or useFieldInitialValue for any atom/fieldAtom declared in your query config. The hydration is handled automatically.

Step-by-Step: Adding URL Sync to a New Page

Step 1: Define Your Query Config

Create a config file in your page’s _atoms/ or _config/ directory:

// app/[locale]/(default)/my-page/_config/my-page-query-config.ts import { defineQueryConfig, type SetParamFn } from "@repo/url-query-sync" import type { Filters } from "@repo/zod-filters" import { sidebarFiltersAtom } from "../_atoms/base" import { currentPageAtom, itemsPerPageFieldAtom, sortColumnFieldAtom, sortDirectionFieldAtom, layoutFieldAtom, } from "@repo/store" import { ViewLayout } from "@repo/api-dtos" // 1. Define your API input type (what your endpoint expects) type MyPageApiInput = { search?: { game?: string; filters?: Filters } sort?: [string, "asc" | "desc"][] limit: number offset: number } // 2. Define params with their URL keys, defaults, and atom bindings const myPageParams = { q: { urlKey: "q", // URL: ?q=charizard fieldAtom: textSearchFieldAtom, // Hydrates this form field transform: (value: string | null) => value ?? "", }, page: { urlKey: "page", // URL: ?page=2 (1-indexed) default: 1, atom: currentPageAtom, // Hydrates this Jotai atom transform: (value: number | null) => (value ?? 1) - 1, // Convert to 0-indexed }, limit: { urlKey: "limit", // URL: ?limit=48 default: 24, fieldAtom: itemsPerPageFieldAtom, transform: (value: number | null) => String(value ?? 24), }, sort: { urlKey: "sort", // URL: ?sort=price fieldAtom: sortColumnFieldAtom, transform: (value: string | null, params: Record<string, unknown>) => value ?? (params.q ? "best-match" : "name"), }, direction: { urlKey: "direction", // URL: ?direction=desc fieldAtom: sortDirectionFieldAtom, transform: (value: string | null) => value === "desc" ? "desc" : "asc", }, layout: { urlKey: "layout", // URL: ?layout=list default: ViewLayout.Grid, uiOnly: true, // Not sent to API fieldAtom: layoutFieldAtom, transform: (value: ViewLayout | null) => value ?? ViewLayout.Grid, }, } // 3. Create the config with mapToInput export const myPageQueryConfig = defineQueryConfig({ params: myPageParams, filterAtom: sidebarFiltersAtom, // Hydrates sidebar filters mapToInput: (filters: Filters, params): MyPageApiInput => { // Transform flat URL state → nested API shape const { game: gameFilter, ...restFilters } = filters const game = (gameFilter as any)?.values?.[0] return { search: game || Object.keys(restFilters).length > 0 ? { game, filters: restFilters } : undefined, sort: params.sort ? [[params.sort, params.direction ?? "asc"]] : undefined, limit: params.limit ?? 24, offset: ((params.page ?? 1) - 1) * (params.limit ?? 24), } }, }) // 4. Export typed setParam for child components export type MyPageSetParamFn = SetParamFn<typeof myPageParams>

Key design decisions:

  • The urlKey is the actual string in the URL. The object key (e.g., q, page) is the name used in code.
  • uiOnly: true excludes the param from mapToInput’s params argument. Use for layout, zoom — things the API doesn’t care about.
  • transform converts the URL-parsed value into the shape your atom/fieldAtom expects. Runs during hydration and on every URL change.
  • mapToInput is a pure function — it receives the flat parsed state and returns the exact shape your API endpoint expects.

Step 2: Create Base Atoms

Create a _atoms/base.ts file for atoms that are needed by the config:

// app/[locale]/(default)/my-page/_atoms/base.ts import { atomWithCompare } from "@repo/store" import type { Filters } from "@repo/zod-filters" export const sidebarFiltersAtom = atomWithCompare<Filters>({})

Use atomWithCompare for the filter atom — it prevents unnecessary re-renders when the filter object is structurally equal.

Step 3: Wire Up the UI Component

// app/[locale]/(default)/my-page/my-page-ui.tsx "use client" import type { Attributes } from "@repo/attributes" import { UrlSyncProvider, useQuerySync } from "@repo/url-query-sync" import { CustomQueryErrorResetBoundary } from "@repo/ui" import { JotaiQueryProvider } from "@/libs/shared/store" import { myPageQueryConfig, type MyPageSetParamFn } from "./_config/my-page-query-config" interface MyPageUIProps { params: Record<string, string> // From Next.js searchParams } // Inner component that calls useQuerySync const MyPageHydrated = ({ params: routeParams }: MyPageUIProps) => { const sortedAttributes = useAtomValue(sortedAttributesAtom) const urlState = useQuerySync( myPageQueryConfig, sortedAttributes as Attributes, routeParams, ) return ( <> <SidebarFilters filters={urlState.filters} setFilters={urlState.setFilters} /> <TableActions setParam={urlState.setParam as MyPageSetParamFn} clearFilters={urlState.clearFilters} /> <Results setParam={urlState.setParam as MyPageSetParamFn} /> </> ) } // Top-level component wraps with providers export function MyPageUI({ params }: MyPageUIProps) { return ( <CustomQueryErrorResetBoundary> <JotaiQueryProvider> <UrlSyncProvider> <MyPageHydrated params={params} /> </UrlSyncProvider> </JotaiQueryProvider> </CustomQueryErrorResetBoundary> ) }

Provider nesting order matters:

  1. CustomQueryErrorResetBoundary — error boundary
  2. JotaiQueryProvider — Jotai store + React Query client
  3. UrlSyncProvider — nuqs adapter + URL sync context

Inside UrlSyncProvider, call useQuerySync as a hook — it handles URL parsing, atom hydration, and continuous sync automatically. No render-prop pattern needed.

Step 4: Create the Server Component Page

// app/[locale]/(default)/my-page/page.tsx import { MyPageUI } from "./my-page-ui" interface MyPageProps { searchParams: Promise<Record<string, string>> } export default async function MyPage({ searchParams }: MyPageProps) { const params = await searchParams return <MyPageUI params={params} /> }

The server component awaits searchParams and passes them down as a plain object. useQuerySync receives these as initialSearchParams for fallback parser inference.

Step 5: Use URL State in Child Components

Child components receive setParam and setFilters as props:

// components/my-table-actions.tsx "use client" import type { MyPageSetParamFn } from "../_config/my-page-query-config" interface MyTableActionsProps { setParam: MyPageSetParamFn clearFilters: () => void } export function MyTableActions({ setParam, clearFilters }: MyTableActionsProps) { const handleSortChange = (sort: string) => { setParam("sort", sort) // Typed — only accepts valid param names } const handleLimitChange = (limit: number) => { setParam("limit", limit) // Typed — only accepts number for limit } const handleLayoutChange = (layout: ViewLayout) => { setParam("layout", layout) // Does NOT reset page (uiOnly) } return (/* your UI */) }

For pagination:

const handlePageChange = (page: number) => { setParam("page", page + 1) // Convert 0-indexed UI → 1-indexed URL setCurrentPage(page) // Also update the atom directly for immediate feedback }

Real-World Example: Search Page

The search page is a primary consumer of this package. Here’s how the pieces fit together:

File Structure

app/[locale]/(default)/search/ ├── page.tsx # Server component — awaits searchParams ├── search-ui.tsx # Client entry — providers + useQuerySync ├── _config/ │ └── search-query-config.ts # defineQueryConfig + mapToInput ├── _atoms/ │ ├── base.ts # sidebarFiltersAtom, nameSlugAtom │ ├── ui.ts # Derived atoms (pickedGamesAtom, etc.) │ ├── queries.ts # API query atoms │ ├── effects.ts # Side effects (page reset, game slug sync) │ └── index.ts # Re-exports └── components/ ├── search-table-actions.tsx # Receives setParam ├── search-results.tsx # Receives setParam for pagination ├── search-sidebar-filters.tsx # Receives filters + setFilters └── search-header.tsx

How It Works

// search-ui.tsx (simplified) const SearchUIHydrated = ({ params: routeParams }: SearchUIProps) => { useHydrateAtoms([ [gameSlugAtom, routeParams.game ?? ""], [nameSlugAtom, routeParams.nameSlug ?? ""], ]) const sortedAttributes = useAtomValue(sortedAttributesAtom) const urlState = useQuerySync( searchQueryConfig, sortedAttributes as Attributes, routeParams, ) return ( <SearchUIContent attributes={sortedAttributes as Attributes} filters={urlState.filters} setParam={urlState.setParam as SearchSetParamFn} setFilters={urlState.setFilters} clearFilters={urlState.clearFilters} layout={(urlState.params.layout ?? ViewLayout.Grid) as ViewLayout} /> ) } export function SearchUI({ params }: SearchUIProps) { return ( <CustomQueryErrorResetBoundary> <JotaiQueryProvider> <UrlSyncProvider> <div className="flex flex-col md:flex-row gap-6 mt-8"> <SearchUIHydrated params={params} /> </div> </UrlSyncProvider> </JotaiQueryProvider> </CustomQueryErrorResetBoundary> ) }

Config Highlights

The search config demonstrates several patterns:

  • q param — Bound to textSearchFieldAtom with transform value ?? "" (never null)
  • page param — 1-indexed in URL, transformed to 0-indexed for currentPageAtom
  • limit param — Number in URL, transformed to string for itemsPerPageFieldAtom
  • sort param — Transform depends on other params (params.q ? "best-match" : "name")
  • layout param — uiOnly: true so it’s excluded from API input
  • filterAtom — Sidebar filters are hydrated with parsed URL filters
  • mapToInput — Routes game and productType filters specially, passes the rest through

Data Flow Walk-Through

  1. User navigates to /search?q=charizard&page=2&game=mtg|pokemon&priceEu=10-100
  2. page.tsx (server) awaits searchParams and passes { q: "charizard", page: "2", game: "mtg|pokemon", priceEu: "10-100" } to SearchUI
  3. UrlSyncProvider wraps the nuqs adapter
  4. useQuerySync calls useUrlSync(searchQueryConfig, attributes, routeParams):
    • Builds parser map: q → searchParser, page → pageParser, game → relationParser, priceEu → rangeParser
    • nuqs parses all URL keys through their parsers
    • Splits into params: { q: "charizard", page: 2, sort: null, ... } and filters: { game: { op: OR, values: ["mtg", "pokemon"] }, priceEu: { min: 10, max: 100 } }
  5. useAtomSync hydrates:
    • textSearchFieldAtom → "charizard" (via transform: value ?? "")
    • currentPageAtom → 1 (via transform: (value ?? 1) - 1)
    • sortColumnFieldAtom → "best-match" (via transform: value ?? (params.q ? "best-match" : "name"))
    • sidebarFiltersAtom → { game: ..., priceEu: ... }
  6. urlState is passed as props to SearchUIContent
  7. Components read from their atoms and use setParam/setFilters for updates

Fallback Parser Inference

A subtle but important feature: when game-specific attributes haven’t loaded yet (async), the URL might contain keys like rarity=rare|mythic that have no parser in the attribute-derived map.

The inferFilterParser function examines the raw URL value string and guesses the appropriate parser:

URL Value PatternInferred Parser
0-100, 10.5-, -50range
gt:100, lt:50, eq:42number
true, falseboolean
exists, not-existsexistence
Everything elseenum (pipe-separated)

When attributes eventually load, the parser map rebuilds. Since the inferred parser typically matches the real one, the parsed value is identical and no re-render is triggered.

Using Codecs Standalone

The codecs are also exported individually for use outside the URL sync system. For example, the daily movers page uses booleanFilterCodec and stringFilterCodec directly to parse query params without the full useQuerySync machinery:

import { booleanFilterCodec, stringFilterCodec } from "@repo/url-query-sync" const fromMyInventoryResult = booleanFilterCodec.safeDecode(fromMyInventory ?? "") const fromMyInventoryParsed = fromMyInventoryResult.success ? fromMyInventoryResult.data.value : false const periodResult = stringFilterCodec.safeDecode(period ?? "") const periodParsed = periodResult.success ? periodResult.data.value : "24h"

This is useful for pages that have simple, static URL params without the need for full filter synchronization.

Server-Side Parsing

For SEO metadata, prefetching, or any server component logic that needs to read URL params:

import { parseUrlParams } from "@repo/url-query-sync" // In a server component or generateMetadata const { filters, params } = parseUrlParams(searchParams, myConfig, attributes) // Use params.q for the page title, filters for prefetching, etc.

parseUrlParams uses the same codecs as the client hook, guaranteeing SSR/client parity. It accepts:

  • Record<string, string | string[] | undefined> — Next.js searchParams format
  • URLSearchParams — Standard web API

Param Behavior Reference

BehaviorDescription
Default elisionWhen setParam("limit", 24) and default is 24, the param is removed from URL
Page auto-resetsetFilter, setFilters, clearFilters, and setParam for data params (sort, direction, limit, q) auto-reset page to null (page 1)
No page resetsetParam("layout", ...), setParam("zoom", ...), setParam("page", ...), setParam("group", ...) do NOT reset page
Parser normalizationsetParam runs the value through the param’s parser before writing, ensuring correct types (e.g., string "24" → number 24 for limit)
Empty filter cleanupsetFilter with empty values ({ values: [] }, { min: null, max: null }, { value: "" }) is treated as null and removed from URL

Advanced Patterns

These patterns were established during the Explore Type page integration and will apply to most non-trivial pages.

Factory-Style Config with Dynamic Defaults

When defaults depend on runtime data (e.g., game-specific sort, layout, items per page), use a factory function instead of a static config object:

// _atoms/query-config.ts import { Configurations, type SupportedGames } from "@repo/game-configuration" export function createMyPageQueryConfig(gameSlug: string) { const gameConfig = Configurations[gameSlug as SupportedGames] const defaultSort = gameConfig?.defaultSorting ?? { column: "name", direction: "asc" } const defaultLayout = gameSlug === "cyberpunk" ? ViewLayout.Gallery : ViewLayout.Grid const defaultLimit = gameSlug === "cyberpunk" ? 96 : 24 const params = { sort: { urlKey: "sort", fieldAtom: sortColumnFieldAtom, transform: (value: string | null) => value ?? defaultSort.column, }, layout: { urlKey: "layout", default: defaultLayout, // For URL elision uiOnly: true, fieldAtom: layoutFieldAtom, transform: (value: ViewLayout | null) => value ?? defaultLayout, }, limit: { urlKey: "limit", default: defaultLimit, // For URL elision fieldAtom: itemsPerPageFieldAtom, transform: (value: number | null) => String(value ?? defaultLimit), }, // ... other params } return defineQueryConfig({ params, filterAtom: sidebarFiltersAtom, mapToInput: ... }) }

In the UI component, memoize the config by the key input:

const typeQueryConfig = useMemo( () => createMyPageQueryConfig(gameSlug), [gameSlug], )

Why transform, not default? The default field only controls URL elision (removing ?limit=24 when 24 is the default). It does NOT hydrate atoms. The transform function is what actually provides a fallback value — when transform returns non-null, the atom sync writes it to the atom.

Custom Param Parsers (Boolean, String, Enums)

For params that aren’t standard types (string/number), use the parser field with a custom codec parser:

import { booleanParamParser, stringParamParser } from "@repo/url-query-sync" const params = { hideOutOfStock: { urlKey: "hideOutOfStock", fieldAtom: availabilityFilterAtom, parser: booleanParamParser, // URL "true" → boolean true transform: (value: boolean | null) => Boolean(value), }, mode: { urlKey: "mode", fieldAtom: listingsModeFieldAtom, parser: stringParamParser, // Generic string pass-through transform: (value: string | null) => value === "discovery" ? "discovery" : "marketplace", }, }

Available param parsers:

ParserTypeUse Case
booleanParamParserbooleanToggles like hideOutOfStock
stringParamParserstringGeneric string params like mode

Built-in parsers (auto-selected by urlKey): pageParser, limitParser, sortParser, directionParser, searchParser, layoutParser, zoomParser, groupParser.

Important: When using a custom parser, ParamValue extracts the type from the parser automatically. So setParam("hideOutOfStock", true) is correctly typed as boolean | null, not string | null. Always pass the correctly typed value — do NOT stringify it (e.g., String(value) would cause a ZodError at runtime because the codec expects a boolean).

Separate sidebarFiltersAtom Gotcha

Some pages define their own sidebarFiltersAtom in their _atoms/ui.ts, separate from the listings sidebarFiltersAtom at @/libs/business/components/listings/_atoms/ui. If the query config uses one atom (via filterAtom) but the data fetching chain reads from a different one, the atom sync populates the wrong atom and you get a flash of unfiltered data.

Symptom: Params (sort, layout, limit) work fine, but filters blink — cards appear unfiltered, then re-render filtered.

Fix: In the content component, add useHydrateAtoms to synchronously set the page’s sidebar atom from the filters prop:

import { useHydrateAtoms } from "jotai/utils" import { sidebarFiltersAtom } from "./_atoms/ui" // The page's OWN atom const MyPageContent = ({ filters, ... }) => { // Synchronously hydrate the page's sidebarFiltersAtom useHydrateAtoms([[sidebarFiltersAtom, filters]]) // Keep the useEffect for subsequent updates const setSidebarFilters = useSetAtom(sidebarFiltersAtom) useEffect(() => { setSidebarFilters(filters) }, [filters, setSidebarFilters]) // ... }

useHydrateAtoms only fires once (first mount), so the useEffect takes over for all subsequent filter changes.

Wrapping clearFilters for Param-Based Toggles

clearFilters() from the URL sync only clears attribute-based filters (condition, language, rarity, etc.) and any params listed in clearFilterParams. It does NOT clear arbitrary params like hideOutOfStock unless you explicitly include them.

To include params in the clear action, use the clearFilterParams option in your config:

export const myConfig = defineQueryConfig({ params, filterAtom: sidebarFiltersAtom, clearFilterParams: ["q", "hideOutOfStock"], // Also cleared when clearFilters() is called mapToInput: ... })

If you need more control (e.g., resetting a field atom’s value too), wrap clearFilters:

const hideOutOfStockActions = useFieldActions(availabilityFilterAtom) const handleClearFilters = useCallback(() => { clearFilters() // Clears attribute-based filters + clearFilterParams hideOutOfStockActions.setValue(false) // Resets the field atom (for the Toggle UI) }, [clearFilters, hideOutOfStockActions])

The ListingsSidebarFilters component manages only the attributes rendered in the sidebar (via DynamicFilters). When a sidebar filter changes, handleFiltersChanged preserves any filter keys not managed by the sidebar (like condition, language, finish from quick filters) by merging them back:

const handleFiltersChanged = useCallback( (newSidebarFilters: Filters) => { const sidebarAttrs = mode === "marketplace" ? marketplaceAttributes : sortedAttributes const sidebarKeys = new Set(Object.keys(sidebarAttrs ?? {})) // Preserve filters not managed by the sidebar const preserved: Filters = {} for (const [key, value] of Object.entries(filters)) { if (!sidebarKeys.has(key)) { preserved[key] = value } } setFilters({ ...preserved, ...newSidebarFilters }) }, [filters, setFilters, mode, marketplaceAttributes, sortedAttributes], )

This means params like hideOutOfStock and mode (which live in config.params) are handled via setParam, while quick filter values like condition, language, and finish (which live in filters) are preserved through the merge pattern above.

Real-World Example: Explore Type Page

The explore type page (/explore/[game]/[expansion]/[type]) is the most complex integration, with game-specific defaults, custom parsers, two rendering modes (V1 discovery and V2 marketplace), and quick filter hydration.

File Structure

app/[locale]/(default)/explore/(games)/[game]/[expansion]/[type]/ ├── page.tsx # Server component — awaits searchParams ├── type-ui.tsx # Client entry — providers + useQuerySync + V1/V2 switch ├── _atoms/ │ ├── query-config.ts # createTypeQueryConfig(gameSlug) factory │ ├── ui.ts # Page-level atoms (sidebarFiltersAtom, urlSyncAttributesAtom) │ ├── queries.ts # V1 data fetching (productsPerExpansionSwrAtom) │ ├── effects.ts # Side effects │ └── index.ts # Re-exports └── components/ ├── type-table-actions.tsx # V1 table actions ├── type-sidebar-filters.tsx # V1 sidebar filters ├── type-products.tsx # V1 product grid └── v2/ ├── type-ui-content.tsx # V2 content — handlers + layout ├── type-table-actions.tsx # V2 table actions with quick filters └── type-products.tsx # V2 product grid

Integration Pattern

// type-ui.tsx (simplified) const TypeHydrated = ({ gameSlug, expansionSlug, productType, searchParams }) => { useHydrateAtoms([ [gameSlugAtom, gameSlug], [expansionSlugAtom, expansionSlug], [productTypeAtom, productType], [listingsExpansionSlugAtom, expansionSlug], [listingsProductTypeAtom, productType], ]) const typeQueryConfig = useMemo( () => createTypeQueryConfig(gameSlug), [gameSlug], ) const attributes = useAtomValue(urlSyncAttributesAtom) const urlState = useQuerySync( typeQueryConfig, attributes as Attributes, searchParams, ) return isMarketplaceEnabled ? ( <TypeUIContentV2 filters={urlState.filters} setParam={urlState.setParam as TypeSetParamFn} setFilters={urlState.setFilters} clearFilters={urlState.clearFilters} /> ) : ( <TypeUIContentV1 filters={urlState.filters} setParam={urlState.setParam as TypeSetParamFn} setFilters={urlState.setFilters} clearFilters={urlState.clearFilters} /> ) } export const TypeUI = ({ gameSlug, expansionSlug, productType, searchParams }) => ( <CustomQueryErrorResetBoundary> <JotaiQueryProvider> <UrlSyncProvider> <TypeHydrated gameSlug={gameSlug} expansionSlug={expansionSlug} productType={productType} searchParams={searchParams} /> </UrlSyncProvider> </JotaiQueryProvider> </CustomQueryErrorResetBoundary> )

Config Highlights

The type page demonstrates:

  • Factory config — createTypeQueryConfig(gameSlug) reads from Configurations to set game-specific defaults for sort, direction, layout, and limit
  • booleanParamParser — hideOutOfStock uses a custom boolean codec (?hideOutOfStock=true)
  • stringParamParser for mode — mode param (?mode=discovery) uses the generic string parser
  • transform as default provider — Every param’s transform returns a non-null fallback when the URL value is absent, ensuring the atom sync always writes to the atom
  • Dynamic default for URL elision — layout and limit have game-specific default values so Cyberpunk’s ?layout=gallery is elided but non-Cyberpunk’s ?layout=gallery is shown

Key Patterns Used

PatternWhereWhy
Factory configquery-config.tsGame-specific defaults for sort/layout/limit
useQuerySync hooktype-ui.tsxSingle entry point for URL ↔ atom sync
useHydrateAtoms for page atomstype-ui.tsxHydrate gameSlug, expansionSlug, productType before useQuerySync
useHydrateAtoms for sidebarTypeUIContentV1Bridge page’s sidebarFiltersAtom from filters prop
setParam callbacksTypeUIContentV2Route hideOutOfStock and mode changes to URL params
onChange callback propsListingsQuickFilters, ListingsSidebarFiltersRoute toggle/select changes to setParam instead of writing atoms directly

Checklist for New Pages

  • Create _atoms/query-config.ts with defineQueryConfig() (or a factory function if defaults are dynamic)
  • Create _atoms/ui.ts with sidebarFiltersAtom (if using filters)
  • Define the mapToInput function that matches your API endpoint’s input shape
  • Wrap your page in CustomQueryErrorResetBoundary > JotaiQueryProvider > UrlSyncProvider
  • Call useQuerySync(config, attributes, searchParams) inside the UrlSyncProvider
  • Pass searchParams from the server component page
  • Type your setParam with SetParamFn<typeof myParams>
  • Handle pagination with 0-indexed ↔ 1-indexed conversion
  • If the page has its own sidebarFiltersAtom, add useHydrateAtoms([[sidebarFiltersAtom, filters]]) in the content component
  • For custom param types (boolean, enum), use the parser field and pass correctly typed values to setParam (do NOT stringify)
  • Use clearFilterParams in the config for params that should be cleared with clearFilters()
  • Test URL sharing: copy URL, open in new tab, verify state matches
  • Test: params + filters are visible on first paint (no blink/flash)
  • Test: browser back/forward preserves all state

Code Locations

ComponentLocation
Package rootpackages/frontend/url-query-sync/
Type definitionspackages/frontend/url-query-sync/src/schema/types.ts
Config builderpackages/frontend/url-query-sync/src/schema/define-query-config.ts
Param codecspackages/frontend/url-query-sync/src/codecs/param-codecs.ts
Filter codecspackages/frontend/url-query-sync/src/codecs/filter-codecs.ts
useQuerySync hookpackages/frontend/url-query-sync/src/hooks/use-query-sync.ts
useAtomSync hookpackages/frontend/url-query-sync/src/hooks/use-atom-sync.ts
useUrlSync hookpackages/frontend/url-query-sync/src/hooks/use-url-sync.ts
Server parserpackages/frontend/url-query-sync/src/parsers/parse-url-params.ts
Search page configapps/frontend/app/[locale]/(default)/search/_config/search-query-config.ts
Search page UIapps/frontend/app/[locale]/(default)/search/search-ui.tsx
Type page configapps/frontend/app/[locale]/(default)/explore/(games)/[game]/[expansion]/[type]/_atoms/query-config.ts
Type page UIapps/frontend/app/[locale]/(default)/explore/(games)/[game]/[expansion]/[type]/type-ui.tsx
Type page V2 contentapps/frontend/app/[locale]/(default)/explore/(games)/[game]/[expansion]/[type]/components/v2/type-ui-content.tsx
Testspackages/frontend/url-query-sync/src/codecs/__tests__/
Last updated on