URL Query Sync
| Package | @repo/url-query-sync |
| Location | packages/frontend/url-query-sync/ |
| Pattern | Schema-driven URL ↔ state synchronization |
| Built on | nuqs , 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:
- Persist across navigation — filters, sort, pagination survive page refreshes and browser back/forward
- Be shareable — copy a URL, send it to someone, they see the same state
- Drive API calls — the URL state maps directly to API input parameters
- Hydrate UI atoms — form fields, Jotai atoms, and sidebar state need to reflect the URL on first render
- 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 likepage,limit,sort,direction,q,layout. Each has a fixed URL key and an optional default value. - Dynamic filters — Derived from game
Attributesloaded at runtime. Things likegame,rarity,priceEu,finish. Their URL keys are the attribute slugs, and their parser is selected based onattribute.type.
Core API: useQuerySync
useQuerySync is the primary public API for URL state management. It composes two internal hooks:
useUrlSync— Pure URL ↔ nuqs bridge. Parses URL, returns typed state + setters.useAtomSync— All Jotai atom synchronization: mount-time hydration, continuous URL → atom sync, and reverse atom → URL sync forsync: trueparams.
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 stateWhat useAtomSync handles automatically
When you use useQuerySync, the atom sync layer handles three concerns:
- Mount-time hydration — Uses
useHydrateAtomsto synchronously set atom/fieldAtom values during the first render. ForfieldAtom, it hydrates both thevalueand_initialValuesub-atoms so forms treat the URL value as the initial value. - Continuous URL → atom sync — A
useEffectthat keeps atoms in sync when URL changes after mount (e.g., browser back/forward, programmatic setParam). - Reverse atom → URL sync — For params with
sync: true, subscribes to the atom viastore.suband 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
urlKeyis the actual string in the URL. The object key (e.g.,q,page) is the name used in code. uiOnly: trueexcludes the param frommapToInput’sparamsargument. Use for layout, zoom — things the API doesn’t care about.transformconverts the URL-parsed value into the shape your atom/fieldAtom expects. Runs during hydration and on every URL change.mapToInputis 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:
CustomQueryErrorResetBoundary— error boundaryJotaiQueryProvider— Jotai store + React Query clientUrlSyncProvider— 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.tsxHow 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:
qparam — Bound totextSearchFieldAtomwith transformvalue ?? ""(never null)pageparam — 1-indexed in URL, transformed to 0-indexed forcurrentPageAtomlimitparam — Number in URL, transformed to string foritemsPerPageFieldAtomsortparam — Transform depends on other params (params.q ? "best-match" : "name")layoutparam —uiOnly: trueso it’s excluded from API inputfilterAtom— Sidebar filters are hydrated with parsed URL filtersmapToInput— RoutesgameandproductTypefilters specially, passes the rest through
Data Flow Walk-Through
- User navigates to
/search?q=charizard&page=2&game=mtg|pokemon&priceEu=10-100 page.tsx(server) awaitssearchParamsand passes{ q: "charizard", page: "2", game: "mtg|pokemon", priceEu: "10-100" }toSearchUIUrlSyncProviderwraps the nuqs adapteruseQuerySynccallsuseUrlSync(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 } }
- Builds parser map:
useAtomSynchydrates: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: ... }
urlStateis passed as props toSearchUIContent- Components read from their atoms and use
setParam/setFiltersfor 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 Pattern | Inferred Parser |
|---|---|
0-100, 10.5-, -50 | range |
gt:100, lt:50, eq:42 | number |
true, false | boolean |
exists, not-exists | existence |
| Everything else | enum (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.jssearchParamsformatURLSearchParams— Standard web API
Param Behavior Reference
| Behavior | Description |
|---|---|
| Default elision | When setParam("limit", 24) and default is 24, the param is removed from URL |
| Page auto-reset | setFilter, setFilters, clearFilters, and setParam for data params (sort, direction, limit, q) auto-reset page to null (page 1) |
| No page reset | setParam("layout", ...), setParam("zoom", ...), setParam("page", ...), setParam("group", ...) do NOT reset page |
| Parser normalization | setParam runs the value through the param’s parser before writing, ensuring correct types (e.g., string "24" → number 24 for limit) |
| Empty filter cleanup | setFilter 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:
| Parser | Type | Use Case |
|---|---|---|
booleanParamParser | boolean | Toggles like hideOutOfStock |
stringParamParser | string | Generic 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])Sidebar Filters and Non-Sidebar Filters
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 gridIntegration 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 fromConfigurationsto set game-specific defaults for sort, direction, layout, and limit booleanParamParser—hideOutOfStockuses a custom boolean codec (?hideOutOfStock=true)stringParamParserfor mode —modeparam (?mode=discovery) uses the generic string parsertransformas default provider — Every param’stransformreturns a non-null fallback when the URL value is absent, ensuring the atom sync always writes to the atom- Dynamic
defaultfor URL elision —layoutandlimithave game-specificdefaultvalues so Cyberpunk’s?layout=galleryis elided but non-Cyberpunk’s?layout=galleryis shown
Key Patterns Used
| Pattern | Where | Why |
|---|---|---|
| Factory config | query-config.ts | Game-specific defaults for sort/layout/limit |
useQuerySync hook | type-ui.tsx | Single entry point for URL ↔ atom sync |
useHydrateAtoms for page atoms | type-ui.tsx | Hydrate gameSlug, expansionSlug, productType before useQuerySync |
useHydrateAtoms for sidebar | TypeUIContentV1 | Bridge page’s sidebarFiltersAtom from filters prop |
setParam callbacks | TypeUIContentV2 | Route hideOutOfStock and mode changes to URL params |
onChange callback props | ListingsQuickFilters, ListingsSidebarFilters | Route toggle/select changes to setParam instead of writing atoms directly |
Checklist for New Pages
- Create
_atoms/query-config.tswithdefineQueryConfig()(or a factory function if defaults are dynamic) - Create
_atoms/ui.tswithsidebarFiltersAtom(if using filters) - Define the
mapToInputfunction that matches your API endpoint’s input shape - Wrap your page in
CustomQueryErrorResetBoundary>JotaiQueryProvider>UrlSyncProvider - Call
useQuerySync(config, attributes, searchParams)inside theUrlSyncProvider - Pass
searchParamsfrom the server component page - Type your
setParamwithSetParamFn<typeof myParams> - Handle pagination with 0-indexed ↔ 1-indexed conversion
- If the page has its own
sidebarFiltersAtom, adduseHydrateAtoms([[sidebarFiltersAtom, filters]])in the content component - For custom param types (boolean, enum), use the
parserfield and pass correctly typed values tosetParam(do NOT stringify) - Use
clearFilterParamsin the config for params that should be cleared withclearFilters() - 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