検索ページやページ送りを AI に書かせると、クエリ文字列をテンプレートリテラルでつなぐコードが出てくることがあります。
const url = `/search?q=${keyword}&category=${category}`;
keyword が React のようなただの英数字なら問題なく動くので、手元の確認ではまず気づきません。ところが R&D や C++、#1 のような値が入った瞬間、エラーも出さずに別の検索条件に化けます。
この記事では、書く側(URL を組み立てる)と読む側(URL から値を取り出す)の両方について、文字列処理で書いた場合と URLSearchParams で書いた場合の結果を並べます。実行環境は Node.js v24.14.1 です(URL / URLSearchParams はブラウザでも同じ標準 API です)。
Before: 文字列連結で組み立てた URL
const base = "https://example.com/search";
function buildBefore(keyword, category) {
return `${base}?q=${keyword}&category=${category}`;
}
この URL を、受け取る側が標準的な方法(new URL(url).searchParams)で読んだ結果です。category はすべて "book" を渡しています。
| keyword | 受け取った側の解釈 |
|---|---|
React |
{"q":"React","category":"book"} ✅ |
R&D |
{"q":"R","D":"","category":"book"} ❌ & で分割される |
C++ |
{"q":"C ","category":"book"} ❌ + が空白になる |
#1 |
{"q":""} ❌ # 以降がフラグメント扱いで category ごと消える |
50%20off |
{"q":"50 off","category":"book"} ❌ %20 が空白として解釈される |
どれも例外は出ません。検索結果が 0 件になる、絞り込みが外れる、といった「静かな不具合」として表に出ます。# のケースは後ろのパラメータまで丸ごと消えるので、ページ番号や並び順が効かなくなる原因として特に見つけにくいです。
After: URLSearchParams で組み立てる
function buildAfter(keyword, category) {
const params = new URLSearchParams({ q: keyword, category });
return `${base}?${params}`;
}
| keyword | 生成されたクエリ | 受け取った側の解釈 |
|---|---|---|
R&D |
?q=R%26D&category=book |
{"q":"R&D","category":"book"} ✅ |
C++ |
?q=C%2B%2B&category=book |
{"q":"C++","category":"book"} ✅ |
#1 |
?q=%231&category=book |
{"q":"#1","category":"book"} ✅ |
日本語 入門 |
?q=%E6%97%A5%E6%9C%AC%E8%AA%9E+%E5%85%A5%E9%96%80&category=book |
{"q":"日本語 入門","category":"book"} ✅ |
URLSearchParams は、値の中の & = + # % などを自動でエンコードします。encodeURIComponent を各値に付けても同じ目的は果たせますが、パラメータが増えるたびに付け忘れが起きやすいので、組み立てごと任せるほうが安全です。
既存の URL に足したり上書きしたりするなら、URL オブジェクトの searchParams を直接操作できます。
const url = new URL("https://example.com/search?q=React&page=2");
url.searchParams.set("page", "3"); // 上書き
url.searchParams.append("tag", "初心者"); // 追加
url.searchParams.delete("q"); // 削除
console.log(url.toString());
// https://example.com/search?page=3&tag=%E5%88%9D%E5%BF%83%E8%80%85
URLSearchParams でも気をつけたいこと
1つ目は、値が undefined や null のとき、そのまま文字列になることです。
new URLSearchParams({ q: "React", page: undefined, sort: null }).toString();
// 'q=React&page=undefined&sort=null'
任意項目があるときは、undefined / null と空文字を先に除いてから渡します。
const entries = Object.entries({ q: "React", page: undefined, sort: null, category: "" })
.filter(([, v]) => v != null && v !== "");
new URLSearchParams(entries).toString(); // 'q=React'
2つ目は、配列をそのまま渡すとカンマでつながった1つの値になることです。同じキーを複数並べたいときは append で1つずつ足します。
new URLSearchParams({ tag: ["a", "b"] }).toString(); // 'tag=a%2Cb'("a,b" という1つの値)
const params = new URLSearchParams({ q: "React" });
for (const t of ["a", "b"]) params.append("tag", t);
params.toString(); // 'q=React&tag=a&tag=b'
読む側も同じ構図になる
URL から値を取り出すコードでも、AI が split で手書きしてくることがあります。
function parseBefore(search) {
const result = {};
for (const pair of search.slice(1).split("&")) {
const [key, value] = pair.split("=");
result[key] = decodeURIComponent(value);
}
return result;
}
| 入力 | 手書きパース | new URLSearchParams(s) |
|---|---|---|
?q=a=b |
q が "a"(2つ目の = 以降が消える) |
get("q") → "a=b"
|
?q=%E6%97%A5%E6%9C%AC%E8%AA%9E+%E5%85%A5%E9%96%80 |
"日本語+入門"(+ が空白に戻らない) |
"日本語 入門" |
?q=100% |
URIError: URI malformed で落ちる |
"100%" |
?tag=a&tag=b |
tag が "b" だけ(後勝ちで上書き) |
getAll("tag") → ["a","b"]
|
decodeURIComponent は + を空白に戻さないため、URLSearchParams で組み立てた URL(空白は + になる)を手書きパースで読むと、書く側と読む側で解釈がずれます。組み立てと読み取りの両方を同じ API に揃えるのがいちばん確実です。
Next.js の useSearchParams で更新するとき
App Router の useSearchParams() が返すのは読み取り専用の URLSearchParams です。値を変えたいときは、コピーを作ってから set します。
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
export function PageLink({ page }: { page: number }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const goTo = () => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", String(page));
router.push(`${pathname}?${params.toString()}`);
};
return <button onClick={goTo}>{page}ページ目</button>;
}
ここで router.push(`${pathname}?page=${page}&q=${q}`) のように文字列で組み直すと、Before と同じ壊れ方をします。
なお、静的に生成するページで useSearchParams() を使う部品は、呼び出し側で <Suspense> に入れて使います。囲まないと next build で「Missing Suspense boundary with useSearchParams」として案内されます(公式ドキュメント)。
AI にコードを頼むときの確認ポイント
Claude Code などの AI が書いた URL 組み立てをレビューするときは、次の2点を見ておくと、ここで挙げた壊れ方にはレビューの段階で気づきやすくなります。
- テンプレートリテラルの中に
?や&と変数が並んでいたら、URLSearchParams(またはurl.searchParams)に置き換えられないか - 確認するときは
Reactのような素直な値ではなく、R&DC++#1日本語 入門を入れて試す
AI への依頼文に最初から「クエリ文字列は URLSearchParams で組み立てて、& + # を含む値でも壊れないようにして」と書いておくと、手戻りが減ります。
未経験者向けの講座を運営しています
未経験から Next.js + Supabase + Claude Code で Webアプリを公開するまで を、全24セッションで体系化した教材です。Claude Code を学習パートナーにする CLAUDE.md と学習モード(learner / developer)の設計までセットで含みます。
- ▶ ライブデモ(インストール不要・無料で今すぐ見られます)→ https://trial-web-ayies128s-projects.vercel.app/?ref=qiita
- 教材完全版+月5,500円のメンタリング(全24セッション+チャット質問し放題)→ https://menta.work/plan/20251?ref=qiita
- 無料体験版(git clone して自分の手元で動かす・最初の数セッション分・⭐ Star もよろしくお願いします)→ https://github.com/ayies128/next-ai-camp-trial
- YouTube『AIエンジニア情報局』(AI×開発ニュースを1本5分でキャッチアップできる別運営チャンネル・無料)→ https://www.youtube.com/channel/UC1rXVD9WYsQPQEWZyd-A1KA/?ref=qiita
※ 中上級者には易しすぎる内容なので、初心者の知り合いへの紹介や社内研修の参考としてどうぞ。