Типизированное состояние в URL для React и Next.js — как useState

useUrlState — это состояние React, которое само записывает себя в строку запроса. Объекты, массивы и даты сохраняют свои типы, любое состояние — это ссылка, которой можно поделиться, оно переживает перезагрузку, и кнопка «Назад» работает. Без провайдеров, без границы Suspense, без бойлерплейта.

  • ~2 KB в gzip
  • ноль зависимостей
  • TypeScript-first
  • Next.js / react-router / Remix / Astro
  • MIT
npm i state-in-url

Управление состоянием в URL в Next.js App Router

state-in-url хранит типизированное состояние в строке запроса на Next.js 14, 15 и 16: один hook useUrlState на фичу, без адаптера, без провайдера, без границы Suspense. Эта страница — о том, что специфично для App Router: Server Components, пререндеринг, layout и история.

Живое демо на главной странице работает на Next.js 16.

Пробрасывайте searchParams из серверной страницы

Серверная страница (Server Component) получает searchParams — начиная с Next.js 15 это Promise. Дождитесь его и передайте объект в клиентский компонент, а тот отдаст его в hook. Тогда первый серверный рендер покажет значения из URL вместо значений по умолчанию, так что не будет ни мигания, ни предупреждения о гидратации.

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 })}
    />
  );
}

Без границы Suspense, с сохранением пререндеринга

Этот hook никогда не вызывает useSearchParams, поэтому использующему его компоненту не нужна обёртка в Suspense и он не исключает страницу из предварительного рендеринга — включая PPR и cacheComponents . Он читает URL напрямую и отслеживает каждое последующее изменение, включая history.pushState из кода, который о нём ничего не знает. Пререндеренная страница всё равно рендерит значения по умолчанию, потому что на этапе сборки строки запроса нет — рендерьте роут динамически, когда ссылка, которой поделились, должна быть правильной уже при первой отрисовке.

Layout: декодируйте строку запроса из заголовка

Серверные layout никогда не получают searchParams. Скопируйте строку запроса в заголовок запроса в proxy.ts (middleware.ts по-прежнему работает как устаревший алиас) и декодируйте её в layout через decodeState с тем же объектом состояния по умолчанию — результат типизирован ровно как urlState на клиенте.

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}</>;
}

История, shallow-обновления и scroll

setUrl по умолчанию заменяет текущую запись истории, поэтому ввод текста не плодит записи; передайте replace: false, чтобы добавить новую. Обновления идут через History API — без обращения к серверу и без запроса _rsc на каждое нажатие клавиши. Передайте useHistory: false, чтобы вместо этого идти через роутер Next.js, когда сервер должен перерендериваться на каждое изменение. scroll по умолчанию равен false.

Быстрые поля ввода: рендерьте сейчас, пишите в URL потом

Для текстовых полей и слайдеров обновляйте состояние через setState на каждое изменение и вызывайте setUrl() без аргументов по blur или после debounce. Компонент перерендеривается сразу; URL записывается один раз, с диффом по содержимому, поэтому повторные вызовы безопасны.

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

Читайте полное сравнение — одна фича в обеих библиотеках и как мигрировать

Состояние в URL в Next.js — частые вопросы

Как хранить состояние в URL в Next.js App Router?
Определите объект состояния по умолчанию вне компонента, оберните useUrlState из state-in-url/next в небольшой hook и вызывайте этот hook в любом клиентском компоненте. urlState — типизированное текущее значение, setUrl записывает partial в строку запроса. Передайте туда проп searchParams страницы, чтобы серверный рендер был правильным сразу.
Нужна ли useSearchParams граница Suspense, и нужна ли она state-in-url?
useSearchParams в Next.js переводит статически рендеримый роут в клиентский рендеринг до ближайшей границы Suspense, а без неё сборка падает. state-in-url его никогда не вызывает: он читает searchParams на сервере и window.location на клиенте, поэтому граница не нужна, а пререндеринг, включая PPR, сохраняется.
Как прочитать состояние из URL в Server Component?
Страницы получают его через проп searchParams — дождитесь его и либо пробросьте в клиентский hook, либо декодируйте на сервере через decodeState с тем же объектом по умолчанию. Layout не получают searchParams; вынесите строку запроса в заголовок, выставленный в proxy.ts, и декодируйте уже его.
Перерендеривает ли обновление URL страницу на сервере?
По умолчанию нет. setUrl обновляет через History API, поэтому ничего не запрашивается и запрос _rsc не уходит. Когда сервер должен увидеть новое состояние — скажем, чтобы перезапросить список в Server Component — передайте useHistory: false, и обновления пойдут через роутер Next.js, а роут перерендерится.
Является ли state-in-url альтернативой nuqs для Next.js?
Да. Обе библиотеки хранят типизированное состояние в строке запроса; state-in-url берёт один объект, сохраняя вложенные значения и даты, не требует компонента-адаптера и парсера на каждый ключ и никогда не трогает useSearchParams. nuqs подходит лучше, когда каждое значение должно быть отдельным читаемым query-параметром. Смотрите полное сравнение.
Какие версии Next.js поддерживаются?
Next.js 14, 15 и 16 на App Router, включая асинхронный searchParams, появившийся в 15, и cacheComponents с PPR в 16. В других конфигурациях можно использовать не зависящие от фреймворка хелперы encodeState и decodeState с любым роутером.