L'URL est le conteneur d'état le plus sous-utilisé de votre application. Elle est partageable, peut être mise en favori, survit aux rechargements et fonctionne gratuitement avec le bouton retour du navigateur. nuqs vous permet de lire et d'écrire cet état avec exactement la même ergonomie que useState — mais entièrement typé et synchronisé avec la chaîne de requête. Dans ce tutoriel, vous construirez un vrai tableau de bord produits avec filtres, pagination et tri, le tout piloté entièrement par l'URL.
Ce que vous allez construire
Nous construirons un tableau de bord de catalogue produits où chaque élément d'état de l'interface vit dans l'URL :
- Une barre de recherche temporisée qui met à jour
?q= - Des filtres de catégories stockés sous forme de tableau dans
?categories= - Une pagination avec
?page=et?perPage= - Des colonnes triables avec
?sort=et?dir= - Un lien entièrement partageable — copiez l'URL et un collègue voit exactement la même vue filtrée
- Une analyse côté serveur pour que le premier rendu soit déjà filtré, sans aucun décalage de mise en page
À la fin, vous comprendrez toutes les API essentielles de nuqs : useQueryState, useQueryStates, la bibliothèque d'analyseurs, les options comme history, shallow et throttleMs, et les caches côté serveur avec createSearchParamsCache.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20 ou plus installé
- Un projet Next.js 15 utilisant l'App Router
- Une connaissance pratique des hooks React et de TypeScript
- Une familiarité avec
useState(car c'est le modèle mental quenuqsemprunte)
Pourquoi l'état dans l'URL ?
La plupart des applications React stockent l'état des filtres et de la pagination dans useState. Cela fonctionne jusqu'à ce que quelqu'un recharge la page et perde ses filtres, ou tente de partager un lien et que le destinataire voie une vue par défaut vide. L'URL résout tout cela — mais analyser les chaînes de requête à la main est fastidieux et source d'erreurs :
// L'ancienne méthode fragile
const page = Number(searchParams.get('page') ?? '1')
const categories = searchParams.get('categories')?.split(',') ?? []
// ...et maintenant resérialisez tout manuellement à chaque changement 😩nuqs remplace cela par une API typée et déclarative. Considérez-la comme useState, sauf que la source de vérité est la chaîne de requête et que chaque valeur est validée via un analyseur.
Étape 1 : installation et configuration de l'adaptateur
Installez le paquet :
npm install nuqsnuqs est indépendant du framework, vous lui indiquez donc votre routeur via un adaptateur. Pour l'App Router de Next.js, enveloppez votre application dans NuqsAdapter. L'endroit le plus propre est la mise en page racine :
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="fr">
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
)
}Ce seul fournisseur est tout le câblage dont nuqs a besoin. Il existe aussi des adaptateurs dédiés pour le Pages Router, React Router, Remix et TanStack Router — seul le chemin d'import change.
Étape 2 : votre premier état de requête
Ajoutons la barre de recherche. Le hook useQueryState reflète exactement useState — il renvoie une valeur et un setter :
// app/products/search-box.tsx
'use client'
import { useQueryState } from 'nuqs'
export function SearchBox() {
const [query, setQuery] = useQueryState('q')
return (
<input
value={query ?? ''}
onChange={(e) => setQuery(e.target.value || null)}
placeholder="Rechercher des produits..."
/>
)
}Tapez quelques caractères et regardez l'URL devenir ?q=keyboard. Rechargez la page — le champ conserve sa valeur. Définir l'état à null supprime entièrement le paramètre de l'URL, c'est ainsi que l'on exprime « retour à la valeur par défaut ».
Par défaut, query est typé string | null. Le null signifie « ce paramètre est absent ». Nous corrigerons la nullabilité avec une valeur par défaut à l'étape suivante.
Étape 3 : analyseurs et valeurs par défaut
Une chaîne de requête brute est toujours du texte. Les analyseurs (parsers) convertissent ce texte en types réels et inversement. nuqs fournit des analyseurs pour tous les cas courants :
'use client'
import {
useQueryState,
parseAsInteger,
parseAsString,
parseAsBoolean,
} from 'nuqs'
export function Pagination() {
// parseAsInteger.withDefault(1) => page est un `number`, jamais null
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
return (
<div>
<button onClick={() => setPage((p) => p - 1)} disabled={page <= 1}>
Précédent
</button>
<span>Page {page}</span>
<button onClick={() => setPage((p) => p + 1)}>Suivant</button>
</div>
)
}Deux choses à remarquer :
parseAsIntegerfait depageun vrainumber. AppelersetPage((p) => p + 1)fonctionne avec le mise à jour fonctionnelle, exactement commeuseState..withDefault(1)retire lenulldu type. Quand?page=est absent, vous obtenez1au lieu denull, etnuqsgarde l'URL propre en omettant la valeur par défaut plutôt qu'en écrivant?page=1.
La bibliothèque d'analyseurs intégrée couvre presque tout :
| Analyseur | Type | Exemple d'URL |
|---|---|---|
parseAsString | string | ?q=keyboard |
parseAsInteger | number | ?page=2 |
parseAsFloat | number | ?price=19.99 |
parseAsBoolean | boolean | ?inStock=true |
parseAsArrayOf(parseAsString) | string[] | ?tags=a,b,c |
parseAsStringLiteral([...]) | union | ?dir=asc |
parseAsIsoDateTime | Date | ?from=2026-06-03... |
Si un utilisateur modifie manuellement l'URL avec une valeur invalide (?page=banana), l'analyseur échoue proprement et revient à votre valeur par défaut — sans plantage.
Étape 4 : tableaux pour les filtres à sélection multiple
Les filtres de catégories sont une sélection multiple, nous les stockons donc sous forme de tableau. Combinez parseAsArrayOf avec parseAsString :
// app/products/category-filter.tsx
'use client'
import { useQueryState, parseAsArrayOf, parseAsString } from 'nuqs'
const ALL_CATEGORIES = ['keyboards', 'mice', 'monitors', 'audio']
export function CategoryFilter() {
const [categories, setCategories] = useQueryState(
'categories',
parseAsArrayOf(parseAsString).withDefault([]),
)
function toggle(cat: string) {
setCategories((current) =>
current.includes(cat)
? current.filter((c) => c !== cat)
: [...current, cat],
)
}
return (
<fieldset>
{ALL_CATEGORIES.map((cat) => (
<label key={cat}>
<input
type="checkbox"
checked={categories.includes(cat)}
onChange={() => toggle(cat)}
/>
{cat}
</label>
))}
</fieldset>
)
}L'URL affiche maintenant ?categories=keyboards,mice. Par défaut le séparateur est une virgule, mais vous pouvez le changer avec .withOptions si vos valeurs contiennent des virgules :
parseAsArrayOf(parseAsString, ';').withDefault([])
// produit ?categories=keyboards;miceÉtape 5 : grouper l'état lié avec useQueryStates
Le tri nécessite deux valeurs qui doivent toujours changer ensemble : une colonne et une direction. Les mettre à jour via deux appels useQueryState distincts déclencherait deux écritures d'URL. useQueryStates regroupe un ensemble de paramètres en une seule mise à jour :
// app/products/sort-control.tsx
'use client'
import { useQueryStates, parseAsStringLiteral } from 'nuqs'
const columns = ['name', 'price', 'rating'] as const
const directions = ['asc', 'desc'] as const
export function SortControl() {
const [sort, setSort] = useQueryStates({
sort: parseAsStringLiteral(columns).withDefault('name'),
dir: parseAsStringLiteral(directions).withDefault('asc'),
})
function sortBy(column: (typeof columns)[number]) {
setSort((prev) => ({
sort: column,
// inverser la direction si la même colonne est cliquée à nouveau
dir: prev.sort === column && prev.dir === 'asc' ? 'desc' : 'asc',
}))
}
return (
<div>
{columns.map((col) => (
<button key={col} onClick={() => sortBy(col)}>
{col} {sort.sort === col ? (sort.dir === 'asc' ? '↑' : '↓') : ''}
</button>
))}
</div>
)
}parseAsStringLiteral est l'arme secrète ici : il contraint sort à être exactement 'name' | 'price' | 'rating' au niveau du type. Une valeur invalide dans l'URL revient à la valeur par défaut, donc vos instructions switch sont toujours exhaustives et sûres.
Étape 6 : options — history, shallow et limitation
Chaque analyseur accepte des options via .withOptions (ou comme troisième argument). Ces trois-là comptent le plus dans les vraies applications :
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export function SearchBox() {
const [query, setQuery] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({
// ajouter une nouvelle entrée d'historique par changement (le bouton retour parcourt les recherches)
history: 'push',
// limiter les écritures d'URL pour qu'une frappe rapide n'inonde pas l'historique
throttleMs: 300,
}),
)
// ...
}history — par défaut 'replace' (l'URL change sans ajouter d'entrée au bouton retour). Utilisez 'push' quand chaque changement d'état est une « page » distincte vers laquelle l'utilisateur doit pouvoir revenir.
throttleMs — fusionne les mises à jour rapides. Pour une barre de recherche liée à onChange, une limitation de 200 à 500 ms empêche l'URL de se mettre à jour à chaque frappe. Cela remplace la plupart de la logique de temporisation manuelle.
shallow — la plus importante pour la récupération de données. Par défaut, les mises à jour de nuqs sont uniquement côté client (shallow: true) : les React Server Components ne se réexécutent pas. Quand vous avez besoin que le serveur effectue un nouveau rendu avec les nouveaux paramètres (par exemple pour recharger des données dans un RSC), définissez shallow: false :
parseAsInteger.withDefault(1).withOptions({ shallow: false })Avec shallow: false, changer ?page= déclenche un aller-retour serveur et votre Server Component lit automatiquement la nouvelle valeur.
Étape 7 : des mises à jour fluides avec startTransition
Quand shallow: false déclenche un travail serveur, vous voulez une interface en attente plutôt qu'une page figée. nuqs s'intègre avec le hook useTransition de React :
'use client'
import { useTransition } from 'react'
import { useQueryState, parseAsInteger } from 'nuqs'
export function Pagination() {
const [isLoading, startTransition] = useTransition()
const [page, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1).withOptions({
shallow: false,
startTransition,
}),
)
return (
<div data-pending={isLoading ? '' : undefined}>
<button onClick={() => setPage((p) => p + 1)}>Suivant</button>
{isLoading && <span>Chargement...</span>}
</div>
)
}Maintenant isLoading vaut true pendant que le serveur recharge, vous permettant de griser le tableau ou d'afficher un indicateur — sans état vide saccadé.
Étape 8 : analyse côté serveur avec createSearchParamsCache
C'est ici que nuqs brille vraiment. Pour rendre la première page déjà filtrée, analysez les mêmes paramètres sur le serveur. L'astuce consiste à partager les définitions d'analyseurs entre le client et le serveur.
D'abord, définissez les analyseurs à un seul endroit :
// app/products/search-params.ts
import {
parseAsInteger,
parseAsString,
parseAsArrayOf,
parseAsStringLiteral,
createSearchParamsCache,
} from 'nuqs/server'
export const productSearchParams = {
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1),
categories: parseAsArrayOf(parseAsString).withDefault([]),
sort: parseAsStringLiteral(['name', 'price', 'rating']).withDefault('name'),
dir: parseAsStringLiteral(['asc', 'desc']).withDefault('asc'),
}
export const searchParamsCache = createSearchParamsCache(productSearchParams)Maintenant votre Server Component peut analyser les paramètres entrants avec une sécurité de type complète :
// app/products/page.tsx
import { searchParamsCache } from './search-params'
import { getProducts } from '@/lib/products'
import { ProductTable } from './product-table'
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
// parse() valide et met en cache les valeurs typées pour cette requête
const { q, page, categories, sort, dir } = await searchParamsCache.parse(
await searchParams,
)
const products = await getProducts({ q, page, categories, sort, dir })
return <ProductTable products={products} />
}Les composants client réutilisent exactement le même objet d'analyseur, il y a donc une source de vérité unique. Passez productSearchParams directement dans useQueryStates :
'use client'
import { useQueryStates } from 'nuqs'
import { productSearchParams } from './search-params'
export function Filters() {
const [state, setState] = useQueryStates(productSearchParams)
// state est entièrement typé, identique à la forme analysée côté serveur
// ...
}Modifiez un filtre côté client, l'URL se met à jour, shallow: false déclenche un rendu serveur, et ProductsPage recharge avec les nouveaux paramètres. Une définition, les deux côtés, entièrement typé.
Étape 9 : construire des liens partageables et des sérialiseurs
Parfois vous devez construire une URL sans rendre de composant — un lien « Réinitialiser les filtres » ou un e-mail généré côté serveur. nuqs expose un createSerializer exactement pour cela :
// app/products/search-params.ts (suite)
import { createSerializer } from 'nuqs/server'
export const serialize = createSerializer(productSearchParams)import Link from 'next/link'
import { serialize } from './search-params'
// produit /products?categories=keyboards&sort=price&dir=desc
const url = serialize('/products', {
categories: ['keyboards'],
sort: 'price',
dir: 'desc',
})
export function CheapKeyboardsLink() {
return <Link href={url}>Claviers les moins chers</Link>
}Parce que le sérialiseur utilise les mêmes analyseurs, le lien généré est garanti d'être réanalysé en l'état typé identique. Les valeurs par défaut sont omises automatiquement, ce qui garde les liens courts.
Tester votre implémentation
Vérifiez le comportement de bout en bout :
- Persistance des filtres — appliquez un terme de recherche et deux catégories, puis rechargez. Les filtres et les résultats doivent être identiques.
- État partageable — copiez l'URL dans un nouvel onglet du navigateur. Vous devez arriver sur la vue filtrée exacte, rendue par le serveur.
- Bouton retour — avec
history: 'push', effectuez trois recherches et appuyez sur retour. Vous devez revenir en arrière à travers chacune d'elles. - Entrée invalide — définissez manuellement
?page=bananadans la barre d'adresse. La page doit revenir à la page 1 sans erreur. - Aucun décalage de mise en page — désactivez JavaScript et chargez une URL filtrée. Comme l'analyse se fait sur le serveur, les bons résultats s'affichent immédiatement.
Dépannage
« useQueryState must be used within a NuqsAdapter » — vous avez oublié d'envelopper votre arbre dans NuqsAdapter, ou importé le mauvais adaptateur pour votre routeur.
Le serveur ne refait pas le rendu au changement — vous utilisez le shallow: true par défaut. Ajoutez .withOptions({ shallow: false }) aux analyseurs qui doivent déclencher un travail serveur.
Les valeurs par défaut apparaissent dans l'URL — assurez-vous d'utiliser .withDefault() plutôt que de passer une valeur par défaut via une logique de style useState. nuqs n'omet une valeur de l'URL que lorsqu'il connaît la valeur par défaut via l'analyseur.
Erreurs de type avec searchParams — dans Next.js 15, searchParams est une Promise. Pensez à utiliser await avant de le passer à searchParamsCache.parse().
Étapes suivantes
- Combinez
nuqsavec TanStack Table pour des grilles de données entièrement pilotées par l'URL — tri, pagination et filtres de colonnes, tout dans la chaîne de requête. - Associez-le à TanStack Query pour qu'un changement d'URL recharge automatiquement les bonnes données.
- Explorez
parseAsJsonavec un schéma Zod pour stocker en toute sécurité un état structuré complexe dans l'URL. - Lisez nos tutoriels associés sur Zustand pour l'état client et le modèle atomique de Jotai pour comprendre quand l'état d'URL, l'état global et l'état atomique conviennent le mieux.
Conclusion
nuqs transforme l'URL en conteneur d'état de premier ordre avec l'ergonomie de useState et la sécurité de TypeScript. Vous avez appris à lire et écrire des valeurs uniques avec useQueryState, à regrouper les valeurs liées avec useQueryStates, à tout valider via des analyseurs, à ajuster le comportement avec history, shallow et throttleMs, et à analyser les mêmes paramètres sur le serveur avec createSearchParamsCache pour des vues instantanées, partageables et résistantes au rechargement.
Le principe est simple mais puissant : si un élément d'état doit être partageable ou survivre à un rechargement, il appartient à l'URL — et nuqs fait de son placement là-bas un changement d'une seule ligne.