nuqs — URL Query를 React State처럼

nextjsreactroutingstate

난이도: ★★★☆☆
연관 노트: sessionStorage 대신 URL에 상태를 담는 이유


핵심 요약

  1. nuqs = URL query를 useState처럼 + 타입 파서 + isReady 추상화
  2. 기본값이 replace — 같은 페이지 내 상태 변경에 최적화. push로 명시하면 히스토리 추가
  3. Pages Router SSG의 hydration flash를 없애는 게 아니라 숨기는 것
  4. App Router에서 createSearchParamsCache로 서버값 주입 → flash 근본 해결
  5. 단순 진입 분기(?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" ↔ boolean
  • parseAsIsoDateTime — ISO 날짜 문자열 ↔ Date
  • parseAsArrayOf(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_userpush로 보낸 건 페이지 간 이동이었으니까 히스토리를 쌓는 게 맞았다.

같은 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 SSGApp 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 내부가 이 원리로 동작

참고