nuqs
Type-safe search params state manager for React frameworks. Like useState
, but stored in the URL query string.
Features
- 🔀 new: Supports Next.js (
app
and pages
routers), plain React (SPA), Remix, React Router, and custom routers via adapters - 🧘♀️ Simple: the URL is the source of truth
- 🕰 Replace history or append to use the Back button to navigate state updates
- ⚡️ Built-in parsers for common state types (integer, float, boolean, Date, and more). Create your own parsers for custom types & pretty URLs
- ♊️ Related querystrings with
useQueryStates
- 📡 Shallow mode by default for URL query updates, opt-in to notify server components
- 🗃 Server cache for type-safe searchParams access in nested server components
- ⌛️ Support for
useTransition
to get loading states on server updates
Documentation
Read the complete documentation at nuqs.47ng.com.
Installation
pnpm add nuqs
yarn add nuqs
npm install nuqs
Adapters
You will need to wrap your React component tree with an adapter for your framework. (expand the appropriate section below)
▲ Next.js (app router)
Supported Next.js versions: >=14.2.0
. For older versions, install nuqs@^1
(which doesn't need this adapter code).
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { type ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html>
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
)
}
▲ Next.js (pages router)
Supported Next.js versions: >=14.2.0
. For older versions, install nuqs@^1
(which doesn't need this adapter code).
import type { AppProps } from 'next/app'
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<NuqsAdapter>
<Component {...pageProps} />
</NuqsAdapter>
)
}
⚛️ Plain React (SPA)
Example: via Vite or create-react-app.
import { NuqsAdapter } from 'nuqs/adapters/react'
createRoot(document.getElementById('root')!).render(
<NuqsAdapter>
<App />
</NuqsAdapter>
)
💿 Remix
Supported Remix versions: @remix-run/react@>=2
import { NuqsAdapter } from 'nuqs/adapters/remix'
export default function App() {
return (
<NuqsAdapter>
<Outlet />
</NuqsAdapter>
)
}
React Router
Supported React Router versions: react-router-dom@>=6
import { NuqsAdapter } from 'nuqs/adapters/react-router'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from './App'
const router = createBrowserRouter([
{
path: '/',
element: <App />
}
])
export function ReactRouter() {
return (
<NuqsAdapter>
<RouterProvider router={router} />
</NuqsAdapter>
)
}
Usage
'use client'
import { useQueryState } from 'nuqs'
export default () => {
const [name, setName] = useQueryState('name')
return (
<>
<h1>Hello, {name || 'anonymous visitor'}!</h1>
<input value={name || ''} onChange={e => setName(e.target.value)} />
<button onClick={() => setName(null)}>Clear</button>
</>
)
}
useQueryState
takes one required argument: the key to use in the query string.
Like React.useState
, it returns an array with the value present in the query
string as a string (or null
if none was found), and a state updater function.
Example outputs for our hello world example:
URL | name value | Notes |
---|
/ | null | No name key in URL |
/?name= | '' | Empty string |
/?name=foo | 'foo' | |
/?name=2 | '2' | Always returns a string by default, see Parsing below |
Parsing
If your state type is not a string, you must pass a parsing function in the
second argument object.
We provide parsers for common and more advanced object types:
import {
parseAsString,
parseAsInteger,
parseAsFloat,
parseAsBoolean,
parseAsTimestamp,
parseAsIsoDateTime,
parseAsArrayOf,
parseAsJson,
parseAsStringEnum,
parseAsStringLiteral,
parseAsNumberLiteral
} from 'nuqs'
useQueryState('tag')
useQueryState('count', parseAsInteger)
useQueryState('brightness', parseAsFloat)
useQueryState('darkMode', parseAsBoolean)
useQueryState('after', parseAsTimestamp)
useQueryState('date', parseAsIsoDateTime)
useQueryState('array', parseAsArrayOf(parseAsInteger))
useQueryState('json', parseAsJson<Point>())
enum Direction {
up = 'UP',
down = 'DOWN',
left = 'LEFT',
right = 'RIGHT'
}
const [direction, setDirection] = useQueryState(
'direction',
parseAsStringEnum<Direction>(Object.values(Direction))
.withDefault(Direction.up)
)
const colors = ['red', 'green', 'blue'] as const
const [color, setColor] = useQueryState(
'color',
parseAsStringLiteral(colors)
.withDefault('red')
)
const diceSides = [1, 2, 3, 4, 5, 6] as const
const [side, setSide] = useQueryState(
'side',
parseAsNumberLiteral(diceSides)
.withDefault(4)
)
You may pass a custom set of parse
and serialize
functions:
import { useQueryState } from 'nuqs'
export default () => {
const [hex, setHex] = useQueryState('hex', {
parse: (query: string) => parseInt(query, 16),
serialize: value => value.toString(16)
})
}
Using parsers in Server Components
Note: see the Accessing searchParams in server components
section for a more user-friendly way to achieve type-safety.
If you wish to parse the searchParams in server components, you'll need to
import the parsers from nuqs/server
, which doesn't include
the "use client"
directive.
You can then use the parseServerSide
method:
import { parseAsInteger } from 'nuqs/server'
type PageProps = {
searchParams: {
counter?: string | string[]
}
}
const counterParser = parseAsInteger.withDefault(1)
export default function ServerPage({ searchParams }: PageProps) {
const counter = counterParser.parseServerSide(searchParams.counter)
console.log('Server side counter: %d', counter)
return (
...
)
}
See the server-side parsing demo
for a live example showing how to reuse parser configurations between
client and server code.
Note: parsers don't validate your data. If you expect positive integers
or JSON-encoded objects of a particular shape, you'll need to feed the result
of the parser to a schema validation library, like Zod.
Default value
When the query string is not present in the URL, the default behaviour is to
return null
as state.
It can make state updating and UI rendering tedious. Take this example of a simple counter stored in the URL:
import { useQueryState, parseAsInteger } from 'nuqs'
export default () => {
const [count, setCount] = useQueryState('count', parseAsInteger)
return (
<>
<pre>count: {count}</pre>
<button onClick={() => setCount(0)}>Reset</button>
{/* handling null values in setCount is annoying: */}
<button onClick={() => setCount(c => c ?? 0 + 1)}>+</button>
<button onClick={() => setCount(c => c ?? 0 - 1)}>-</button>
<button onClick={() => setCount(null)}>Clear</button>
</>
)
}
You can specify a default value to be returned in this case:
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
const clearCount = () => setCount(null)
Note: the default value is internal to React, it will not be written to the
URL.
Setting the state to null
will remove the key in the query string and set the
state to the default value.
Options
History
By default, state updates are done by replacing the current history entry with
the updated query when state changes.
You can see this as a sort of git squash
, where all state-changing
operations are merged into a single history value.
You can also opt-in to push a new history item for each state change,
per key, which will let you use the Back button to navigate state
updates:
useQueryState('foo', { history: 'replace' })
useQueryState('foo', { history: 'push' })
Any other value for the history
option will fallback to the default.
You can also override the history mode when calling the state updater function:
const [query, setQuery] = useQueryState('q', { history: 'push' })
setQuery(null, { history: 'replace' })
Shallow
Note: this feature only applies to Next.js
By default, query state updates are done in a client-first manner: there are
no network calls to the server.
This is equivalent to the shallow
option of the Next.js pages router set to true
,
or going through the experimental windowHistorySupport
flag in the app router.
To opt-in to query updates notifying the server (to re-run getServerSideProps
in the pages router and re-render Server Components on the app router),
you can set shallow
to false
:
const [state, setState] = useQueryState('foo', { shallow: false })
setState('bar', { shallow: false })
Throttling URL updates
Because of browsers rate-limiting the History API, internal updates to the
URL are queued and throttled to a default of 50ms, which seems to satisfy
most browsers even when sending high-frequency query updates, like binding
to a text input or a slider.
Safari's rate limits are much higher and would require a throttle of around 340ms.
If you end up needing a longer time between updates, you can specify it in the
options:
useQueryState('foo', {
shallow: false,
throttleMs: 1000
})
setState('bar', { throttleMs: 1000 })
Note: the state returned by the hook is always updated instantly, to keep UI responsive.
Only changes to the URL, and server requests when using shallow: false
, are throttled.
If multiple hooks set different throttle values on the same event loop tick,
the highest value will be used. Also, values lower than 50ms will be ignored,
to avoid rate-limiting issues. Read more.
Transitions
When combined with shallow: false
, you can use the useTransition
hook to get
loading states while the server is re-rendering server components with the
updated URL.
Pass in the startTransition
function from useTransition
to the options
to enable this behaviour:
'use client'
import React from 'react'
import { useQueryState, parseAsString } from 'nuqs'
function ClientComponent({ data }) {
const [isLoading, startTransition] = React.useTransition()
const [query, setQuery] = useQueryState(
'query',
parseAsString().withOptions({
startTransition,
shallow: false
})
)
if (isLoading) return <div>Loading...</div>
return <div>{/*...*/}</div>
}
Configuring parsers, default value & options
You can use a builder pattern to facilitate specifying all of those things:
useQueryState(
'counter',
parseAsInteger.withDefault(0).withOptions({
history: 'push',
shallow: false
})
)
You can get this pattern for your custom parsers too, and compose them
with others:
import { createParser, parseAsHex } from 'nuqs'
const hexColorSchema = createParser({
parse(query) {
if (query.length !== 6) {
return null
}
return {
r: parseAsHex.parse(query.slice(0, 2)) ?? 0x00,
g: parseAsHex.parse(query.slice(2, 4)) ?? 0x00,
b: parseAsHex.parse(query.slice(4)) ?? 0x00
}
},
serialize({ r, g, b }) {
return (
parseAsHex.serialize(r) +
parseAsHex.serialize(g) +
parseAsHex.serialize(b)
)
}
})
.withOptions({ history: 'push' })
useQueryState(
'tribute',
hexColorSchema.withDefault({
r: 0x66,
g: 0x33,
b: 0x99
})
)
Note: see this example running in the hex-colors demo.
Multiple Queries (batching)
You can call as many state update function as needed in a single event loop
tick, and they will be applied to the URL asynchronously:
const MultipleQueriesDemo = () => {
const [lat, setLat] = useQueryState('lat', parseAsFloat)
const [lng, setLng] = useQueryState('lng', parseAsFloat)
const randomCoordinates = React.useCallback(() => {
setLat(Math.random() * 180 - 90)
setLng(Math.random() * 360 - 180)
}, [])
}
If you wish to know when the URL has been updated, and what it contains, you can
await the Promise returned by the state updater function, which gives you the
updated URLSearchParameters object:
const randomCoordinates = React.useCallback(() => {
setLat(42)
return setLng(12)
}, [])
randomCoordinates().then((search: URLSearchParams) => {
search.get('lat')
search.get('lng')
})
Implementation details (Promise caching)
The returned Promise is cached until the next flush to the URL occurs,
so all calls to a setState (of any hook) in the same event loop tick will
return the same Promise reference.
Due to throttling of calls to the Web History API, the Promise may be cached
for several ticks. Batched updates will be merged and flushed once to the URL.
This means not every setState will reflect to the URL, if another one comes
overriding it before flush occurs.
The returned React state will reflect all set values instantly,
to keep UI responsive.
useQueryStates
For query keys that should always move together, you can use useQueryStates
with an object containing each key's type:
import { useQueryStates, parseAsFloat } from 'nuqs'
const [coordinates, setCoordinates] = useQueryStates(
{
lat: parseAsFloat.withDefault(45.18),
lng: parseAsFloat.withDefault(5.72)
},
{
history: 'push'
}
)
const { lat, lng } = coordinates
const search = await setCoordinates({
lat: Math.random() * 180 - 90,
lng: Math.random() * 360 - 180
})
Accessing searchParams in Server Components
If you wish to access the searchParams in a deeply nested Server Component
(ie: not in the Page component), you can use createSearchParamsCache
to do so in a type-safe manner.
Note: parsers don't validate your data. If you expect positive integers
or JSON-encoded objects of a particular shape, you'll need to feed the result
of the parser to a schema validation library, like Zod.
import {
createSearchParamsCache,
parseAsInteger,
parseAsString
} from 'nuqs/server'
export const searchParamsCache = createSearchParamsCache({
q: parseAsString.withDefault(''),
maxResults: parseAsInteger.withDefault(10)
})
import { searchParamsCache } from './searchParams'
export default function Page({
searchParams
}: {
searchParams: Record<string, string | string[] | undefined>
}) {
const { q: query } = searchParamsCache.parse(searchParams)
return (
<div>
<h1>Search Results for {query}</h1>
<Results />
</div>
)
}
function Results() {
const maxResults = searchParamsCache.get('maxResults')
return <span>Showing up to {maxResults} results</span>
}
The cache will only be valid for the current page render
(see React's cache
function).
Note: the cache only works for server components, but you may share your
parser declaration with useQueryStates
for type-safety in client components:
import { parseAsFloat, createSearchParamsCache } from 'nuqs/server'
export const coordinatesParsers = {
lat: parseAsFloat.withDefault(45.18),
lng: parseAsFloat.withDefault(5.72)
}
export const coordinatesCache = createSearchParamsCache(coordinatesParsers)
import { coordinatesCache } from './searchParams'
import { Server } from './server'
import { Client } from './client'
export default async function Page({ searchParams }) {
await coordinatesCache.parse(searchParams)
return (
<>
<Server />
<Suspense>
<Client />
</Suspense>
</>
)
}
import { coordinatesCache } from './searchParams'
export function Server() {
const { lat, lng } = coordinatesCache.all()
const lat = coordinatesCache.get('lat')
const lng = coordinatesCache.get('lng')
return (
<span>
Latitude: {lat} - Longitude: {lng}
</span>
)
}
;'use client'
import { useQueryStates } from 'nuqs'
import { coordinatesParsers } from './searchParams'
export function Client() {
const [{ lat, lng }, setCoordinates] = useQueryStates(coordinatesParsers)
}
Serializer helper
To populate <Link>
components with state values, you can use the createSerializer
helper.
Pass it an object describing your search params, and it will give you a function
to call with values, that generates a query string serialized as the hooks would do.
Example:
import {
createSerializer,
parseAsInteger,
parseAsIsoDateTime,
parseAsString,
parseAsStringLiteral
} from 'nuqs/server'
const searchParams = {
search: parseAsString,
limit: parseAsInteger,
from: parseAsIsoDateTime,
to: parseAsIsoDateTime,
sortBy: parseAsStringLiteral(['asc', 'desc'] as const)
}
const serialize = createSerializer(searchParams)
serialize({
search: 'foo bar',
limit: 10,
from: new Date('2024-01-01'),
sortBy: null
})
Base parameter
The returned serialize
function can take a base parameter over which to
append/amend the search params:
serialize('/path?baz=qux', { foo: 'bar' })
const search = new URLSearchParams('?baz=qux')
serialize(search, { foo: 'bar' })
const url = new URL('https://example.com/path?baz=qux')
serialize(url, { foo: 'bar' })
serialize('?remove=me', { foo: 'bar', remove: null })
Parser type inference
To access the underlying type returned by a parser, you can use the
inferParserType
type helper:
import { parseAsInteger, type inferParserType } from 'nuqs'
const intNullable = parseAsInteger
const intNonNull = parseAsInteger.withDefault(0)
inferParserType<typeof intNullable>
inferParserType<typeof intNonNull>
For an object describing parsers (that you'd pass to createSearchParamsCache
or to useQueryStates
, inferParserType
will
return the type of the object with the parsers replaced by their inferred types:
import { parseAsBoolean, parseAsInteger, type inferParserType } from 'nuqs'
const parsers = {
a: parseAsInteger,
b: parseAsBoolean.withDefault(false)
}
inferParserType<typeof parsers>
Testing
Since nuqs v2, you can use a testing adapter to unit-test components using
useQueryState
and useQueryStates
in isolation, without needing to mock
your framework or router.
Here's an example using Testing Library and Vitest:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import { describe, expect, it, vi } from 'vitest'
import { CounterButton } from './counter-button'
it('should increment the count when clicked', async () => {
const user = userEvent.setup()
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
render(<CounterButton />, {
wrapper: ({ children }) => (
<NuqsTestingAdapter searchParams="?count=42" onUrlUpdate={onUrlUpdate}>
{children}
</NuqsTestingAdapter>
)
})
const button = screen.getByRole('button')
expect(button).toHaveTextContent('count is 42')
await user.click(button)
expect(button).toHaveTextContent('count is 43')
expect(onUrlUpdate).toHaveBeenCalledOnce()
expect(onUrlUpdate.mock.calls[0][0].queryString).toBe('?count=43')
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('count')).toBe('43')
expect(onUrlUpdate.mock.calls[0][0].options.history).toBe('push')
})
See #259 for more testing-related discussions.
Debugging
You can enable debug logs in the browser by setting the debug
item in localStorage
to nuqs
, and reload the page.
localStorage.setItem('debug', 'nuqs')
Note: unlike the debug
package, this will not work with wildcards, but
you can combine it: localStorage.setItem('debug', '*,nuqs')
Log lines will be prefixed with [nuqs]
for useQueryState
and [nuq+]
for
useQueryStates
, along with other internal debug logs.
User timings markers are also recorded, for advanced performance analysis using
your browser's devtools.
Providing debug logs when opening an issue
is always appreciated. 🙏
SEO
If your page uses query strings for local-only state, you should add a
canonical URL to your page, to tell SEO crawlers to ignore the query string
and index the page without it.
In the app router, this is done via the metadata object:
import type { Metadata } from 'next'
export const metadata: Metadata = {
alternates: {
canonical: '/url/path/without/querystring'
}
}
If however the query string is defining what content the page is displaying
(eg: YouTube's watch URLs, like https://www.youtube.com/watch?v=dQw4w9WgXcQ
),
your canonical URL should contain relevant query strings, and you can still
use useQueryState
to read it:
import type { Metadata, ResolvingMetadata } from 'next'
import { useQueryState } from 'nuqs'
import { parseAsString } from 'nuqs/server'
type Props = {
searchParams: { [key: string]: string | string[] | undefined }
}
export async function generateMetadata({
searchParams
}: Props): Promise<Metadata> {
const videoId = parseAsString.parseServerSide(searchParams.v)
return {
alternates: {
canonical: `/watch?v=${videoId}`
}
}
}
Lossy serialization
If your serializer loses precision or doesn't accurately represent
the underlying state value, you will lose this precision when
reloading the page or restoring state from the URL (eg: on navigation).
Example:
const geoCoordParser = {
parse: parseFloat,
serialize: v => v.toFixed(4)
}
const [lat, setLat] = useQueryState('lat', geoCoordParser)
Here, setting a latitude of 1.23456789 will render a URL query string
of lat=1.2345
, while the internal lat
state will be correctly
set to 1.23456789.
Upon reloading the page, the state will be incorrectly set to 1.2345.
License
MIT
Made with ❤️ by François Best
Using this package at work ? Sponsor me
to help with support and maintenance.