2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIが書いたクエリ文字列の連結は、& や # が入ると静かに壊れる — URLSearchParamsで直す

2
Posted at

検索ページやページ送りを 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&D C++ #1 日本語 入門 を入れて試す

AI への依頼文に最初から「クエリ文字列は URLSearchParams で組み立てて、& + # を含む値でも壊れないようにして」と書いておくと、手戻りが減ります。


未経験者向けの講座を運営しています

未経験から Next.js + Supabase + Claude Code で Webアプリを公開するまで を、全24セッションで体系化した教材です。Claude Code を学習パートナーにする CLAUDE.md と学習モード(learner / developer)の設計までセットで含みます。

※ 中上級者には易しすぎる内容なので、初心者の知り合いへの紹介や社内研修の参考としてどうぞ。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?