🔗

Next.jsでのURL状態管理を楽にするnuqsの使い方

に公開

Next.jsでフロント開発をしていると、「状態をURLに載せたい」という場面がよくあります。

  • 検索条件を共有したい
  • 戻る/進むで状態を復元したい
  • ページをリロードしても結果を維持したい

これを素直に実装すると useSearchParamsrouter.replace を手書きすることになり、煩雑さや型崩れに悩まされます。

そこで便利なのが nuqs

https://nuqs.dev/

useStateライクなAPIでURLと状態を同期し、型安全かつ履歴制御も柔軟に扱えます。
この記事では、検索フォームやフィルタ、タブ切り替え、モーダルなどの実際によく出てくる場面を例にnuqsの使い方を見ていきます。

補足:TanStack Router を使っている場合

TanStack Router を採用している場合、検索パラメータは標準で JSON パース / 型安全な検証 / スキーマ連携 が揃っており、URL状態管理だけが目的なら nuqs を追加しなくても十分です。

一方で、既に他プロジェクトで nuqs の API(useQueryState/useQueryStates)に慣れている、あるいは Next.js/Remix/React Router と書き味を統一したい場合には、nuqs v2 の TanStack Router アダプターを使うのも手です。

導入方法

https://github.com/47ng/nuqs?tab=readme-ov-file#nuqs

nuqs をインストールした後、例えば、Next.js (App Router) では、下記のようにレイアウトにアダプターを設定する必要があります。

// app/layout.tsx
import { NuqsAdapter } from "nuqs/adapters/next/app";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <NuqsAdapter>{children}</NuqsAdapter>
      </body>
    </html>
  );
}

使い方サンプル

"use client";
import { useQueryState } from "nuqs";

export default function Counter() {
  const [count, setCount] = useQueryState("count", { defaultValue: 0 });

  return (
    <div>
      <button onClick={() => setCount(c => c - 1)}>-</button>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>+</button>
    </div>
  );
}
  • URLに ?count=1 が同期されます
  • null を渡すとキーごと削除されます
  • defaultValue はURLに表示されず、省略可能

ケース①:検索フォーム × サーバ再描画

検索フォームの入力内容をURLに載せたいケースです。
shallow:false にすると サーバー再描画(RSC) が走り、検索結果が最新化されます。

const [q, setQ] = useQueryState("q", {
  defaultValue: "",
  shallow: false,
  history: "replace",
  clearOnDefault: true,
});
  • 入力中に無駄にリクエストを飛ばさないように debouncethrottle オプションを使うと良い
  • クリア時は null をセットしてクエリを消すとURLがすっきり

ケース②:複数条件フィルタ

カテゴリ、並び順などをまとめて管理したいときは useQueryStates が便利です。

const [filters, setFilters] = useQueryStates(
  {
    category: parseAsString.withDefault("all"),
    sort: parseAsString.withDefault("desc"),
  },
  { urlKeys: { category: "c", sort: "s" } }
);
  • URL上は ?c=books&s=asc のように短縮
  • コード上は filters.category / filters.sort と意味ある名前で扱える
  • history:"push" にすると、戻るボタンでフィルタ操作を1ステップずつ追える

ケース③:タブ切り替え

タブ状態をURLに同期させると、リンク共有やリロード時の復元が自然にできます。

"use client";
import { useQueryState } from "nuqs";

export default function TabExample() {
  const [tab, setTab] = useQueryState<"details" | "reviews">("tab", {
    defaultValue: "details",
  });

  return (
    <div>
      <button onClick={() => setTab("details")}>詳細</button>
      <button onClick={() => setTab("reviews")}>レビュー</button>
      {tab === "details" ? <Details /> : <Reviews />}
    </div>
  );
}
  • URLに ?tab=reviews が出るので リンク共有可能
  • 戻る/進むでも状態が復元される

ケース④:モーダル

モーダルもURLと同期させることで、直接リンクから開いたり、戻るボタンで閉じたりできます。

"use client";
import { useQueryState } from "nuqs";

export default function ModalExample() {
  const [modal, setModal] = useQueryState("modal", {
    defaultValue: "",
    history: "push", // 履歴を残す
    clearOnDefault: true,
  });

  return (
    <div>
      <button onClick={() => setModal("123")}>商品詳細を開く</button>

      {modal && (
        <div className="modal">
          <h2>商品 {modal} の詳細</h2>
          <button onClick={() => setModal(null)}>閉じる</button>
        </div>
      )}
    </div>
  );
}
  • ?modal=123 で直接開ける(ディープリンク対応)
  • 「閉じる」を押すと setModal(null) → クエリ削除
  • history:"push" を使うと「閉じる」で戻るボタンが効く

パーサと型安全

nuqsはパーサを使って型安全に扱えます。

  • parseAsInteger → 数値
  • parseAsBoolean → 真偽値
  • parseAsArray → 配列
  • parseAsJson → JSON
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));

withDefault を付けることで 型と既定値を一緒に管理できます。
tRPCやフォームバリデーションと組み合わせると堅牢になります。

サーバ側での取り回し

createLoader で検索パラメータの定義を1か所にまとめ、サーバーコンポーネントやAPIから同じ定義を呼び出せます。Strict モードはローダー呼び出し時に指定します。

// search-params.ts
import { createLoader, parseAsInteger, parseAsString } from "nuqs/server";

export const loadSearchParams = createLoader({
  q: parseAsString.withDefault(""),
  page: parseAsInteger.withDefault(1),
});
// app/page.tsx(App Router の例)
import { loadSearchParams } from "./search-params";

export default async function Page({ searchParams }: { searchParams: URLSearchParams | Promise<URLSearchParams> }) {
  const { q, page } = await loadSearchParams(searchParams, { strict: true });
  // ...
}
  • 不正な値の排除は loadSearchParams(…, { strict: true }) の呼び出し時に有効化
  • 深いRSCで共有したい場合は createSearchParamsCache(loadSearchParams) を使い、cache.parse(searchParams, { strict: true }) のように同様のオプションを渡せます

SEOと正規化

検索エンジンは URL のクエリパラメータの順序が違うだけでも別ページと認識することがあります。

例:

  • /search?q=book&page=2
  • /search?page=2&q=book

人間にとっては同じ検索結果ページですが、検索エンジン的には重複ページ扱いになり、SEO 的に不利になる可能性があります。

そこで nuqs の processUrlSearchParams を使うと、クエリキーの順序を自動でソートし、常に一定のURL形式に正規化できます。

import { processUrlSearchParams } from "nuqs/server";

const url = processUrlSearchParams("/search?page=2&q=book");
// => "/search?q=book&page=2"

このようにして クエリ順序を統一しておくと、検索エンジンにもユーザーにも扱いやすいURLを維持できます。

テスト

NuqsTestingAdapter を使えば、テスト環境でURLを偽装できます。

import { NuqsTestingAdapter } from "nuqs/adapters/testing";
  • 初期クエリを与えて状態を確認
  • イベント後にURLが正しく更新されるかをアサート可能

まとめ

nuqsを使うことで「URL=状態」という設計をシンプルに実現でき、検索フォームや複数条件フィルタ、モーダルやタブといった実務で頻出するパターンに対応しやすくなります。

導入の際には、その状態が本当にURLに載せるべきものかどうかを見極めることが重要です。適切に扱えば、URLを介した共有や再現性、UXの向上に大きく貢献してくれるはずです。

Discussion