nuqs — URL Query를 React State처럼
난이도: ★★★☆☆
연관 노트: sessionStorage 대신 URL에 상태를 담는 이유
핵심 요약
- nuqs = URL query를 useState처럼 + 타입 파서 + isReady 추상화
- 기본값이 replace — 같은 페이지 내 상태 변경에 최적화. push로 명시하면 히스토리 추가
- Pages Router SSG의 hydration flash를 없애는 게 아니라 숨기는 것
- App Router에서
createSearchParamsCache로 서버값 주입 → flash 근본 해결 - 단순 진입 분기(
?from=...)는 직접 구현이 더 적합 — nuqs 도입 비용 불필요
nuqs란
“URL query param을 React state처럼” 다루는 라이브러리.
우리가 직접 구현한 패턴:
const isFromReturningUser = router.query.from === "returning_user";
nuqs를 쓰면:
const [from] = useQueryState('from');
const isFromReturningUser = from === "returning_user";
같은 개념의 productionized 버전이다. 이 노트는 nuqs가 내부적으로 어떤 문제를 해결하는지, 그리고 Next.js 라우터 아키텍처와 어떻게 맞물리는지를 다룬다.
1. nuqs가 해결하는 문제들
문제 1: router.isReady 타이밍
정적 페이지에서 router.query는 hydration 후에야 채워진다 (자세한 원리 →).
직접 구현하면:
// 매번 이걸 처리해야 함
if (!router.isReady) return <Skeleton />;
const value = router.query.tab as string;
nuqs는 이걸 내부에서 처리:
const [tab] = useQueryState('tab');
// router.isReady 전엔 null 반환, 이후 자동 업데이트
문제 2: 타입 불안정
router.query의 타입은 string | string[] | undefined. 매번 타입 가드 필요.
// 직접 구현
const page = typeof router.query.page === 'string'
? Number(router.query.page)
: 1;
// nuqs - 파서가 타입을 보장
const [page] = useQueryState('page', parseAsInteger.withDefault(1));
// page의 타입: number (undefined 없음, withDefault 덕분)
내장 파서 목록:
parseAsString— 기본parseAsInteger— 정수parseAsFloat— 실수parseAsBoolean—"true"/"false"↔ booleanparseAsIsoDateTime— ISO 날짜 문자열 ↔ DateparseAsArrayOf(parser)— 배열parseAsJson(schema)— JSON (zod 스키마 연동 가능)
문제 3: 여러 파라미터 동시 변경 시 히스토리 오염
// 직접 구현: push가 2번 → 히스토리 엔트리 2개 생성
router.push({ query: { ...router.query, sort: 'asc' } });
router.push({ query: { ...router.query, page: '1' } });
// nuqs: useQueryStates로 묶어서 한 번에 push
const [params, setParams] = useQueryStates({
sort: parseAsString,
page: parseAsInteger.withDefault(1),
});
setParams({ sort: 'asc', page: 1 }); // 히스토리 엔트리 1개
2. push vs replace — nuqs의 기본값은 replace
nuqs의 기본값은 replace다. push가 아니다.
// 기본: replace (히스토리 교체)
const [tab, setTab] = useQueryState('tab');
setTab('review'); // history.replaceState
// push로 변경하려면 명시
setTab('review', { history: 'push' }); // history.pushState
왜 기본이 replace인가?
nuqs는 주로 같은 페이지 안에서 필터/정렬/탭 전환에 쓰인다. 탭을 누를 때마다 히스토리 엔트리가 쌓이면 뒤로가기 UX가 지저분해진다.
반면 우리 코드에서 /?from=returning_user를 push로 보낸 건 페이지 간 이동이었으니까 히스토리를 쌓는 게 맞았다.
같은 API, 다른 쓰임새에 따라 기본값 설계가 달라진 것 (pushState vs replaceState 원리 →).
3. Pages Router vs App Router — nuqs의 한계와 해결
Pages Router (SSG)
nuqs도 빌드타임 제약을 피할 수 없다. router.query = {}로 시작하는 건 동일.
nuqs가 하는 것: router.isReady 추상화 + 기본값 처리. hydration flash가 사라지는 게 아니라, 코드에서 직접 다룰 필요가 없어지는 것이다.
빌드타임 HTML: query = {}
↓
hydration render: nuqs → null (or default) 반환
↓
router.isReady: nuqs → 실제 query값으로 업데이트 (리렌더)
App Router — 근본적 해결
App Router에서 page.tsx는 Server Component이고, searchParams를 요청 시점에 받는다.
// app/home/page.tsx — Server Component
export default function Page({
searchParams,
}: {
searchParams: { from?: string }
}) {
// 서버가 요청 시점에 searchParams를 이미 알고 있음
}
nuqs는 이 값을 클라이언트 초기값으로 주입하는 API(createSearchParamsCache)를 제공한다:
import { createSearchParamsCache, parseAsString } from 'nuqs/server';
export const searchParamsCache = createSearchParamsCache({
from: parseAsString,
});
export default async function Page({ searchParams }) {
const { from } = searchParamsCache.parse(searchParams);
// 서버에서 parse → 클라이언트에 초기값으로 주입
// 서버 HTML이 처음부터 올바른 값을 담고 있음 → flash 없음
}
비교
| Pages Router SSG | App Router | |
|---|---|---|
| 서버가 query를 아는 시점 | 빌드 타임 (모름) | 요청 시점 (앎) |
| nuqs의 역할 | isReady 추상화 | 서버값 주입으로 flash 근본 해결 |
| hydration flash | 여전히 존재 (코드에서 안 보일 뿐) | 없음 |
빌드타임 제약은 nuqs가 없애는 게 아니다. App Router로 가면 아키텍처 자체가 요청 시점 렌더링으로 바뀌기 때문에 제약이 사라진다. nuqs는 그 위에서 DX를 개선하는 것.
4. 언제 nuqs를 쓰고, 언제 직접 구현하나
| 상황 | 선택 |
|---|---|
단순 진입 경로 분기 (?from=...) | router.query 직접 — nuqs 도입 비용 불필요 |
| 필터 / 정렬 / 탭 상태 URL 동기화 | nuqs useQueryState |
| 여러 파라미터 동시 변경 | nuqs useQueryStates |
| 타입 변환 필요 (숫자, 날짜, 배열) | nuqs 파서 |
| App Router + 서버에서 초기값 필요 | nuqs createSearchParamsCache |
우리가 만든 ?from=returning_user는 단방향으로 읽기만 하는 단순한 경우. nuqs 없이 직접 쓴 게 맞는 선택이었다.
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- sessionStorage 대신 URL에 상태를 담는 이유 — URL query param을 직접 다루는 원리. nuqs는 이걸 추상화한 것
- URL Encoding (RFC 3986) — query param 값에 특수문자/한글이 있을 때 encodeURIComponent로 처리해야 하는 이유. nuqs 파서가 내부적으로 처리
→ 확장 개념 (여기서 더 나아가면)
useTransition— nuqs가 상태 업데이트 시 UI 블로킹을 막기 위해 내부에서 사용. React 18 concurrent 기능- Zod +
parseAsJson— URL에 복잡한 구조 저장. 필터 상태가 복잡해질 때
↔ 같은 원리가 적용되는 곳
- React Query
queryKey— URL query param처럼 “외부 상태를 React state로 동기화”하는 패턴 useSyncExternalStore— React가 외부 상태 소스를 구독하는 공식 API. nuqs 내부가 이 원리로 동작