Estado tipado en la URL para React y Next.js — como useState

useUrlState es el estado de React que se escribe a sí mismo en la cadena de consulta. Los objetos, los arrays y las fechas conservan sus tipos, cada estado es un enlace compartible, sobrevive a las recargas y el botón atrás funciona — sin providers, sin límite de Suspense, sin código repetitivo.

  • ~2 KB en gzip
  • cero dependencias
  • TypeScript-first
  • Next.js / react-router / Remix / Astro
  • MIT
npm i state-in-url

Gestión de estado en la URL en Next.js App Router

state-in-url guarda estado tipado en la cadena de consulta en Next.js 14, 15 y 16: un hook useUrlState por feature, sin adaptador, sin provider, sin límite de Suspense. Esta página cubre lo específico del App Router — Server Components, prerenderizado, layouts e historial.

La demo en vivo de la página principal corre en Next.js 16.

Reenvía searchParams desde la página de servidor

Una página que es Server Component recibe searchParams — una Promise desde Next.js 15. Espérala con await y pasa el objeto al componente de cliente, que se lo entrega al hook. El primer render en el servidor muestra entonces los valores de la URL en lugar de los valores por defecto, así que no hay parpadeo ni aviso de hidratación.

page.tsx
// 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} />;
}
JobsList.tsx
// 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 })}
    />
  );
}

Sin límite de Suspense, prerenderizado conservado

El hook nunca llama a useSearchParams, por lo que un componente que lo usa no necesita envolverse en Suspense y no excluye su página del prerenderizado: PPR y cacheComponents incluidos. Lee la URL directamente y sigue cada cambio posterior, incluido un history.pushState desde código que no sabe nada de él. Una página prerenderizada sigue renderizando los valores por defecto, porque en el momento del build no hay cadena de consulta — renderiza una ruta dinámicamente cuando un enlace compartido deba ser correcto en el primer pintado.

Layouts: decodifica la cadena de consulta desde una cabecera

Los layouts de servidor nunca reciben searchParams. Copia la cadena de consulta en una cabecera de la petición en proxy.ts (middleware.ts sigue funcionando como alias obsoleto) y decodifícala en el layout con decodeState y el mismo objeto de estado por defecto — el resultado queda tipado exactamente igual que urlState en el cliente.

proxy.ts
// 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 } });
}
layout.tsx
// 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}</>;
}

Historial, actualizaciones superficiales y scroll

setUrl reemplaza la entrada actual del historial por defecto, así que escribir no acumula entradas; pasa replace: false para añadir una. Las actualizaciones van por la History API — sin viaje al servidor ni petición _rsc por cada pulsación. Pasa useHistory: false para ir por el router de Next.js en su lugar, cuando el servidor deba volver a renderizar en cada cambio. scroll es false por defecto.

Campos rápidos: renderiza ahora, escribe la URL después

Para campos de texto y deslizadores, actualiza con setState en cada cambio y llama a setUrl() sin argumentos al perder el foco o tras un debounce. El componente se vuelve a renderizar de inmediato; la URL se escribe una sola vez, con diffing por contenido, así que llamarlo repetidamente es seguro.

useJobsState — usage
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 default

Lee la comparación completa — la misma feature en ambas y cómo migrar

Estado en la URL en Next.js — preguntas frecuentes

¿Cómo guardo estado en la URL en Next.js App Router?
Define un objeto de estado por defecto fuera del componente, envuelve useUrlState de state-in-url/next en un hook pequeño y llama a ese hook en cualquier componente de cliente. urlState es el valor actual tipado y setUrl escribe un partial en la cadena de consulta. Pasa la prop searchParams de la página para que el render en el servidor ya sea correcto.
¿Necesita useSearchParams un límite de Suspense, y lo necesita state-in-url?
El useSearchParams de Next hace que una ruta renderizada estáticamente pase a renderizarse en el cliente hasta el límite de Suspense más cercano, y el build falla si no hay ninguno. state-in-url nunca lo llama: lee searchParams en el servidor y window.location en el cliente, así que no hace falta ningún límite y el prerenderizado, PPR incluido, se conserva.
¿Cómo leo el estado de la URL en un Server Component?
Las páginas lo reciben como la prop searchParams — espérala con await y reenvíala al hook de cliente o decodifícala en el servidor con decodeState y el mismo objeto por defecto. Los layouts no reciben searchParams; expón la cadena de consulta mediante una cabecera establecida en proxy.ts y decodifica esa.
¿Actualizar la URL vuelve a renderizar la página en el servidor?
Por defecto, no. setUrl actualiza a través de la History API, así que no se descarga nada ni se hace ninguna petición _rsc. Cuando el servidor deba ver el nuevo estado — por ejemplo, para volver a pedir una lista en un Server Component — pasa useHistory: false para que las actualizaciones vayan por el router de Next.js y la ruta se vuelva a renderizar.
¿Es state-in-url una alternativa a nuqs para Next.js?
Sí. Ambas guardan estado tipado en la cadena de consulta; state-in-url toma un solo objeto con valores anidados y fechas conservados, no necesita componente adaptador ni parser por clave, y nunca toca useSearchParams. nuqs encaja mejor cuando cada valor debe ser su propio parámetro de consulta legible a mano. Consulta la comparación completa.
¿Qué versiones de Next.js están soportadas?
Next.js 14, 15 y 16 en el App Router, incluidos los searchParams asíncronos introducidos en la 15 y cacheComponents con PPR en la 16. Otras configuraciones pueden usar los helpers encodeState y decodeState, independientes del framework, con el router que prefieran.