React & Next.js용 타입 URL 상태 — useState처럼
useUrlState 는 자신을 쿼리 문자열에 기록하는 React 상태입니다. 객체, 배열, 날짜는 타입을 유지하고, 모든 상태는 공유 가능한 링크가 되며, 새로고침 후에도 유지되고 뒤로 가기 버튼도 동작합니다 — 프로바이더도, Suspense 경계도, 보일러플레이트도 필요 없습니다.
- ~2 KB gzip 압축
- 의존성 없음
- TypeScript 우선
- Next.js / react-router / Remix / Astro
- MIT
npm i state-in-urlNext.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의 값이 표시되므로 깜빡임도, 하이드레이션 경고도 없습니다.
// 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 })}
/>
);
}Suspense 경계 없이, 사전 렌더링은 그대로
이 훅은 useSearchParams 을(를) 호출하지 않으므로, 이를 사용하는 컴포넌트는 Suspense 로 감쌀 필요가 없고, 페이지가 사전 렌더링에서 제외되지도 않습니다. PPR과 cacheComponents 도 포함됩니다. URL을 직접 읽고 이후의 모든 변경을 따라갑니다. 예를 들어 history.pushState 을(를), 그 존재조차 모르는 코드에서 호출한 경우도 따라갑니다. 사전 렌더링된 페이지는 여전히 기본값을 렌더링합니다. 빌드 시점에는 쿼리 문자열이 없기 때문입니다 — 공유 링크가 첫 페인트부터 정확해야 한다면 해당 라우트를 동적으로 렌더링하세요.
레이아웃: 헤더에서 쿼리 문자열 디코딩하기
서버 레이아웃은 searchParams를 받지 않습니다. proxy.ts에서 쿼리 문자열을 요청 헤더에 복사하고(middleware.ts도 사용 중단된 별칭으로 여전히 동작합니다), 레이아웃에서 decodeState에 같은 기본 상태 객체를 넘겨 디코딩하세요 — 결과는 클라이언트의 urlState와 정확히 같은 타입입니다.
// 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}</>;
}히스토리, 얕은 업데이트, scroll 옵션
setUrl은 기본적으로 현재 히스토리 항목을 교체하므로, 타이핑해도 항목이 쌓이지 않습니다. 항목을 push하려면 replace: false를 넘기세요. 업데이트는 History API를 통해 이루어집니다 — 서버 왕복도, 키 입력마다 _rsc 요청도 없습니다. 변경할 때마다 서버가 다시 렌더링해야 한다면 useHistory: false를 넘겨 대신 Next.js 라우터를 거치게 하세요. scroll은 기본값이 false입니다.
빠른 입력: 지금 렌더링하고, URL은 나중에 기록
텍스트 필드와 슬라이더는 변경할 때마다 setState로 갱신하고, blur 시점이나 디바운스 후에 인자 없이 setUrl()을 호출하세요. 컴포넌트는 즉시 다시 렌더링되고, URL은 내용 기반 diff로 한 번만 기록되므로 반복 호출해도 안전합니다.
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 defaultNext.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 헬퍼를 원하는 라우터와 함께 사용할 수 있습니다.
