État typé dans l’URL pour React et Next.js — comme useState
useUrlState est l’état React qui s’écrit lui-même dans la chaîne de requête. Les objets, les tableaux et les dates conservent leurs types, chaque état est un lien partageable, il survit aux rechargements et le bouton Précédent fonctionne — sans providers, sans frontière Suspense, sans code répétitif.
- ~2 KB en gzip
- zéro dépendance
- TypeScript-first
- Next.js / react-router / Remix / Astro
- MIT
npm i state-in-urlGestion d’état dans l’URL avec Next.js App Router
state-in-url garde un état typé dans la chaîne de requête sur Next.js 14, 15 et 16 : un hook useUrlState par fonctionnalité, sans adaptateur, sans provider, sans frontière Suspense. Cette page couvre ce qui est propre à l’App Router — Server Components, prérendu, layouts et historique.
La démo en direct de la page d’accueil tourne sur Next.js 16.
Transmettez searchParams depuis la page serveur
Une page Server Component reçoit searchParams — une Promise depuis Next.js 15. Attendez-la avec await et passez l’objet au composant client, qui le remet au hook. Le premier rendu serveur affiche alors les valeurs de l’URL plutôt que les valeurs par défaut : pas de flash, pas d’avertissement d’hydratation.
// app/jobs/page.tsx (Server Component)
import { JobsList } from './JobsList';
export default async function Page({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
// A Promise since Next.js 15; a plain object in 14
return <JobsList searchParams={await searchParams} />;
}// app/jobs/JobsList.tsx
'use client';
import { useUrlState } from 'state-in-url/next';
// Outside the component: sharing is keyed by object identity
const JOBS_STATE = { q: '', page: 1, remote: false, tags: [] as string[] };
export function JobsList({ searchParams }: { searchParams: object }) {
const { urlState, setUrl } = useUrlState(JOBS_STATE, { searchParams });
return (
<input
value={urlState.q}
onChange={(ev) => setUrl({ q: ev.target.value, page: 1 })}
/>
);
}Aucune frontière Suspense, prérendu conservé
Le hook n'appelle jamais useSearchParams, donc un composant qui l'utilise n'a pas besoin d'être enveloppé dans Suspense et n'exclut pas sa page du prérendu — PPR et cacheComponents inclus. Il lit l'URL directement et suit chaque changement ultérieur, y compris un history.pushState issu d'un code qui n'en sait rien. Une page prérendue affiche tout de même les valeurs par défaut, puisqu’il n’y a pas de chaîne de requête au moment du build — rendez une route dynamiquement quand un lien partagé doit être juste dès le premier affichage.
Layouts : décodez la chaîne de requête depuis un en-tête
Les layouts serveur ne reçoivent jamais searchParams. Copiez la chaîne de requête dans un en-tête de requête dans proxy.ts (middleware.ts fonctionne encore, comme alias déprécié) et décodez-la dans le layout avec decodeState et le même objet d’état par défaut — le résultat est typé exactement comme urlState côté client.
// proxy.ts (middleware.ts before Next.js 16)
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
const sp = (request.url.includes('_next') ? '' : request.url).split('?')[1] ?? '';
const headers = new Headers(request.headers);
headers.set('searchParams', sp);
return NextResponse.next({ request: { headers } });
}// app/jobs/layout.tsx (Server Component)
import { headers } from 'next/headers';
import { decodeState } from 'state-in-url/encodeState';
import { JOBS_STATE } from './jobsState';
export default async function Layout({ children }: { children: React.ReactNode }) {
const sp = (await headers()).get('searchParams') ?? '';
const initial = decodeState(sp, JOBS_STATE); // typed like urlState
return <>{/* use `initial` */}{children}</>;
}Historique, mises à jour sans aller-retour serveur et scroll
setUrl remplace l’entrée d’historique courante par défaut, donc la saisie n’empile pas les entrées ; passez replace: false pour en pousser une. Les mises à jour passent par l’History API — aucun aller-retour serveur ni requête _rsc à chaque frappe. Indiquez useHistory: false pour passer par le routeur Next.js à la place, quand le serveur doit re-rendre à chaque changement. scroll vaut false par défaut.
Champs rapides : affichez maintenant, écrivez l’URL plus tard
Pour les champs de texte et les curseurs, mettez à jour avec setState à chaque changement et appelez setUrl() sans argument au blur ou après un debounce. Le composant se re-rend immédiatement ; l’URL est écrite une fois, avec un diff basé sur le contenu, donc l’appeler à répétition ne pose aucun problème.
const { urlState, setState, setUrl } = useJobsState();
// Render now, write the URL once the field is left
<input
value={urlState.q}
onChange={(ev) => setState({ q: ev.target.value })}
onBlur={() => setUrl()}
/>
setUrl({ page: 2 }); // replaces the history entry (default)
setUrl({ page: 2 }, { replace: false }); // pushes a new one — Back returns to page 1
setUrl({ page: 2 }, { scroll: true }); // scroll to top, off by defaultLisez la comparaison complète — la même fonctionnalité dans les deux, et comment migrer
État dans l’URL avec Next.js — questions fréquentes
- Comment garder l’état dans l’URL avec Next.js App Router ?
- Définissez un objet d’état par défaut hors du composant, enveloppez useUrlState de state-in-url/next dans un petit hook, et appelez ce hook dans n’importe quel composant client. urlState est la valeur courante typée et setUrl écrit un partiel dans la chaîne de requête. Passez-lui la prop searchParams de la page pour que le rendu serveur soit déjà correct.
- useSearchParams a-t-il besoin d’une frontière Suspense, et state-in-url ?
- Le useSearchParams de Next fait basculer une route rendue statiquement en rendu client jusqu’à la frontière Suspense la plus proche, et le build échoue sans elle. state-in-url ne l’appelle jamais : il lit searchParams côté serveur et window.location côté client, donc aucune frontière n’est nécessaire et le prérendu, PPR compris, est conservé.
- Comment lire l’état de l’URL dans un Server Component ?
- Les pages le reçoivent via la prop searchParams — attendez-la avec await, puis transmettez-la au hook client ou décodez-la côté serveur avec decodeState et le même objet par défaut. Les layouts ne reçoivent pas searchParams ; exposez la chaîne de requête via un en-tête défini dans proxy.ts et décodez celui-ci.
- Mettre à jour l’URL re-rend-il la page côté serveur ?
- Pas par défaut. setUrl met à jour via l’History API, donc rien n’est récupéré et aucune requête _rsc n’est émise. Quand le serveur doit voir le nouvel état — par exemple pour recharger une liste dans un Server Component — passez useHistory: false pour que les mises à jour passent par le routeur Next.js et que la route se re-rende.
- state-in-url est-il une alternative à nuqs pour Next.js ?
- Oui. Les deux gardent un état typé dans la chaîne de requête ; state-in-url prend un seul objet, valeurs imbriquées et dates préservées, n’a besoin ni de composant adaptateur ni de parseur par clé, et ne touche jamais à useSearchParams. nuqs convient mieux quand chaque valeur doit être son propre query param lisible à la main. Voir la comparaison complète.
- Quelles versions de Next.js sont prises en charge ?
- Next.js 14, 15 et 16 avec l’App Router, y compris les searchParams asynchrones introduits en 15 et cacheComponents avec PPR en 16. Les autres configurations peuvent utiliser les helpers encodeState et decodeState, indépendants du framework, avec le routeur de leur choix.
