diff --git a/web/app/providers.tsx b/web/app/providers.tsx
index 5d0ff12..fdaefee 100644
--- a/web/app/providers.tsx
+++ b/web/app/providers.tsx
@@ -2,13 +2,16 @@
import { TRPCProvider } from '@/lib/trpc/client'
import { SessionProvider } from 'next-auth/react'
+import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { Toaster } from '@/components/ui/sonner'
export function Providers({ children }: { children: React.ReactNode }) {
return (
-
- {children}
-
-
+
+
+ {children}
+
+
+
)
}
diff --git a/web/docs/_index.md b/web/docs/_index.md
index 1ad0b22..5dcb314 100644
--- a/web/docs/_index.md
+++ b/web/docs/_index.md
@@ -17,3 +17,4 @@ The `docs` agent reads this index first to locate the right file.
| Object storage (Cloudflare R2) — presign utility, uploadToR2, useUpload hook | [storage.md](storage.md) | `lib/storage.ts`, `lib/useUpload.ts` |
| Authentication (NextAuth v5) — providers, session, proxy, forms, hooks | [auth.md](auth.md) | `auth.ts`, `proxy.ts`, `features/auth/` |
| DataTable component (TanStack Table v8) — sorting, filtering, pagination, column definitions | [data-table.md](data-table.md) | `components/data-table/DataTable.tsx`, `components/data-table/columns.ts`, `components/data-table/index.ts`, `app/demo/page.tsx`, `app/demo/DemoTable.tsx` |
+| URL search-param state (nuqs) — useQueryState, parsers, server cache | [url-state.md](url-state.md) | `app/providers.tsx` |
diff --git a/web/docs/trpc.md b/web/docs/trpc.md
index a7c59bf..21ccd7b 100644
--- a/web/docs/trpc.md
+++ b/web/docs/trpc.md
@@ -1,6 +1,6 @@
---
topic: trpc
-last_verified: 2026-06-24
+last_verified: 2026-06-27
sources:
- server/trpc.ts
- server/routers/_app.ts
@@ -149,18 +149,23 @@ export default async function HealthPage() {
## Provider wiring (`app/providers.tsx` + `app/layout.tsx`)
-`app/providers.tsx` is a thin `'use client'` wrapper. `SessionProvider` (from `next-auth/react`) wraps `TRPCProvider` so both session and React Query contexts are available to all Client Components:
+`app/providers.tsx` is a thin `'use client'` wrapper. From outermost to innermost: `NuqsAdapter` (URL search-param state), `SessionProvider` (next-auth session), `TRPCProvider` (React Query + tRPC). A `Toaster` is rendered as a sibling of `TRPCProvider` inside `SessionProvider`:
```tsx
'use client'
import { TRPCProvider } from '@/lib/trpc/client'
import { SessionProvider } from 'next-auth/react'
+import { NuqsAdapter } from 'nuqs/adapters/next/app'
+import { Toaster } from '@/components/ui/sonner'
export function Providers({ children }: { children: React.ReactNode }) {
return (
-
- {children}
-
+
+
+ {children}
+
+
+
)
}
```
diff --git a/web/docs/url-state.md b/web/docs/url-state.md
new file mode 100644
index 0000000..6bda8aa
--- /dev/null
+++ b/web/docs/url-state.md
@@ -0,0 +1,108 @@
+---
+topic: url-state
+last_verified: 2026-06-27
+sources:
+ - app/providers.tsx
+---
+
+# URL Search-Param State (nuqs)
+
+[nuqs](https://nuqs.47ng.com) synchronises React state with URL search params. It replaces manual `useSearchParams` + `router.push` wiring and works with Next.js App Router out of the box.
+
+## Adapter
+
+`NuqsAdapter` is already mounted as the outermost provider in `app/providers.tsx`. No extra setup is needed.
+
+## Basic usage
+
+```tsx
+'use client'
+import { useQueryState, parseAsInteger } from 'nuqs'
+
+export function PageSizeSelector() {
+ const [pageSize, setPageSize] = useQueryState('pageSize', parseAsInteger.withDefault(20))
+
+ return (
+
+ )
+}
+```
+
+The URL becomes `?pageSize=50`; removing the param resets to the default.
+
+## Multiple params at once
+
+```tsx
+'use client'
+import { useQueryStates, parseAsString, parseAsInteger } from 'nuqs'
+
+const [{ q, page }, setSearch] = useQueryStates({
+ q: parseAsString.withDefault(''),
+ page: parseAsInteger.withDefault(1),
+})
+
+// Merge-update (only touches specified keys):
+setSearch({ page: 2 })
+
+// Full replace:
+setSearch({ q: 'hello', page: 1 })
+```
+
+## Built-in parsers
+
+| Parser | URL value | JS value |
+|---|---|---|
+| `parseAsString` | `?q=hello` | `'hello'` |
+| `parseAsInteger` | `?page=2` | `2` |
+| `parseAsBoolean` | `?open=true` | `true` |
+| `parseAsFloat` | `?price=9.99` | `9.99` |
+| `parseAsIsoDateTime` | `?date=2026-06-27T...` | `Date` |
+| `parseAsArrayOf(parseAsString)` | `?tags=a&tags=b` | `['a', 'b']` |
+| `parseAsJson(zodSchema.parse)` | `?filter=%7B...%7D` | parsed object |
+
+## Server Component access
+
+Server Components receive search params as a prop — no hook needed:
+
+```tsx
+// app/search/page.tsx
+export default async function SearchPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ q?: string; page?: string }>
+}) {
+ const { q = '', page = '1' } = await searchParams
+ // pass to server action / tRPC caller
+}
+```
+
+Use `createSearchParamsCache` from nuqs/server to parse and validate in one step:
+
+```tsx
+import { createSearchParamsCache, parseAsString, parseAsInteger } from 'nuqs/server'
+
+export const searchParamsCache = createSearchParamsCache({
+ q: parseAsString.withDefault(''),
+ page: parseAsInteger.withDefault(1),
+})
+
+export default async function SearchPage({ searchParams }: { searchParams: Promise> }) {
+ const { q, page } = searchParamsCache.parse(await searchParams)
+ // ...
+}
+```
+
+## When to use nuqs vs. plain `useSearchParams`
+
+| Scenario | Use |
+|---|---|
+| Syncing filter/sort/pagination UI with the URL | `useQueryState` / `useQueryStates` |
+| Reading params in a Server Component | `searchParams` prop (or `createSearchParamsCache`) |
+| One-off read-only access in a Client Component | `useSearchParams()` (Next.js built-in) |
+| Programmatic navigation without state binding | `router.push` / `router.replace` |
+
+Prefer nuqs whenever a Client Component both reads and writes a search param — it handles serialisation, defaults, and shallow routing automatically.
diff --git a/web/package.json b/web/package.json
index a394e2a..3eb9558 100644
--- a/web/package.json
+++ b/web/package.json
@@ -28,6 +28,7 @@
"next": "16.2.9",
"next-auth": "5.0.0-beta.31",
"next-themes": "^0.4.6",
+ "nuqs": "^2.8.9",
"radix-ui": "^1.6.0",
"react": "19.2.4",
"react-dom": "19.2.4",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 19fe2de..1511d14 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -53,6 +53,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ nuqs:
+ specifier: ^2.8.9
+ version: 2.8.9(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
radix-ui:
specifier: ^1.6.0
version: 1.6.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -2113,6 +2116,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
+ '@standard-schema/spec@1.0.0':
+ resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
+
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -4462,6 +4468,27 @@ packages:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
+ nuqs@2.8.9:
+ resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==}
+ peerDependencies:
+ '@remix-run/react': '>=2'
+ '@tanstack/react-router': ^1
+ next: '>=14.2.0'
+ react: '>=18.2.0 || ^19.0.0-0'
+ react-router: ^5 || ^6 || ^7
+ react-router-dom: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ '@remix-run/react':
+ optional: true
+ '@tanstack/react-router':
+ optional: true
+ next:
+ optional: true
+ react-router:
+ optional: true
+ react-router-dom:
+ optional: true
+
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
@@ -7762,6 +7789,8 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
+ '@standard-schema/spec@1.0.0': {}
+
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
@@ -10346,6 +10375,13 @@ snapshots:
path-key: 4.0.0
unicorn-magic: 0.3.0
+ nuqs@2.8.9(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@standard-schema/spec': 1.0.0
+ react: 19.2.4
+ optionalDependencies:
+ next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+
oauth4webapi@3.8.6: {}
object-assign@4.1.1: {}