searchParams و Query String

در درس قبل بخشی از مسیر آدرس را پویا کردیم (مثل /posts/hello-nextjs). اما بسیاری از حالت‌های صفحه به مسیر مربوط نیستند، بلکه «تنظیمات نمایش» هستند: عبارت جست‌وجو، فیلتر برچسب، شماره‌ی صفحه یا ترتیب مرتب‌سازی. این‌ها معمولاً بعد از علامت ? در آدرس می‌آیند و Query String نام دارند. در این درس یاد می‌گیریم Query String را در Next.js 16 با searchParams بخوانیم و جست‌وجوی ساده‌ی DevNotes را شروع کنیم.

Query String چیست؟

Query String بخشی از آدرس است که بعد از ? می‌آید و از جفت‌های کلید=مقدار تشکیل می‌شود که با & از هم جدا می‌شوند:

text
/posts?q=nextjs&page=2&tag=react&tag=cache
       └──────────────┬───────────────┘
              Query String
بخش مقدار توضیح
q=nextjs "nextjs" یک کلید با یک مقدار
page=2 "2" مقدار همیشه رشته است، نه عدد
tag=react&tag=cache ["react", "cache"] کلید تکراری به آرایه تبدیل می‌شود

مزیت مهم Query String این است که حالت صفحه داخل آدرس است؛ پس قابل اشتراک‌گذاری، قابل ذخیره‌ی نشانک (Bookmark) و سازگار با دکمه‌ی «بازگشت» مرورگر است.

params یا searchParams؟

ویژگی params searchParams
منبع قطعه‌های پویای مسیر ([slug]) Query String بعد از ?
وجود در ساختار پوشه‌ها بله، با پوشه‌ی [slug] خیر، به پوشه‌ای نیاز ندارد
الزامی بودن بدون آن مسیر تطبیق نمی‌کند اختیاری است؛ ممکن است وجود نداشته باشد
نوع در Next.js 16 Promise Promise
کجا در دسترس است؟ صفحه‌ها و Layoutها فقط صفحه‌ها (page.tsx)

یک قاعده‌ی عملی: چیزی که «هویت» صفحه را مشخص می‌کند (مثل کدام نوشته) در مسیر می‌رود و چیزی که «نحوه‌ی نمایش» را تغییر می‌دهد (جست‌وجو، فیلتر، صفحه‌بندی) در Query String.

خواندن searchParams در صفحه

هر page.tsx علاوه بر params، prop دیگری به نام searchParams هم می‌گیرد. در Next.js 16 این prop هم یک Promise است و باید await شود:

tsx
type PostsPageProps = {
  searchParams: Promise<{
    [key: string]: string | string[] | undefined;
  }>;
};

export default async function PostsPage({ searchParams }: PostsPageProps) {
  const params = await searchParams;

  return <p>مقدار q: {String(params.q)}</p>;
}
  • نوع هر مقدار string | string[] | undefined است: یک رشته، آرایه‌ای از رشته‌ها (کلید تکراری) یا نبودِ کلید.
  • برخلاف params، مقدارهای searchParams معمولاً قبلاً دیکد شده‌اند و فارسی را به شکل متن معمولی می‌گیرید.
  • این مقدار فقط در صفحه در دسترس است؛ layout.tsx هیچ‌وقت searchParams نمی‌گیرد. دلیلش این است که Layout هنگام جابه‌جایی بین صفحات دوباره رندر نمی‌شود و ممکن بود مقدار قدیمی نشان دهد.

ساخت فرم جست‌وجو بدون JavaScript

ساده‌ترین راه تغییر Query String یک فرم HTML با متد پیش‌فرض GET است. وقتی فرم ارسال شود، مرورگر مقدار فیلدها را به شکل Query String به آدرس action اضافه می‌کند. هیچ کد کلاینتی لازم نیست و فرم داخل یک Server Component کار می‌کند.

tsx
<form action="/posts">
  <label htmlFor="q">جست‌وجو</label>
  <input id="q" type="search" name="q" />
  <button type="submit">بجو</button>
</form>

اگر کاربر در فیلد، nextjs را بنویسد و دکمه را بزند، مرورگر به /posts?q=nextjs می‌رود. ویژگی name نام کلید در Query String را تعیین می‌کند.

عملی: جست‌وجوی صفحه‌ی نوشته‌های DevNotes

فایل app/(public)/posts/page.tsx را به این شکل به‌روزرسانی کنید. هنوز داده‌ی واقعی نداریم، پس فقط مقدارها را می‌خوانیم، اعتبارسنجی می‌کنیم و نمایش می‌دهیم. فیلتر واقعی را وقتی داده داریم اضافه می‌کنیم.

فایل‌های متأثر: فقط app/(public)/posts/page.tsx.

tsx
// app/(public)/posts/page.tsx
import Link from "next/link";
import PostsIntro from "./_components/PostsIntro";

type PostsPageProps = {
  searchParams: Promise<{
    [key: string]: string | string[] | undefined;
  }>;
};

// اگر کلیدی تکراری بود فقط اولین مقدار را برمی‌داریم.
function first(value: string | string[] | undefined): string | undefined {
  return Array.isArray(value) ? value[0] : value;
}

// ساخت آدرس با Query String تمیز (بدون کلید خالی).
function buildHref(q: string, page: number): string {
  const search = new URLSearchParams();
  if (q) search.set("q", q);
  if (page > 1) search.set("page", String(page));
  const queryString = search.toString();
  return queryString ? `/posts?${queryString}` : "/posts";
}

export default async function PostsPage({ searchParams }: PostsPageProps) {
  const params = await searchParams;

  const q = first(params.q)?.trim() ?? "";

  const rawPage = Number(first(params.page));
  const page = Number.isInteger(rawPage) && rawPage >= 1 ? rawPage : 1;

  return (
    <main>
      <h1>نوشته‌ها</h1>
      <PostsIntro />

      <form action="/posts">
        <label htmlFor="q">جست‌وجو در نوشته‌ها</label>
        <input id="q" type="search" name="q" defaultValue={q} />
        <button type="submit">بجو</button>
      </form>

      <p>
        {q ? `نتیجه‌ی جست‌وجو برای «${q}»` : "همه‌ی نوشته‌ها"} — صفحه‌ی {page}
      </p>

      <nav aria-label="صفحه‌بندی">
        {page > 1 && (
          <Link href={buildHref(q, page - 1)}>صفحه‌ی قبل</Link>
        )}{" "}
        <Link href={buildHref(q, page + 1)}>صفحه‌ی بعد</Link>
      </nav>
    </main>
  );
}

توضیح کد

  • await searchParams: آبجکت Query String را می‌گیرد. چون Promise است، تابع async شده است.
  • first: چون هر مقدار می‌تواند رشته، آرایه یا undefined باشد، این تابع کمکی آن را به یک رشته (یا undefined) تبدیل می‌کند. مثلاً اگر کسی آدرس ?q=a&q=b را بزند، فقط "a" را می‌گیریم.
  • ?.trim() ?? "": فاصله‌های اضافه را حذف می‌کند و اگر کلید نبود، رشته‌ی خالی می‌دهد.
  • page: مقدار رشته‌ای را به عدد تبدیل می‌کنیم و فقط عدد صحیح بزرگ‌تر یا مساوی ۱ را می‌پذیریم؛ در غیر این صورت صفحه‌ی ۱. یعنی ورودی‌هایی مثل ?page=abc یا ?page=-5 مشکلی ایجاد نمی‌کنند.
  • defaultValue={q}: مقدار جست‌وجوی قبلی بعد از ارسال فرم در فیلد باقی می‌ماند.
  • buildHref با URLSearchParams آدرس را می‌سازد و خودش کاراکترهای خاص را کدگذاری می‌کند. کلیدهای خالی (مثل q=) را اضافه نمی‌کند.
  • متن جست‌وجو را داخل JSX قرار داده‌ایم؛ React مقدار را خودکار Escape می‌کند، پس ورودی کاربر به‌عنوان HTML اجرا نمی‌شود.

خروجی و رفتار مورد انتظار

  • /posts: متن «همه‌ی نوشته‌ها — صفحه‌ی 1» و فقط لینک «صفحه‌ی بعد».
  • نوشتن nextjs در فرم و ارسال: آدرس به /posts?q=nextjs می‌رود و متن «نتیجه‌ی جست‌وجو برای «nextjs» — صفحه‌ی 1» دیده می‌شود.
  • کلیک روی «صفحه‌ی بعد»: آدرس /posts?q=nextjs&page=2 می‌شود و لینک «صفحه‌ی قبل» هم ظاهر می‌شود.
  • /posts?page=abc و /posts?page=0: هر دو صفحه‌ی ۱ را نشان می‌دهند.
  • دکمه‌ی «بازگشت» مرورگر بین حالت‌های مختلف جست‌وجو درست کار می‌کند، چون هر حالت یک آدرس دارد.

خواندن Query String در Client Component

prop searchParams فقط در صفحه‌ی Server Component در دسترس است. اگر Client Componentی (مثلاً نوار جست‌وجو در هدر) لازم داشته باشد Query String را بخواند، از Hook با نام useSearchParams در next/navigation استفاده می‌کند:

tsx
// components/SearchSummary.tsx
"use client";

import { useSearchParams } from "next/navigation";

export default function SearchSummary() {
  const searchParams = useSearchParams();
  const q = searchParams.get("q") ?? "";

  return <p>عبارت فعلی: {q || "—"}</p>;
}
  • searchParams.get("q") اولین مقدار کلید را برمی‌گرداند (یا null)؛ برای کلیدهای تکراری getAll وجود دارد.
  • این Hook فقط خواندن را انجام می‌دهد. تغییر آدرس از داخل کد با useRouter در درس «مسیریابی برنامه‌ای با useRouter» می‌آید.
  • اگر کامپوننتی که useSearchParams دارد در یک مسیر استاتیک استفاده شود، باید داخل یک مرز Suspense قرار بگیرد؛ در غیر این صورت Build خطا می‌دهد. Suspense را در فصل «Loading، Error و Streaming» می‌شناسیم. ما SearchSummary را در DevNotes استفاده نمی‌کنیم و این نمونه فقط برای شناخت است.

searchParams و نوع رندر صفحه

Query String فقط هنگام درخواست مشخص می‌شود؛ پس صفحه‌ای که searchParams را می‌خواند نمی‌تواند هنگام Build برای همه‌ی مقادیر ممکن از پیش ساخته شود. در حالت پیش‌فرض (بدون cacheComponents) چنین صفحه‌ای هنگام درخواست رندر می‌شود (Dynamic Rendering). این به معنی «بدون کش» نیست؛ بین رندر پویا و کش داده تفاوت هست و در درس‌های «ارتباط Cache با Static Rendering، Dynamic Rendering و APIهای پویای درخواست» و «مدل‌های رندر: Static، Dynamic، SSR، SSG و ISR» دقیق بررسی می‌شود. اگر cacheComponents روشن باشد، دسترسی به searchParams هم قواعد خاص خودش را دارد که در فصل کش می‌آید.

نکته‌های مهم

  • همه‌ی مقدارها رشته‌اند: قبل از استفاده‌ی عددی، تبدیل و اعتبارسنجی کنید، همان‌طور که با page انجام دادیم.
  • به ورودی آدرس اعتماد نکنید: هر کسی می‌تواند هر Query Stringی تایپ کند. مقدارها را اعتبارسنجی کنید و هنگام استفاده در پایگاه داده، از پرس‌وجوی پارامتری استفاده کنید (در فصل «پایگاه داده با Prisma و SQLite»).
  • Layout هرگز searchParams نمی‌گیرد: اگر Layout به Query String نیاز دارد، باید آن بخش را به Client Component با useSearchParams بسپارید.
  • کلید تکراری آرایه می‌شود: کدی که فقط رشته را فرض کند، با آدرس‌های ساخته‌شده‌ی دستی می‌شکند؛ برای همین تابع first را نوشتیم.
  • ورودی را مستقیماً به صورت HTML رندر نکنید: از dangerouslySetInnerHTML برای مقدار Query String استفاده نکنید.

جمع‌بندی

  • Query String بخش بعد از ? در آدرس است و برای حالت‌های نمایش مثل جست‌وجو، فیلتر و صفحه‌بندی استفاده می‌شود.
  • در Next.js 16، prop به نام searchParams یک Promise است و فقط در page.tsx وجود دارد؛ Layout آن را نمی‌گیرد.
  • مقدار هر کلید string | string[] | undefined است و باید قبل از استفاده نرمال‌سازی و اعتبارسنجی شود.
  • یک فرم HTML با متد GET بدون JavaScript Query String می‌سازد و برای جست‌وجوی ساده کافی است.
  • در Client Component از useSearchParams برای خواندن استفاده می‌کنیم.
  • خواندن searchParams رندر صفحه را به زمان درخواست می‌برد؛ رابطه‌ی دقیقش با کش را در فصل‌های بعد می‌بینیم.
  • در DevNotes اکنون صفحه‌ی /posts فرم جست‌وجو و صفحه‌بندی ساده دارد.
  • در درس بعد، «Catch-all و Optional Catch-all Routes»، مسیرهایی می‌سازیم که چند بخش از آدرس را یکجا می‌گیرند.