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: {}