Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e8af136
feat: multi-parsers
TkDodo Sep 13, 2025
db3c11e
chore: increase size limit
TkDodo Sep 13, 2025
2fd2a23
fix: read is too broad for useQueryStates
TkDodo Sep 13, 2025
44820f5
ref: remove read abstraction
TkDodo Sep 13, 2025
d8e26a9
ref: bump size limit some more
TkDodo Sep 13, 2025
bc9786d
test: add some tests for parseAsNativeArray
TkDodo Sep 13, 2025
d622cea
test: native-array e2e tests
TkDodo Sep 13, 2025
6c95153
fix: return null from parsing if everything is unparsable
TkDodo Sep 13, 2025
864e0cd
test: add withDefault([]) to parseAsNativeArrayOf in
TkDodo Sep 14, 2025
c8da7d1
ref: type guard
TkDodo Sep 14, 2025
effb49c
ref: use object.entries over object.keys with an indexed access and a…
TkDodo Sep 14, 2025
ff10737
ref: rename isEmpty to avoid ambiguity
TkDodo Sep 14, 2025
e0021b1
fix: compareQuery for iterables
TkDodo Sep 14, 2025
cc89e20
fix: explicitly set searchParam to empty string if we get an empty it…
TkDodo Sep 14, 2025
27a354a
test: fix wrong parser assumptions
TkDodo Sep 14, 2025
c38d780
fix: switch to comparing all values in key-isolation
TkDodo Sep 14, 2025
c1f2ef9
Merge branch 'next' into feature/multi-parsers
TkDodo Sep 14, 2025
6f01b4d
fix: defensive check for standard schema
TkDodo Sep 14, 2025
ef68422
Update packages/nuqs/src/lib/search-params.ts
TkDodo Sep 15, 2025
8e4ebf4
feat: add .withDefault([]) to parseAsNativeArrayOf
TkDodo Sep 19, 2025
0be1405
ref: rename defaultValue to fallbackValue
TkDodo Sep 19, 2025
0411386
chore: leave a comment about the special empty value set
TkDodo Sep 19, 2025
c0b9677
Merge branch 'next' into feature/multi-parsers
TkDodo Sep 19, 2025
f036840
fix: types for parseAsNativeArray
TkDodo Sep 19, 2025
f5bf1b8
test: compare tests
TkDodo Sep 24, 2025
6b14b75
fix: keep backwards compatibility for Parser / ParserBuilder
TkDodo Sep 24, 2025
8fcd61b
doc: parseAsNativeArrayOf
TkDodo Sep 24, 2025
976aeb9
ref: move away from Iterables towards Arrays
TkDodo Sep 24, 2025
79f4249
fix: compare tests
TkDodo Sep 24, 2025
660af9d
doc: createMultiParser demo
TkDodo Sep 25, 2025
0622d04
Update packages/docs/content/docs/parsers/built-in.mdx
TkDodo Sep 25, 2025
4bf2ea2
doc: align clear button
TkDodo Sep 25, 2025
7013a5a
fix: NaN
TkDodo Sep 25, 2025
9f42bfc
doc: show values
TkDodo Sep 25, 2025
b0a2277
doc: simplify example
TkDodo Sep 25, 2025
fb185ce
doc: styles & wording for native arrays section & demo
franky47 Sep 26, 2025
11d8951
doc: add block about equality function
franky47 Sep 26, 2025
c9060e6
doc: custom multiparsers wording & demo
franky47 Sep 26, 2025
dfd874f
ref: rename QueryParam type to Query
franky47 Sep 26, 2025
551131b
ref: import type
franky47 Sep 26, 2025
f149875
feat: handle multi parsers in bijectivity testing helpers
franky47 Sep 26, 2025
875beaf
doc: mark MultiParser's parseServerSide as deprecated
franky47 Sep 26, 2025
805ed6a
chore: allow a bit more headroom on the server bundle
franky47 Sep 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion packages/docs/content/docs/parsers/built-in.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
DateISOParserDemo,
DatetimeISOParserDemo,
DateTimestampParserDemo,
JsonParserDemo
JsonParserDemo,
NativeArrayParserDemo
} from '@/content/docs/parsers/demos'

Search params are strings by default, but chances are your state is more complex than that.
Expand Down Expand Up @@ -322,6 +323,37 @@ parseAsJson(userSchema.validateSync)
return `null{:ts}` for invalid data. Only **synchronous** validation is supported.
</Callout>

## Native Arrays

<FeatureSupportMatrix introducedInVersion='2.7.0' />

If you want to use the native URL format for arrays, repeating the same key multiple times like:

```
/products?tag=books&tag=tech&tag=design
```

you can now use `MultiParsers` like `parseAsNativeArrayOf` to read and write those values in a fully type-safe way.

```tsx
import { useQueryState, parseAsNativeArrayOf, parseAsInteger } from 'nuqs'

const [projectIds, setProjectIds] = useQueryState(
'project',
parseAsNativeArrayOf(parseAsInteger)
)

// ?project=123&project=456 ==> [123, 456]
```

<Suspense fallback={<DemoFallback />}>
<NativeArrayParserDemo />
</Suspense>

<Callout title="Empty Array Default">
Note that `parseAsNativeArrayOf` has a built-in default value of empty array (`.withDefault([])`) so that you don't have to handle `null` cases.
</Callout>

## Using parsers server-side

For shared code that may be imported in the Next.js app router, you should import
Expand Down
209 changes: 209 additions & 0 deletions packages/docs/content/docs/parsers/demos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ChevronDown, ChevronUp, Minus, Star } from 'lucide-react'
import {
ParserBuilder,
createParser,
createMultiParser,
parseAsBoolean,
parseAsFloat,
parseAsHex,
Expand All @@ -27,8 +28,12 @@ import {
parseAsIsoDate,
parseAsIsoDateTime,
parseAsJson,
parseAsArrayOf,
parseAsNativeArrayOf,
parseAsString,
parseAsStringLiteral,
parseAsTimestamp,
SingleParser,
useQueryState
} from 'nuqs'
import React from 'react'
Expand Down Expand Up @@ -465,6 +470,210 @@ export function CustomParserDemo() {
)
}

export function NativeArrayParserDemo() {
const [value, setValue] = useQueryState(
'nativeArray',
parseAsNativeArrayOf(parseAsInteger)
)
return (
<DemoContainer demoKey="nativeArray">
<Button
onClick={() => setValue(prev => prev.concat(Math.floor(Math.random() * 500) + 1))}
>
Add random number
</Button>
<Button
onClick={() => setValue(prev => prev.slice(0, -1))}
>
Remove last number
</Button>
<Button
variant="secondary"
onClick={() => setValue([])}
className="ml-auto"
>
Clear
</Button>
<div className="w-full text-sm text-zinc-500">
Current value: [{value.join(', ') || ''}]
</div>
</DemoContainer>
)
}

export function CustomMultiParserDemo() {

const parseAsFromTo = createParser({
parse: value => {
const [min = null, max = null] = value.split('~').map(parseAsInteger.parse)
if (min === null) return null
if (max === null) return { eq: min}
return { gte: min, lte: max }
},
serialize: value => {
return value.eq !== undefined ? String(value.eq) : `${value.gte}~${value.lte}`
}
})

const parseAsKeyValue = createParser({
parse: value => {
const [key, val] = value.split(':')
if (!key || !val) return null
return { key, value: val}
},
serialize: value => {
return `${value.key}:${value.value}`
}
})

const parseAsFilters = <TItem extends {}>(itemParser: SingleParser<TItem>) => {
return createMultiParser({
parse: values => {
const keyValue = values.map(parseAsKeyValue.parse).filter(v => v !== null)

const result = Object.fromEntries(
keyValue.flatMap(({ key, value }) => {
const parsedValue: TItem | null = itemParser.parse(value)
return parsedValue === null ? [] : [[key, parsedValue]]
})
)

return Object.keys(result).length === 0 ? null : result
},
serialize: values => {
return Object.entries(values).map(([key, value]) => {
if (!itemParser.serialize) return null
return parseAsKeyValue.serialize({ key, value: itemParser.serialize(value) })
}).filter(v => v !== null)
}
})
}

const [filters, setFilters] = useQueryState(
'filters',
parseAsFilters(parseAsFromTo).withDefault({})
)

return (
<DemoContainer demoKey="filters">
<div>
<label
className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Rating:
</label>
<input
type="number"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
value={filters.rating?.eq ?? ''}
onChange={e => {
setFilters(prev => ({...prev, rating: { eq: e.target.valueAsNumber }}))
}}
autoComplete="off"
/>
</div>
<div>
<label
className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Price From:
</label>
<input
type="number"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
value={filters.price?.gte ?? 0}
onChange={e => {
setFilters(prev => ({...prev, price: { lte: prev.price?.lte ?? 0, gte: e.target.value === '' ? 0 : e.target.valueAsNumber }}))
}}
autoComplete="off"
/>
</div>
<div>
<label
className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Price To:
</label>
<input
type="number"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
value={filters.price?.lte ?? 0}
onChange={e => {
setFilters(prev => ({...prev, price: { gte: prev.price?.gte ?? 0, lte: e.target.value === '' ? 0 : e.target.valueAsNumber }}))
}}
autoComplete="off"
/>
</div>
<Button
variant="secondary"
onClick={() => setFilters(null)}
className="ml-auto mt-auto"
>
Clear
</Button>
</DemoContainer>
)

return (
<DemoContainer demoKey="filters">
{
Object.entries(filters).map(([key, value]) => {
if (value.eq !== undefined) {
return (
<div key={key}>
<label
className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{key}:{' '}
</label>
<input
key={key}
type="number"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
value={value.eq}
onChange={e => {
setFilters(prev => ({...prev, [key]: { eq: e.target.valueAsNumber }}))
}}
placeholder="What's your favourite number?"
autoComplete="off"
/>
</div>
)
}
return (
<div key={key}>
<label
className="text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{key}:{' '}
</label>
<input
key={key}
type="number"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 flex-1 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
value={value.eq}
onChange={e => {
setFilters(prev => ({...prev, [key]: { eq: e.target.valueAsNumber }}))
}}
placeholder="What's your favourite number?"
autoComplete="off"
/>
</div>
)
})
}
<Button
variant="secondary"
onClick={() => setFilters(null)}
className="ml-auto"
>
Clear
</Button>
</DemoContainer>
)
}


type StarButtonProps = Omit<React.ComponentProps<typeof Button>, 'value'> & {
index: Rating
value: Rating | null
Expand Down
67 changes: 66 additions & 1 deletion packages/docs/content/docs/parsers/making-your-own.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ description: Making your own parsers for custom data types & pretty URLs
---

import {
CustomParserDemo
CustomParserDemo,
CustomMultiParserDemo
} from '@/content/docs/parsers/demos'

You may wish to customise the rendered query string for your data type.
Expand Down Expand Up @@ -35,6 +36,70 @@ const parseAsStarRating = createParser({
<CustomParserDemo/>
</Suspense>

## Custom Multi Parsers

`MultiParsers` work similar to `SingleParsers`, except that they operate on Arrays. That means:

1. `parse` takes an `Array<string>`. It receives all matching values of the key it operates on, and returns the parsed value, or `null{:ts}` if invalid.
2. `serialize` takes the parsed value and returns an `Array<string>`, where each item will be separately added to the URL.

```tsx
const parseAsFromTo = createParser({
parse: value => {
const [min = null, max = null] = value.split('~').map(parseAsInteger.parse)
if (min === null) return null
if (max === null) return { eq: min}
return { gte: min, lte: max }
},
serialize: value => {
return value.eq !== undefined ? String(value.eq) : `${value.gte}~${value.lte}`
}
})

const parseAsKeyValue = createParser({
parse: value => {
const [key, val] = value.split(':')
if (!key || !val) return null
return { key, value: val}
},
serialize: value => {
return `${value.key}:${value.value}`
}
})

const parseAsFilters = <TItem extends {}>(itemParser: SingleParser<TItem>) => {
return createMultiParser({
parse: values => {
const keyValue = values.map(parseAsKeyValue.parse).filter(v => v !== null)

const result = Object.fromEntries(
keyValue.flatMap(({ key, value }) => {
const parsedValue: TItem | null = itemParser.parse(value)
return parsedValue === null ? [] : [[key, parsedValue]]
})
)

return Object.keys(result).length === 0 ? null : result
},
serialize: values => {
return Object.entries(values).map(([key, value]) => {
if (!itemParser.serialize) return null
return parseAsKeyValue.serialize({ key, value: itemParser.serialize(value) })
}).filter(v => v !== null)
}
})
}

const [filters, setFilters] = useQueryState(
'filters',
parseAsFilters(parseAsFromTo).withDefault({})
)
```

<Suspense>
<CustomMultiParserDemo/>
</Suspense>

## Caveat: lossy serializers

If your serializer loses precision or doesn't accurately represent
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/components/querystring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function filterQueryKeys(query: string | URLSearchParams, keys?: string[]) {
const destination = new URLSearchParams()
for (const [key, value] of source.entries()) {
if (keys.includes(key)) {
destination.set(key, value)
destination.append(key, value)
}
}
return destination
Expand Down
10 changes: 10 additions & 0 deletions packages/e2e/next/src/app/app/(shared)/native-array/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NativeArray } from 'e2e-shared/specs/native-array'
import { Suspense } from 'react'

export default function Page() {
return (
<Suspense>
<NativeArray />
</Suspense>
)
}
3 changes: 3 additions & 0 deletions packages/e2e/next/src/pages/pages/native-array.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { NativeArray } from 'e2e-shared/specs/native-array'

export default NativeArray
1 change: 1 addition & 0 deletions packages/e2e/react-router/v6/src/react-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const router = createBrowserRouter(
<Route path="linking/useQueryState/other" lazy={load(import('./routes/linking.useQueryState.other'))} />
<Route path="linking/useQueryStates" lazy={load(import('./routes/linking.useQueryStates'))} />
<Route path="linking/useQueryStates/other" lazy={load(import('./routes/linking.useQueryStates.other'))} />
<Route path="native-array" lazy={load(import('./routes/native-array'))} />
<Route path="pretty-urls" lazy={load(import('./routes/pretty-urls'))} />
<Route path="referential-stability/useQueryState" lazy={load(import('./routes/referential-stability.useQueryState'))} />
<Route path="referential-stability/useQueryStates" lazy={load(import('./routes/referential-stability.useQueryStates'))} />
Expand Down
3 changes: 3 additions & 0 deletions packages/e2e/react-router/v6/src/routes/native-array.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { NativeArray } from 'e2e-shared/specs/native-array'

export default NativeArray
Loading