Next.js nuqs 쓰는 이유: URL 쿼리 상태를 useState처럼 다루기
nuqs는 URL 쿼리 문자열을 useState처럼 읽고 쓰게 해 주는 라이브러리다. Next.js 기본 도구로는 직접 챙겨야 하는 문자열 변환, 쿼리 조립, 서버 갱신 여부 선택, 업데이트 빈도 조절을 파서와 훅 하나로 묶어 준다.
상태를 URL에 두는 이유
-
상품 목록의 검색어, 정렬 기준, 페이지 번호 같은 상태를
useState에 두면 그 값은 컴포넌트 메모리에만 있다. -
그래서 새로고침하면 값이 초기화되고, 주소를 복사해 보내도 받은 사람에게는 같은 검색어와 페이지가 전해지지 않는다.
-
같은 값을
?q=shoes&page=2처럼 URL 쿼리 문자열에 두면 주소 자체가 상태를 담는다. -
주소가 상태를 담으므로 새로고침, 링크 공유, 북마크 뒤에도 URL에 담긴 검색어, 정렬, 페이지 상태를 복원할 수 있다.
-
다만 복원되는 것은 URL에 담긴 값뿐이고, 그 값으로 가져오는 데이터나 사용자 권한까지 같아지지는 않는다.
-
Next.js App Router는 쿼리 문자열을 다루는 도구로
useSearchParams와useRouter를 준다.
Next.js 기본 도구로 쿼리 상태를 다룰 때
-
useSearchParams는 현재 쿼리 문자열을 읽기 전용URLSearchParams객체로 돌려준다.// 주소가 /products?q=shoes&page=2일 때 const searchParams = useSearchParams() searchParams.get('q') // 'shoes' searchParams.get('page') // '2': 숫자도 문자열로 나온다 searchParams.get('sort') // null: 주소에 없는 키는 null이다 searchParams.set('page', '3') // Next.js 16 기준 ReadonlyURLSearchParamsError를 던진다 -
이 객체를 고쳐도 브라우저 주소창은 바뀌지 않고 React도 화면을 다시 그리지 않는다.
-
그래서 Next.js는
set같은 고치는 메서드를 막아 두고, 값을 바꾸려면 URL 자체를 바꾸게 한다. -
URL을 바꾸는 방법은 라우터 이동인
router.replace·router.push와, 브라우저 API인window.history.replaceState·pushState두 갈래다. -
Next.js는
history호출도 라우터에 통합해useSearchParams와 동기화하므로, 두 방법 모두 바뀐 쿼리가 화면에 반영된다. -
어느 쪽을 쓰든 기본 도구로 쿼리 상태를 다루면 아래 다섯 가지 불편함이 생긴다.
-
첫째로, 쿼리 값은 항상 문자열이라 숫자, 불리언, 배열이 필요할 때마다 변환과 예외 처리를 컴포넌트마다 반복한다.
// 주소: ?inStock=false&tags=sale,new const inStock = searchParams.get('inStock') === 'true' // 'false'도 빈 문자열이 아니어서 truthy이므로 문자열 비교로 바꾼다 const tags = (searchParams.get('tags') ?? '').split(',') // ['sale', 'new']: 배열은 직접 쪼갠다 -
둘째로,
router.replace나history.replaceState에 넘긴 쿼리 문자열은 기존 쿼리 전체를 덮어쓰므로, 한 키만 바꾸려 해도 매번 기존 쿼리를 복사해 조립해야 한다.// 현재 주소: ?q=shoes&sort=price&page=2에서 page만 3으로 바꾸고 싶다 router.replace('?page=3') // 결과 ?page=3: 넘긴 문자열이 쿼리 전체를 덮어써서 q와 sort가 사라진다 // 그래서 기존 쿼리를 복사한 뒤 page만 고쳐서 넘긴다 const params = new URLSearchParams(searchParams.toString()) params.set('page', '3') router.replace(`?${params.toString()}`) // 결과 ?q=shoes&sort=price&page=3 -
셋째로, 라우터 이동은 동적 페이지에서 Server Component 결과를 서버에 다시 요청할 수 있어서, 정렬처럼 클라이언트에서만 쓰는 쿼리도 불필요한
_rsc요청을 만들 수 있다.
-
실제 요청 수는 프리페치와 클라이언트 캐시에 따라 달라지므로, 클릭 수와 늘 같지는 않다.
-
history.replaceState를 직접 쓰면 이 요청은 피하지만, 서버 컴포넌트가 값을 알아야 하는 쿼리는 라우터 이동으로 바꿔야 하므로 쿼리마다 어느 쪽을 쓸지 골라 코드를 나눠야 한다. -
넷째로, 검색창처럼 키 입력마다 URL을 바꾸면 History API 호출이 몰리고, 브라우저는 짧은 시간에 너무 많이 호출하면 호출을 막는다.
-
예를 들어 Safari는 짧은 시간 안에 100번을 넘는
history.replaceState호출에SecurityError를 던진다(기준 시간은 WebKit 버전에 따라 10초나 30초로 보고된다). -
직접 디바운스 훅을 붙이면 호출 수는 줄지만, URL이 늦게 바뀌는 동안 입력창에 보일 값을 따로
useState로 들고 URL과 맞추는 코드가 검색창마다 붙는다. -
다섯째로, 서버 컴포넌트도
searchParams로 같은 쿼리를 읽는데 여기서도 값이 문자열이나 문자열 배열이라, 클라이언트와 같은 변환 코드를 서버 쪽에 한 벌 더 짜야 하고 두 쪽 규칙이 어긋날 수 있다. -
nuqs는 이 다섯 가지를 차례로 파서, 자기 키만 고치는 상태 업데이트 함수(setter),
shallow옵션, URL 업데이트 큐, 서버용 로더로 맡는다.
nuqs란
-
nuqs는 쿼리 문자열의 한 키를 React 상태처럼 쓰게 해 주는 훅 라이브러리이고, GitHub README는 스스로를 URL 쿼리 문자열에 저장되는
useState라고 소개한다. -
핵심 API는
useQueryState(key, parser)이며,useState처럼[값, 업데이트 함수]쌍을 돌려준다.const [q, setQ] = useQueryState('q') // ?q=shoes면 q는 'shoes', 키가 없으면 null setQ('boots') // 주소가 ?q=boots로 바뀌고 q도 'boots'가 된다 setQ(null) // 주소에서 q 키를 지운다 -
이 글은 nuqs 2.10.2와 Next.js App Router를 기준으로 하며, nuqs 2.x는 Next.js 14.2.0 이상을 지원한다.
-
nuqs는 프레임워크마다 URL을 바꾸는 방법이 달라서, Next.js App Router에서는 루트 레이아웃을
NuqsAdapter로 감싸 Next.js용 구현을 연결한다.// app/layout.tsx import { NuqsAdapter } from 'nuqs/adapters/next/app' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ko"> <body> {/* 이 아래의 useQueryState가 Next.js의 라우터와 History API를 쓰도록 연결한다 */} <NuqsAdapter>{children}</NuqsAdapter> </body> </html> ) } -
페이지 번호를 읽고 하나 올리는 코드를 기본 도구와 nuqs로 나란히 쓰면 아래와 같다.
// before: Next.js 기본 도구 (컴포넌트 본문 안) const router = useRouter() const searchParams = useSearchParams() const parsed = parseInt(searchParams.get('page') ?? '') const page = Number.isNaN(parsed) ? 1 : parsed // 첫째: 문자열을 숫자로 바꾸고 기본값을 직접 채운다 function goNext() { const params = new URLSearchParams(searchParams.toString()) // 둘째: 다른 키를 지키려고 복사한다 params.set('page', String(page + 1)) router.replace(`?${params.toString()}`) // 셋째: 동적 페이지면 서버 요청이 생길 수 있다 } // after: nuqs (컴포넌트 본문 안) const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)) // 변환과 기본값은 파서가 맡는다 function goNext() { setPage(page + 1) // 다른 키는 그대로 두고, 기본으로 서버 요청 없이 주소만 고친다 } -
nuqs 쪽 코드에는 문자열 변환, 쿼리 복사, URL을 바꿀 API 선택을 다루는 줄이 없다.
해결1: 파서가 문자열 변환을 맡는다
-
Next.js 기본 도구에서는 쿼리 값이 항상 문자열이라 숫자, 불리언, 배열로 바꾸는 코드를 컴포넌트마다 반복했다.
-
nuqs에서는 이 변환을 파서가 맡으며, 파서는 URL의 문자열을 값으로 바꾸는
parse와 값을 다시 문자열로 바꾸는serialize를 한 쌍으로 묶은 객체다.parseAsInteger.parse('3') // 3: 주소에서 읽은 문자열을 숫자로 바꾼다 parseAsInteger.serialize(3) // '3': 숫자를 주소에 쓸 문자열로 바꾼다 -
parseAsInteger의parse는parseInt로 숫자를 만들고, 결과가NaN이면null을 돌려준다. -
null이 나오면withDefault(1)로 정한 기본값이 대신 쓰인다. -
같은 파서에 여러 주소를 넣으면
page값은 아래처럼 정해진다.const [page] = useQueryState('page', parseAsInteger.withDefault(1)) // ?page=3 -> 3 // ?page=abc -> 1: parse가 null을 돌려줘 기본값을 쓴다 // page 키 없음 -> 1: 읽을 문자열이 없어 기본값을 쓴다 // ?page=-3 -> -3: 범위는 검사하지 않는다 // ?page=2abc -> 2: parseInt가 앞쪽 숫자만 읽는다 -
기본값이 있으면 값이
null일 수 없으므로 TypeScript도page를number로 추론한다. -
반대 방향으로, 업데이트 함수로 기본값과 같은 값을 넣으면
clearOnDefault옵션(2.x 기본값true) 때문에page키가 URL에서 지워진다.// 현재 주소: ?q=shoes&page=2 setPage(3) // ?q=shoes&page=3 setPage(1) // ?q=shoes: 기본값 1과 같아서 page 키를 지운다 -
이미 주소에 있던
?page=1을 읽기만 할 때는 지우지 않는다. -
숫자 말고 다른 타입은 다른 내장 파서가 맡는다.
parseAsBoolean // ?inStock=true -> true, ?inStock=yes -> false: 'true'와 같은지만 본다 parseAsArrayOf(parseAsString) // ?tags=sale,new -> ['sale', 'new']: 쉼표로 나눈다 parseAsStringLiteral(['asc', 'desc'] as const) // ?sort=asc -> 'asc', ?sort=up -> null: 목록에 없는 값은 버린다 -
앞 예시의
-3과2abc처럼parseAsInteger는 숫자 변환만 하고 범위는 검사하지 않는다. -
범위 검사가 필요하면
createParser로 범위를 벗어난 값에null을 돌려주는 파서를 직접 만든다.import { createParser } from 'nuqs' const parseAsPage = createParser({ parse: (v) => { const n = parseInt(v) // 1보다 작거나 NaN이면 null을 돌려줘 withDefault의 기본값으로 떨어지게 한다 return n >= 1 ? n : null }, serialize: (n: number) => String(n), // 값을 주소에 쓸 문자열로 바꾼다 }) const [page] = useQueryState('page', parseAsPage.withDefault(1)) // ?page=-3 -> 1 -
형식이 복잡한 값은 파서 결과를 Zod 같은 스키마 라이브러리로 한 번 더 검사할 수도 있다.
해결2: 업데이트 함수는 자기 키만 고친다
-
Next.js 기본 도구에서는
router.replace나history.replaceState에 넘긴 문자열이 쿼리 전체를 덮어써서, 한 키를 바꿀 때마다 기존 쿼리를 복사해 조립했다. -
nuqs의 업데이트 함수는 이 복사와 조립을 안에서 대신한다.
-
2.10.2 소스를 보면 nuqs는 URL을 쓰기 직전에 현재 주소의 쿼리를 통째로 가져오고, 그 위에 바뀐 키만
set하거나delete한다. -
그래서 나머지 키는 손대지 않은 채로 남는다.
// 현재 주소: ?q=shoes&sort=price&page=2 const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)) setPage(3) // ?q=shoes&sort=price&page=3: q와 sort는 그대로 남는다 -
같은 순간에 여러 업데이트 함수를 불러도 nuqs는 바뀐 키를 큐에 모았다가 한 번의 URL 쓰기로 합친다.
-
여러 키를 처음부터 묶어 다루고 싶으면
useQueryStates를 쓴다.const [{ q, page }, setParams] = useQueryStates({ q: parseAsString.withDefault(''), page: parseAsInteger.withDefault(1), }) // 검색어를 바꾸면서 페이지를 1로 되돌린다. page는 기본값과 같아 주소에서 지워진다 setParams({ q: 'boots', page: 1 })
해결3: shallow 옵션으로 서버 갱신 여부를 고른다
-
Next.js 기본 도구에서는 라우터 이동을 쓰면 불필요한
_rsc요청이 생길 수 있었고,history.replaceState를 쓰면 쿼리마다 어느 쪽을 쓸지 직접 골라야 했다. -
nuqs의
shallow옵션은 기본값이true이고, 이때 URL만 고치고 서버에는 알리지 않는다. -
Next.js App Router 어댑터 소스를 보면
shallow: true일 때router.replace를 부르지 않고 브라우저의historyAPI만 직접 부른다. -
이때 부를 메서드는
history옵션이 정하며, 기본값'replace'면history.replaceState로 현재 기록을 덮어써 뒤로 가기 기록이 늘지 않고,'push'면history.pushState로 기록을 하나씩 쌓는다. -
즉
shallow는 서버를 다시 렌더링할지를,history는 방문 기록을 어떻게 남길지를 정하는 별개의 옵션이다. -
Next.js는
history호출을useSearchParams와 동기화하므로,useQueryState값은 바로 바뀌지만 서버 요청은 생기지 않는다. -
스크롤도 기본으로 움직이지 않는다(
scroll기본값false). -
대가는 서버 컴포넌트가 바뀐 쿼리를 모른다는 것이다.
-
페이지 컴포넌트가
searchParams로 쿼리를 읽어 데이터를 가져온다면 그 값은 갱신되지 않는다. -
이럴 때는
shallow: false를 주며, 그러면 어댑터가history호출 뒤에router.replace(url, { scroll: false })를 불러 서버 컴포넌트를 다시 렌더링하게 한다.
// 컴포넌트 본문 안: page가 바뀌면 서버 컴포넌트도 새 page로 다시 그려진다 const [page, setPage] = useQueryState( 'page', parseAsInteger.withDefault(1).withOptions({ shallow: false }), ) -
서버 요청 없이 URL을 바꾸는 방법 자체는 기본 도구에도 있고, nuqs는 그 선택을 키마다 옵션 하나로 바꾸게 해 준다.
해결4: URL 쓰기를 모아서 내보낸다
-
Next.js 기본 도구에서는 키 입력마다 History API를 부르면 브라우저 제한에 걸릴 수 있었고, 직접 디바운스를 붙이면 입력창용 상태를 따로 들어야 했다.
-
nuqs는 업데이트 함수가 불릴 때마다 URL을 바로 쓰지 않고, 업데이트를 큐에 모았다가 일정 간격으로 내보낸다.
-
그래서 업데이트 함수를 부른 횟수와 실제 History API 호출 횟수는 다를 수 있다.
-
2.10.2 소스 기준 기본 간격은 50ms이고, Safari 17 이상은 120ms, 그 이전 Safari는 320ms를 쓴다.
-
간격 안에 여러 번 값을 바꾸면 첫 업데이트는 바로 URL에 쓰이고, 그 뒤 값들은 모였다가 마지막 값으로 한 번 쓰인다.
-
이때 늦춰지는 것은 URL 쓰기뿐이고 훅이 돌려주는 값은 호출 즉시 바뀌므로, 입력창용
useState를 따로 두지 않아도 입력창이 밀리지 않는다. -
검색어처럼
shallow: false로 서버 요청까지 일으키는 값은limitUrlUpdates: debounce(300)을 주어 입력이 멈춘 뒤 한 번만 URL과 서버를 갱신하게 할 수 있다.import { debounce, parseAsString, useQueryState } from 'nuqs' // 컴포넌트 본문 안에서 부른다 const [q, setQ] = useQueryState( 'q', parseAsString.withDefault('').withOptions({ shallow: false, // 서버 컴포넌트가 q로 검색 결과를 가져온다 limitUrlUpdates: debounce(300), // 키우면 서버 요청이 줄고 결과가 늦게 뜨며, 줄이면 그 반대가 된다 }), ) -
limitUrlUpdates와debounce는 2.5.0부터 있고, 그 전에는throttleMs를 썼다.
해결5: 서버 컴포넌트도 같은 파서로 읽는다
-
Next.js 기본 도구에서는 서버 컴포넌트가 읽는 쿼리도 문자열이라, 클라이언트와 같은 변환 코드를 서버 쪽에 한 벌 더 짜야 했다.
-
Next.js 15부터 페이지는
searchParams를Promise<{ [key: string]: string | string[] | undefined }>로 받고, Promise를 풀면 키마다 문자열, 같은 키가 반복되면 문자열 배열, 없으면undefined가 들어 있다. -
nuqs는 같은 파서 객체로 서버에서 쿼리를 읽는
createLoader와createSearchParamsCache를 준다. -
서버와 클라이언트가 함께 쓰는 파서 파일은
'use client'없이 두고, 파서를nuqs가 아닌nuqs/server에서 가져온다. -
2.10.2 소스에서
nuqs진입 파일은 첫 줄이"use client"라서, 여기서 가져온 파서는 서버 컴포넌트에서 실제 함수로 쓸 수 없기 때문이다.// app/products/search-params.ts ('use client' 없음) import { createLoader, parseAsInteger, parseAsString } from 'nuqs/server' // 서버에서도 실행되도록 nuqs/server에서 가져온다 // 서버와 클라이언트가 함께 쓰는 쿼리 규칙 export const productParsers = { q: parseAsString.withDefault(''), page: parseAsInteger.withDefault(1), } // 서버 컴포넌트에서 searchParams를 같은 규칙으로 읽는 함수 export const loadProductParams = createLoader(productParsers) -
로더는 Promise인
searchParams를 그대로 받는다.// app/products/page.tsx import type { SearchParams } from 'nuqs/server' import { loadProductParams } from './search-params' export default async function Page({ searchParams }: { searchParams: Promise<SearchParams> }) { // 클라이언트의 useQueryStates(productParsers)와 같은 타입, 같은 기본값을 얻는다 const { q, page } = await loadProductParams(searchParams) // q와 page로 데이터를 가져와 그린다 return <p>{q} 검색 결과 {page}페이지</p> } -
클라이언트에서는 같은
productParsers를useQueryStates(productParsers)에 넘기므로 기본값이나 타입을 바꿀 때 한 곳만 고치면 된다. -
페이지보다 깊은 서버 컴포넌트에서 값을 읽어야 하면
createSearchParamsCache로 페이지에서 한 번 파싱하고 하위 컴포넌트에서get으로 꺼낸다.
정리
- 검색어, 필터, 페이지 번호를 URL에 두면 새로고침과 링크 공유 뒤에도 URL에 담긴 상태를 복원할 수 있다.
- Next.js 기본 도구만 쓰면 문자열 변환, 쿼리 복사와 조립, 라우터 이동과
historyAPI 중 무엇을 쓸지 고르는 일, History API 호출 빈도, 서버 컴포넌트의 중복 변환을 컴포넌트마다 직접 챙겨야 한다. - 문자열 변환은 파서가 맡아,
useQueryState가 타입 있는 값과 기본값을 돌려준다. - 쿼리 복사와 조립은 업데이트 함수가 맡아, 현재 쿼리 위에 자기 키만 고친다.
- 서버 갱신 여부는
shallow옵션이 정하며, 기본값true는historyAPI만 불러 서버 요청을 건너뛰고false는router.replace로 서버 컴포넌트까지 갱신한다. - 호출 빈도는 URL 업데이트 큐가 브라우저 제한 안으로 맞추고, 필요하면
debounce로 서버 요청까지 줄인다. nuqs/server에서 가져온 같은 파서 객체를createLoader에 넘기면 서버 컴포넌트도 클라이언트와 같은 규칙으로 쿼리를 읽는다.- 다만
parseAsInteger같은 내장 파서는 범위를 검사하지 않으므로, 범위가 중요한 값은 직접 만든 파서나 스키마 검증으로 걸러야 한다.