Skip to content

Commit b929682

Browse files
authored
feat: Add Standard Schema interface (#965)
1 parent 63236f9 commit b929682

39 files changed

+1688
-157
lines changed

CONTRIBUTING.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,20 @@ This monorepo contains:
1515
- The source code for the `nuqs` NPM package, in [`packages/nuqs`](./packages/nuqs).
1616
- A Next.js app under [`packages/docs`](./packages/docs) that serves the documentation and as a playground deployed at <https://nuqs.47ng.com>
1717
- Test benches for [end-to-end tests](./packages/e2e) for each supported framework, driven by Cypress
18+
- Examples of integration with other tools.
1819

1920
When running `next dev`, this will:
2021

2122
- Build the library and watch for changes using [`tsup`](https://tsup.egoist.dev/)
2223
- Start the docs app, which will be available at <http://localhost:3000>.
2324
- Start the end-to-end test benches:
24-
- http://localhost:3001 - Next.js
25-
- http://localhost:3002 - React SPA
26-
- http://localhost:3003 - Remix
27-
- http://localhost:3006 - React Router v6
28-
- http://localhost:3007 - React Router v7
25+
- http://localhost:3001 - [Next.js](./packages/e2e/next)
26+
- http://localhost:3002 - [React SPA](./packages/e2e/react)
27+
- http://localhost:3003 - [Remix](./packages/e2e/remix)
28+
- http://localhost:3006 - [React Router v6](./packages/e2e/react-router/v6)
29+
- http://localhost:3007 - [React Router v7](./packages/e2e/react-router/v7)
2930
- Start the examples:
31+
- http://localhost:4000 - [tRPC](./packages/examples/trpc)
3032
- http://localhost:4001 - [Next.js - App router](./packages/examples/next-app)
3133

3234
## Testing

README.md

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -285,45 +285,6 @@ export default () => {
285285
}
286286
```
287287

288-
### Using parsers in Server Components
289-
290-
> Note: see the [Accessing searchParams in server components](#accessing-searchparams-in-server-components)
291-
> section for a more user-friendly way to achieve type-safety.
292-
293-
If you wish to parse the searchParams in server components, you'll need to
294-
import the parsers from `nuqs/server`, which doesn't include
295-
the `"use client"` directive.
296-
297-
You can then use the `parseServerSide` method:
298-
299-
```tsx
300-
import { parseAsInteger } from 'nuqs/server'
301-
302-
type PageProps = {
303-
searchParams: {
304-
counter?: string | string[]
305-
}
306-
}
307-
308-
const counterParser = parseAsInteger.withDefault(1)
309-
310-
export default function ServerPage({ searchParams }: PageProps) {
311-
const counter = counterParser.parseServerSide(searchParams.counter)
312-
console.log('Server side counter: %d', counter)
313-
return (
314-
...
315-
)
316-
}
317-
```
318-
319-
See the [server-side parsing demo](<./packages/docs/src/app/playground/(demos)/pagination>)
320-
for a live example showing how to reuse parser configurations between
321-
client and server code.
322-
323-
> Note: parsers **don't validate** your data. If you expect positive integers
324-
> or JSON-encoded objects of a particular shape, you'll need to feed the result
325-
> of the parser to a schema validation library, like [Zod](https://zod.dev).
326-
327288
## Default value
328289

329290
When the query string is not present in the URL, the default behaviour is to
@@ -642,6 +603,10 @@ const { q, page } = loadSearchParams('?q=hello&page=2')
642603

643604
It accepts various types of inputs (strings, URL, URLSearchParams, Request, Promises, etc.). [Read more](https://nuqs.47ng.com/docs/server-side#loaders)
644605

606+
See the [server-side parsing demo](<./packages/docs/src/app/playground/(demos)/pagination>)
607+
for a live example showing how to reuse parser configurations between
608+
client and server code.
609+
645610
## Accessing searchParams in Server Components
646611

647612
If you wish to access the searchParams in a deeply nested Server Component
@@ -930,25 +895,62 @@ export const metadata: Metadata = {
930895
If however the query string is defining what content the page is displaying
931896
(eg: YouTube's watch URLs, like `https://www.youtube.com/watch?v=dQw4w9WgXcQ`),
932897
your canonical URL should contain relevant query strings, and you can still
933-
use `useQueryState` to read it:
898+
use your parsers to read it, and to serialize the canonical URL:
934899

935900
```ts
936901
// page.tsx
937902
import type { Metadata, ResolvingMetadata } from 'next'
938-
import { useQueryState } from 'nuqs'
939-
import { parseAsString } from 'nuqs/server'
903+
import { notFound } from 'next/navigation'
904+
import {
905+
createParser,
906+
parseAsString,
907+
createLoader,
908+
createSerializer,
909+
type SearchParams,
910+
type UrlKeys
911+
} from 'nuqs/server'
912+
913+
const youTubeVideoIdRegex = /^[^"&?\/\s]{11}$/i
914+
const youTubeSearchParams = {
915+
videoId: createParser({
916+
parse(query) {
917+
if (!youTubeVideoIdRegex.test(query)) {
918+
return null
919+
}
920+
return query
921+
},
922+
serialize(videoId) {
923+
return videoId
924+
}
925+
})
926+
}
927+
const youTubeUrlKeys: UrlKeys<typeof youTubeSearchParams> = {
928+
videoId: 'v'
929+
}
930+
const loadYouTubeSearchParams = createLoader(youTubeSearchParams, {
931+
urlKeys: youTubeUrlKeys
932+
})
933+
const serializeYouTubeSearchParams = createSerializer(youTubeSearchParams, {
934+
urlKeys: youTubeUrlKeys
935+
})
936+
937+
// --
940938

941939
type Props = {
942-
searchParams: { [key: string]: string | string[] | undefined }
940+
searchParams: Promise<SearchParams>
943941
}
944942

945943
export async function generateMetadata({
946944
searchParams
947945
}: Props): Promise<Metadata> {
948-
const videoId = parseAsString.parseServerSide(searchParams.v)
946+
const { videoId } = await loadYouTubeSearchParams(searchParams)
947+
if (!videoId) {
948+
notFound()
949+
}
949950
return {
950951
alternates: {
951-
canonical: `/watch?v=${videoId}`
952+
canonical: serializeYouTubeSearchParams('/watch', { videoId })
953+
// /watch?v=dQw4w9WgXcQ
952954
}
953955
}
954956
}

packages/docs/content/docs/parsers/built-in.mdx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -253,10 +253,11 @@ parseAsArrayOf(parseAsInteger, ';')
253253

254254
If primitive types are not enough, you can encode JSON in the query string.
255255

256-
Pass it a validation function to validate and infer the type of the parsed data,
257-
like a [Zod](https://zod.dev) schema:
256+
Pass it a [Standard Schema](https://standardschema.dev) (eg: a Zod schema)
257+
to validate and infer the type of the parsed data:
258258

259259
```ts
260+
// [!code word:parseAsJson]
260261
import { parseAsJson } from 'nuqs'
261262
import { z } from 'zod'
262263

@@ -266,10 +267,9 @@ const schema = z.object({
266267
worksWith: z.array(z.string())
267268
})
268269

269-
// This parser is a function, don't forget to call it with the parse function
270-
// as an argument.
271-
// [!code word:parseAsJson()]
272-
const [json, setJson] = useQueryState('json', parseAsJson(schema.parse))
270+
// This parser is a function, don't forget to call it
271+
// with your schema as an argument.
272+
const [json, setJson] = useQueryState('json', parseAsJson(schema))
273273

274274
setJson({
275275
pkg: 'nuqs',
@@ -282,8 +282,25 @@ setJson({
282282
<JsonParserDemo />
283283
</Suspense>
284284

285-
Using other validation libraries is possible, as long as they throw an error
286-
when the data is invalid (eg: Valibot, Yup, etc).
285+
Using other validation libraries is possible: `parseAsJson{:ts}` accepts
286+
any Standard Schema compatible input (eg: ArkType, Valibot),
287+
or a custom validation function (eg: Yup, Joi, etc):
288+
289+
```ts
290+
import { object, string, number } from 'yup';
291+
292+
let userSchema = object({
293+
name: string().required(),
294+
age: number().required().positive().integer(),
295+
});
296+
297+
parseAsJson(userSchema.validateSync)
298+
```
299+
300+
<Callout title="Note">
301+
Validation functions must either throw an error or
302+
return `null{:ts}` for invalid data. Only **synchronous** validation is supported.
303+
</Callout>
287304

288305
## Using parsers server-side
289306

packages/docs/content/docs/parsers/demos.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -388,10 +388,7 @@ const jsonParserSchema = z.object({
388388
})
389389

390390
export function JsonParserDemo() {
391-
const [value, setValue] = useQueryState(
392-
'json',
393-
parseAsJson(jsonParserSchema.parse)
394-
)
391+
const [value, setValue] = useQueryState('json', parseAsJson(jsonParserSchema))
395392
return (
396393
<DemoContainer demoKey="json" className="items-start">
397394
<pre className="flex-1 rounded-md border bg-background p-2 text-sm text-zinc-500">

packages/docs/content/docs/seo.mdx

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,42 +22,67 @@ export const metadata: Metadata = {
2222
If however the query string is defining what content the page is displaying
2323
(eg: YouTube's watch URLs, like `https://www.youtube.com/watch?v=dQw4w9WgXcQ`),
2424
your canonical URL should contain relevant query strings, and you can still
25-
use your parsers to read it:
25+
use your parsers to read it, and to serialize the canonical URL.
2626

2727
```ts title="/app/watch/page.tsx"
2828
import type { Metadata, ResolvingMetadata } from 'next'
2929
import { notFound } from "next/navigation";
30-
import { createParser, parseAsString, type SearchParams } from 'nuqs/server'
30+
import {
31+
createParser,
32+
parseAsString,
33+
createLoader,
34+
createSerializer,
35+
type SearchParams,
36+
type UrlKeys
37+
} from 'nuqs/server'
3138

32-
type Props = {
33-
searchParams: Promise<SearchParams>
34-
}
35-
36-
// Normally you'd reuse custom parsers across your application,
37-
// but for this example we'll define it here.
3839
const youTubeVideoIdRegex = /^[^"&?\/\s]{11}$/i
39-
const parseAsYouTubeVideoId = createParser({
40-
parse(query) {
41-
if (!youTubeVideoIdRegex.test(query)) {
42-
return null
40+
const youTubeSearchParams = {
41+
videoId: createParser({
42+
parse(query) {
43+
if (!youTubeVideoIdRegex.test(query)) {
44+
return null
45+
}
46+
return query
47+
},
48+
serialize(videoId) {
49+
return videoId
4350
}
44-
return query
45-
},
46-
serialize(videoId) {
47-
return videoId
51+
})
52+
}
53+
const youTubeUrlKeys: UrlKeys<typeof youTubeSearchParams> = {
54+
videoId: 'v'
55+
}
56+
const loadYouTubeSearchParams = createLoader(
57+
youTubeSearchParams,
58+
{
59+
urlKeys: youTubeUrlKeys
4860
}
49-
})
61+
)
62+
const serializeYouTubeSearchParams = createSerializer(
63+
youTubeSearchParams,
64+
{
65+
urlKeys: youTubeUrlKeys
66+
}
67+
)
68+
69+
// --
70+
71+
type Props = {
72+
searchParams: Promise<SearchParams>
73+
}
5074

5175
export async function generateMetadata({
5276
searchParams
5377
}: Props): Promise<Metadata> {
54-
const videoId = parseAsYouTubeVideoId.parseServerSide((await searchParams).v)
78+
const { videoId } = await loadYouTubeSearchParams(searchParams)
5579
if (!videoId) {
5680
notFound()
5781
}
5882
return {
5983
alternates: {
60-
canonical: `/watch?v=${videoId}`
84+
canonical: serializeYouTubeSearchParams('/watch', { videoId })
85+
// /watch?v=dQw4w9WgXcQ
6186
}
6287
}
6388
}

packages/docs/content/docs/server-side.mdx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,26 @@ The loader function will accept the following input types to parse search params
121121
- A `Record<string, string | string[] | undefined>{:ts}` (eg: `{ foo: 'bar' }{:ts}`)
122122
- A `Promise{:ts}` of any of the above, in which case it also returns a Promise.
123123

124+
### Strict mode
125+
126+
If a search param contains an invalid value for the associated parser (eg: `?count=banana` for `parseAsInteger{:ts}`),
127+
the default behaviour is to return the [default value](/docs/basic-usage#default-values) if specified, or `null{:ts}` otherwise.
128+
129+
You can turn on **strict mode** to instead throw an error on invalid values when running the loader:
130+
131+
```ts
132+
const loadSearchParams = createLoader({
133+
count: parseAsInteger.withDefault(0)
134+
})
135+
136+
// Default: will return { count: 0 }
137+
loadSearchParams('?count=banana')
138+
139+
// Strict mode: will throw an error
140+
loadSearchParams('?count=banana', { strict: true })
141+
// [nuqs] Error while parsing query `banana` for key `count`
142+
```
143+
124144
## Cache
125145

126146
<Callout>
@@ -202,7 +222,8 @@ import { Server } from './server'
202222
import { Client } from './client'
203223

204224
export default async function Page({ searchParams }) {
205-
await coordinatesCache.parse(searchParams)
225+
// Note: you can also use strict mode here:
226+
await coordinatesCache.parse(searchParams, { strict: true })
206227
return (
207228
<>
208229
<Server />

0 commit comments

Comments
 (0)