React & Next.js용 타입 URL 상태 — useState처럼

useUrlState 는 자신을 쿼리 문자열에 기록하는 React 상태입니다. 객체, 배열, 날짜는 타입을 유지하고, 모든 상태는 공유 가능한 링크가 되며, 새로고침 후에도 유지되고 뒤로 가기 버튼도 동작합니다 — 프로바이더도, Suspense 경계도, 보일러플레이트도 필요 없습니다.

  • ~2 KB gzip 압축
  • 의존성 없음
  • TypeScript 우선
  • Next.js / react-router / Remix / Astro
  • MIT
npm i state-in-url

Next.js App Router의 URL 상태 관리

state-in-url은 Next.js 14, 15, 16에서 타입 상태를 쿼리 문자열에 저장합니다: 기능마다 useUrlState 훅 하나, 어댑터도, 프로바이더도, Suspense 경계도 없습니다. 이 페이지는 App Router에 특화된 내용을 다룹니다 — Server Component, 사전 렌더링, 레이아웃, 히스토리.

라이브 데모는 홈 페이지에 있으며 Next.js 16에서 실행됩니다.

서버 페이지에서 searchParams 전달하기

Server Component 페이지는 searchParams를 받습니다 — Next.js 15부터는 Promise입니다. await한 뒤 그 객체를 클라이언트 컴포넌트에 넘기고, 클라이언트 컴포넌트는 이를 훅에 전달합니다. 그러면 첫 서버 렌더링부터 기본값 대신 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 경계 없이, 사전 렌더링은 그대로

이 훅은 useSearchParams 을(를) 호출하지 않으므로, 이를 사용하는 컴포넌트는 Suspense 로 감쌀 필요가 없고, 페이지가 사전 렌더링에서 제외되지도 않습니다. PPR과 cacheComponents 도 포함됩니다. URL을 직접 읽고 이후의 모든 변경을 따라갑니다. 예를 들어 history.pushState 을(를), 그 존재조차 모르는 코드에서 호출한 경우도 따라갑니다. 사전 렌더링된 페이지는 여전히 기본값을 렌더링합니다. 빌드 시점에는 쿼리 문자열이 없기 때문입니다 — 공유 링크가 첫 페인트부터 정확해야 한다면 해당 라우트를 동적으로 렌더링하세요.

레이아웃: 헤더에서 쿼리 문자열 디코딩하기

서버 레이아웃은 searchParams를 받지 않습니다. proxy.ts에서 쿼리 문자열을 요청 헤더에 복사하고(middleware.ts도 사용 중단된 별칭으로 여전히 동작합니다), 레이아웃에서 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}</>;
}

히스토리, 얕은 업데이트, scroll 옵션

setUrl은 기본적으로 현재 히스토리 항목을 교체하므로, 타이핑해도 항목이 쌓이지 않습니다. 항목을 push하려면 replace: false를 넘기세요. 업데이트는 History API를 통해 이루어집니다 — 서버 왕복도, 키 입력마다 _rsc 요청도 없습니다. 변경할 때마다 서버가 다시 렌더링해야 한다면 useHistory: false를 넘겨 대신 Next.js 라우터를 거치게 하세요. scroll은 기본값이 false입니다.

빠른 입력: 지금 렌더링하고, URL은 나중에 기록

텍스트 필드와 슬라이더는 변경할 때마다 setState로 갱신하고, blur 시점이나 디바운스 후에 인자 없이 setUrl()을 호출하세요. 컴포넌트는 즉시 다시 렌더링되고, URL은 내용 기반 diff로 한 번만 기록되므로 반복 호출해도 안전합니다.

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

전체 비교 읽기 — 같은 기능을 두 라이브러리로 구현, 마이그레이션 방법까지

Next.js URL 상태 — 자주 묻는 질문

Next.js App Router에서 상태를 URL에 저장하려면 어떻게 하나요?
컴포넌트 밖에서 기본 상태 객체를 정의하고, state-in-url/next의 useUrlState를 작은 훅으로 감싼 뒤, 아무 클라이언트 컴포넌트에서나 그 훅을 호출하세요. urlState는 타입이 지정된 현재 값이고, setUrl은 partial을 쿼리 문자열에 기록합니다. 페이지의 searchParams prop을 넘기면 서버 렌더링부터 이미 올바른 값이 됩니다.
useSearchParams에는 Suspense 경계가 필요한가요? state-in-url도 그런가요?
Next.js의 useSearchParams는 정적으로 렌더링되는 라우트를 가장 가까운 Suspense 경계까지 클라이언트 렌더링으로 전환하며, 경계가 없으면 빌드가 실패합니다. state-in-url은 이를 호출하지 않습니다: 서버에서는 searchParams를, 클라이언트에서는 window.location을 읽으므로 경계가 필요 없고, PPR을 포함한 사전 렌더링이 유지됩니다.
Server Component에서 URL 상태를 읽으려면 어떻게 하나요?
페이지는 searchParams prop으로 받습니다 — await한 뒤 클라이언트 훅에 전달하거나, 서버에서 decodeState에 같은 기본 객체를 넘겨 디코딩하세요. 레이아웃은 searchParams를 받지 않으므로, proxy.ts에서 설정한 헤더로 쿼리 문자열을 노출하고 그것을 디코딩하세요.
URL을 업데이트하면 서버에서 페이지가 다시 렌더링되나요?
기본적으로는 아닙니다. setUrl은 History API로 업데이트하므로 아무것도 가져오지 않고 _rsc 요청도 발생하지 않습니다. 서버가 새 상태를 봐야 한다면 — 예를 들어 Server Component에서 목록을 다시 가져와야 할 때 — useHistory: false를 넘기세요. 그러면 업데이트가 Next.js 라우터를 거치고 라우트가 다시 렌더링됩니다.
state-in-url은 Next.js용 nuqs 대안인가요?
네. 둘 다 타입 상태를 쿼리 문자열에 저장합니다. state-in-url은 중첩 값과 날짜가 유지되는 객체 하나를 받고, 어댑터 컴포넌트도 키별 파서도 필요 없으며, useSearchParams를 전혀 건드리지 않습니다. 값마다 사람이 읽기 쉬운 쿼리 파라미터가 되어야 한다면 nuqs가 더 잘 맞습니다. 전체 비교를 참고하세요.
지원하는 Next.js 버전은 무엇인가요?
App Router 기준 Next.js 14, 15, 16을 지원하며, 15에서 도입된 비동기 searchParams와 16의 PPR을 포함한 cacheComponents도 지원합니다. 다른 구성에서는 프레임워크에 독립적인 encodeState, decodeState 헬퍼를 원하는 라우터와 함께 사용할 수 있습니다.