الـ URL هو حاوية الحالة الأقل استغلالًا في تطبيقك. فهو قابل للمشاركة والحفظ في المفضلة، ويصمد أمام إعادة التحميل، ويعمل مع زر الرجوع في المتصفح مجانًا. تتيح لك مكتبة nuqs قراءة هذه الحالة وكتابتها بنفس سهولة useState تمامًا — لكن بأمان نوعي كامل ومتزامنة مع سلسلة الاستعلام. في هذا الدرس ستبني لوحة منتجات حقيقية بفلاتر وترقيم صفحات وفرز، مدفوعة بالكامل من الـ URL.
ماذا ستبني
سنبني لوحة فهرس منتجات حيث تعيش كل حالة واجهة المستخدم داخل الـ URL:
- صندوق بحث بتأخير زمني يحدّث
?q= - فلاتر تصنيفات تُخزَّن كمصفوفة في
?categories= - ترقيم صفحات عبر
?page=و?perPage= - أعمدة قابلة للفرز عبر
?sort=و?dir= - رابط قابل للمشاركة بالكامل — انسخ الـ URL ليرى زميلك نفس العرض المُفلتر تمامًا
- تحليل من جهة الخادم بحيث يكون أول عرض مُفلترًا مسبقًا، دون أي إزاحة في التخطيط
بنهاية الدرس ستفهم كل واجهات nuqs الأساسية: useQueryState و useQueryStates ومكتبة المحلّلات (parsers) وخيارات مثل history و shallow و throttleMs والتخزين المؤقت من جهة الخادم عبر createSearchParamsCache.
المتطلبات المسبقة
قبل البدء، تأكّد من توفّر:
- Node.js 20 أو أحدث
- مشروع Next.js 15 يستخدم App Router
- معرفة عملية بخطافات React و TypeScript
- إلمام بـ
useState(لأنه النموذج الذهني الذي تستعيرهnuqs)
لماذا حالة الـ URL؟
معظم تطبيقات React تخزّن حالة الفلاتر والترقيم في useState. يعمل ذلك إلى أن يعيد أحدهم تحميل الصفحة فيفقد فلاتره، أو يحاول مشاركة رابط فيرى المستلم عرضًا افتراضيًا فارغًا. الـ URL يحل كل ذلك — لكن تحليل سلاسل الاستعلام يدويًا مملّ ومعرّض للأخطاء:
// الطريقة القديمة الهشّة
const page = Number(searchParams.get('page') ?? '1')
const categories = searchParams.get('categories')?.split(',') ?? []
// ...والآن أعد تسلسل كل شيء يدويًا عند كل تغيير 😩تستبدل nuqs هذا بواجهة مُصرَّح بها وآمنة نوعيًا. تخيّلها كـ useState، باستثناء أن مصدر الحقيقة هو سلسلة الاستعلام وكل قيمة تُتحقَّق عبر محلّل.
الخطوة 1: التثبيت وإعداد المحوّل
ثبّت الحزمة:
npm install nuqsnuqs غير مرتبطة بإطار معيّن، لذا تخبرها بالموجّه الذي تستخدمه عبر محوّل (adapter). لـ Next.js App Router، غلّف تطبيقك بـ NuqsAdapter. أنظف مكان هو التخطيط الجذري:
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ar" dir="rtl">
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
)
}هذا المزوّد الوحيد هو كل ما تحتاجه nuqs. توجد محوّلات مخصّصة لـ Pages Router و React Router و Remix و TanStack Router أيضًا — يتغيّر مسار الاستيراد فقط.
الخطوة 2: أول حالة استعلام لك
لنضف صندوق البحث. يحاكي خطاف useQueryState الخطاف useState تمامًا — يُعيد قيمة ودالّة تعيين:
// app/products/search-box.tsx
'use client'
import { useQueryState } from 'nuqs'
export function SearchBox() {
const [query, setQuery] = useQueryState('q')
return (
<input
value={query ?? ''}
onChange={(e) => setQuery(e.target.value || null)}
placeholder="ابحث عن المنتجات..."
/>
)
}اكتب بعض الأحرف وراقب الـ URL يصبح ?q=keyboard. أعد تحميل الصفحة — يحتفظ الحقل بقيمته. تعيين الحالة إلى null يزيل المعامل من الـ URL تمامًا، وهذه طريقة التعبير عن "العودة إلى الافتراضي".
افتراضيًا، نوع query هو string | null. الـ null يعني "هذا المعامل غائب". سنعالج قابلية الإفراغ بقيمة افتراضية في الخطوة التالية.
الخطوة 3: المحلّلات والقيم الافتراضية
سلسلة الاستعلام الخام نصّ دائمًا. المحلّلات (parsers) تحوّل ذلك النص إلى أنواع حقيقية والعكس. تأتي nuqs بمحلّلات لكل الحالات الشائعة:
'use client'
import {
useQueryState,
parseAsInteger,
parseAsString,
parseAsBoolean,
} from 'nuqs'
export function Pagination() {
// parseAsInteger.withDefault(1) => page من نوع `number`، لا يكون null أبدًا
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
return (
<div>
<button onClick={() => setPage((p) => p - 1)} disabled={page <= 1}>
السابق
</button>
<span>الصفحة {page}</span>
<button onClick={() => setPage((p) => p + 1)}>التالي</button>
</div>
)
}لاحظ أمرين:
- يجعل
parseAsIntegerقيمةpageمن نوعnumberحقيقي. استدعاءsetPage((p) => p + 1)يعمل مع المُحدِّث الدالّي، تمامًا مثلuseState. - تزيل
.withDefault(1)النوعnull. عند غياب?page=تحصل على1بدلًا منnull، وتُبقيnuqsالـ URL نظيفًا بحذف القيمة الافتراضية بدلًا من كتابة?page=1.
تغطي مكتبة المحلّلات المدمجة كل شيء تقريبًا:
| المحلّل | النوع | مثال URL |
|---|---|---|
parseAsString | string | ?q=keyboard |
parseAsInteger | number | ?page=2 |
parseAsFloat | number | ?price=19.99 |
parseAsBoolean | boolean | ?inStock=true |
parseAsArrayOf(parseAsString) | string[] | ?tags=a,b,c |
parseAsStringLiteral([...]) | اتحاد | ?dir=asc |
parseAsIsoDateTime | Date | ?from=2026-06-03... |
إذا حرّر مستخدم الـ URL يدويًا إلى قيمة غير صالحة (?page=banana)، يفشل المحلّل بأمان ويرجع إلى قيمتك الافتراضية — دون أي انهيار.
الخطوة 4: المصفوفات لفلاتر الاختيار المتعدّد
فلاتر التصنيفات اختيار متعدّد، لذا نخزّنها كمصفوفة. ادمج parseAsArrayOf مع parseAsString:
// app/products/category-filter.tsx
'use client'
import { useQueryState, parseAsArrayOf, parseAsString } from 'nuqs'
const ALL_CATEGORIES = ['keyboards', 'mice', 'monitors', 'audio']
export function CategoryFilter() {
const [categories, setCategories] = useQueryState(
'categories',
parseAsArrayOf(parseAsString).withDefault([]),
)
function toggle(cat: string) {
setCategories((current) =>
current.includes(cat)
? current.filter((c) => c !== cat)
: [...current, cat],
)
}
return (
<fieldset>
{ALL_CATEGORIES.map((cat) => (
<label key={cat}>
<input
type="checkbox"
checked={categories.includes(cat)}
onChange={() => toggle(cat)}
/>
{cat}
</label>
))}
</fieldset>
)
}يصبح الـ URL الآن ?categories=keyboards,mice. الفاصل الافتراضي هو الفاصلة، لكن يمكنك تغييره عبر .withOptions إن كانت قيمك تحتوي على فواصل:
parseAsArrayOf(parseAsString, ';').withDefault([])
// ينتج ?categories=keyboards;miceالخطوة 5: تجميع الحالات المترابطة عبر useQueryStates
يحتاج الفرز إلى قيمتين يجب أن تتغيّرا معًا دائمًا: العمود والاتجاه. تحديثهما عبر استدعاءين منفصلين لـ useQueryState سيُطلق كتابتين للـ URL. يجمّع useQueryStates مجموعة من المعاملات في تحديث واحد:
// app/products/sort-control.tsx
'use client'
import { useQueryStates, parseAsStringLiteral } from 'nuqs'
const columns = ['name', 'price', 'rating'] as const
const directions = ['asc', 'desc'] as const
export function SortControl() {
const [sort, setSort] = useQueryStates({
sort: parseAsStringLiteral(columns).withDefault('name'),
dir: parseAsStringLiteral(directions).withDefault('asc'),
})
function sortBy(column: (typeof columns)[number]) {
setSort((prev) => ({
sort: column,
// عكس الاتجاه إذا نُقر العمود نفسه مرة أخرى
dir: prev.sort === column && prev.dir === 'asc' ? 'desc' : 'asc',
}))
}
return (
<div>
{columns.map((col) => (
<button key={col} onClick={() => sortBy(col)}>
{col} {sort.sort === col ? (sort.dir === 'asc' ? '↑' : '↓') : ''}
</button>
))}
</div>
)
}parseAsStringLiteral هو السلاح السري هنا: يقيّد sort ليكون بالضبط 'name' | 'price' | 'rating' على مستوى النوع. أي قيمة غير صالحة في الـ URL ترجع إلى الافتراضي، فتبقى عبارات switch لديك شاملة وآمنة.
الخطوة 6: الخيارات — history و shallow والتأخير الزمني
يقبل كل محلّل خيارات عبر .withOptions (أو كوسيط ثالث). هذه الثلاثة هي الأهم في التطبيقات الحقيقية:
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export function SearchBox() {
const [query, setQuery] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({
// إضافة مدخل سجلّ متصفّح جديد لكل تغيير (زر الرجوع يتنقّل بين عمليات البحث)
history: 'push',
// تقييد كتابات الـ URL كي لا تُغرق الكتابةُ السريعة السجلَّ
throttleMs: 300,
}),
)
// ...
}history — قيمتها الافتراضية 'replace' (يتغيّر الـ URL دون إضافة مدخل لزر الرجوع). استخدم 'push' عندما يكون كل تغيير حالة "صفحة" مستقلة يجب أن يستطيع المستخدم الرجوع إليها.
throttleMs — يدمج التحديثات السريعة. لصندوق بحث مربوط بـ onChange، تأخير 200–500 مللي ثانية يمنع الـ URL من التحديث عند كل ضغطة مفتاح. هذا يغني عن معظم منطق التأخير اليدوي.
shallow — الأهم لجلب البيانات. افتراضيًا تكون تحديثات nuqs من جهة العميل فقط (shallow: true): لا تُعاد مكوّنات الخادم (React Server Components). عندما تحتاج إلى أن يُعيد الخادم العرض بالمعاملات الجديدة (مثلًا لإعادة جلب البيانات في RSC)، اضبط shallow: false:
parseAsInteger.withDefault(1).withOptions({ shallow: false })مع shallow: false، يُطلق تغيير ?page= رحلة ذهاب وإياب للخادم، ويقرأ مكوّن الخادم القيمة الجديدة تلقائيًا.
الخطوة 7: تحديثات سلسة عبر startTransition
عندما يُطلق shallow: false عملًا على الخادم، تريد واجهة تنتظر بدلًا من صفحة متجمّدة. تتكامل nuqs مع خطاف useTransition في React:
'use client'
import { useTransition } from 'react'
import { useQueryState, parseAsInteger } from 'nuqs'
export function Pagination() {
const [isLoading, startTransition] = useTransition()
const [page, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1).withOptions({
shallow: false,
startTransition,
}),
)
return (
<div data-pending={isLoading ? '' : undefined}>
<button onClick={() => setPage((p) => p + 1)}>التالي</button>
{isLoading && <span>جارٍ التحميل...</span>}
</div>
)
}الآن تكون isLoading بقيمة true أثناء إعادة الخادم للجلب، ما يتيح لك تعتيم الجدول أو إظهار مؤشّر تحميل — دون حالات فارغة مزعجة.
الخطوة 8: التحليل من جهة الخادم عبر createSearchParamsCache
هنا تتألّق nuqs حقًا. لعرض أول صفحة مُفلترة مسبقًا، حلّل المعاملات نفسها على الخادم. الحيلة هي مشاركة تعريفات المحلّلات بين العميل والخادم.
أولًا، عرّف المحلّلات في مكان واحد:
// app/products/search-params.ts
import {
parseAsInteger,
parseAsString,
parseAsArrayOf,
parseAsStringLiteral,
createSearchParamsCache,
} from 'nuqs/server'
export const productSearchParams = {
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1),
categories: parseAsArrayOf(parseAsString).withDefault([]),
sort: parseAsStringLiteral(['name', 'price', 'rating']).withDefault('name'),
dir: parseAsStringLiteral(['asc', 'desc']).withDefault('asc'),
}
export const searchParamsCache = createSearchParamsCache(productSearchParams)الآن يستطيع مكوّن الخادم تحليل المعاملات الواردة بأمان نوعي كامل:
// app/products/page.tsx
import { searchParamsCache } from './search-params'
import { getProducts } from '@/lib/products'
import { ProductTable } from './product-table'
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
// parse() يتحقّق ويخزّن القيم المُحلَّلة لهذا الطلب
const { q, page, categories, sort, dir } = await searchParamsCache.parse(
await searchParams,
)
const products = await getProducts({ q, page, categories, sort, dir })
return <ProductTable products={products} />
}تعيد مكوّنات العميل استخدام كائن المحلّل نفسه بالضبط، فيكون هناك مصدر حقيقة واحد. مرّر productSearchParams مباشرة إلى useQueryStates:
'use client'
import { useQueryStates } from 'nuqs'
import { productSearchParams } from './search-params'
export function Filters() {
const [state, setState] = useQueryStates(productSearchParams)
// state مُحدَّد النوع بالكامل، مطابق للشكل المُحلَّل على الخادم
// ...
}حرّر فلترًا على العميل، فيتحدّث الـ URL، ويُطلق shallow: false عرضًا على الخادم، فيعيد ProductsPage الجلب بالمعاملات الجديدة. تعريف واحد، وكلا الجانبين، بأمان نوعي كامل.
الخطوة 9: بناء روابط قابلة للمشاركة والمُسلسِلات
أحيانًا تحتاج إلى بناء URL دون عرض مكوّن — رابط "إعادة ضبط الفلاتر" أو بريد يولّده الخادم. توفّر nuqs الدالّة createSerializer لهذا الغرض بالضبط:
// app/products/search-params.ts (تكملة)
import { createSerializer } from 'nuqs/server'
export const serialize = createSerializer(productSearchParams)import Link from 'next/link'
import { serialize } from './search-params'
// ينتج /products?categories=keyboards&sort=price&dir=desc
const url = serialize('/products', {
categories: ['keyboards'],
sort: 'price',
dir: 'desc',
})
export function CheapKeyboardsLink() {
return <Link href={url}>أرخص لوحات المفاتيح</Link>
}لأن المُسلسِل يستخدم المحلّلات نفسها، فإن الرابط المُولَّد مضمون أن يُحلَّل إلى نفس الحالة المُحدَّدة النوع. تُحذف القيم الافتراضية تلقائيًا، ما يبقي الروابط قصيرة.
اختبار التنفيذ
تحقّق من السلوك من البداية إلى النهاية:
- ثبات الفلاتر — طبّق مصطلح بحث وتصنيفين، ثم أعد التحميل. يجب أن تكون الفلاتر والنتائج متطابقة.
- الحالة القابلة للمشاركة — انسخ الـ URL في تبويب متصفّح جديد. يجب أن تصل إلى نفس العرض المُفلتر، مُعرَّضًا من الخادم.
- زر الرجوع — مع
history: 'push'، نفّذ ثلاث عمليات بحث واضغط رجوع. يجب أن تتراجع عبر كل منها. - مدخل غير صالح — اضبط يدويًا
?page=bananaفي شريط العنوان. يجب أن ترجع الصفحة إلى الصفحة 1 دون أخطاء. - لا إزاحة تخطيط — عطّل JavaScript وحمّل URL مُفلترًا. لأن التحليل يحدث على الخادم، تُعرَض النتائج الصحيحة فورًا.
استكشاف الأخطاء وإصلاحها
"useQueryState must be used within a NuqsAdapter" — نسيت تغليف شجرتك بـ NuqsAdapter، أو استوردت المحوّل الخطأ لموجّهك.
الخادم لا يعيد العرض عند التغيير — أنت تستخدم الافتراضي shallow: true. أضف .withOptions({ shallow: false }) إلى المحلّلات التي يجب أن تُطلق عملًا على الخادم.
القيم الافتراضية تظهر في الـ URL — تأكّد من استخدام .withDefault() بدلًا من تمرير افتراضي عبر منطق على نمط useState. لا تحذف nuqs قيمة من الـ URL إلا عندما تعرف الافتراضي عبر المحلّل.
أخطاء نوعية مع searchParams — في Next.js 15، يكون searchParams من نوع Promise. تذكّر أن تستخدم await قبل تمريره إلى searchParamsCache.parse().
الخطوات التالية
- ادمج
nuqsمع TanStack Table لجداول بيانات مدفوعة بالكامل من الـ URL — الفرز والترقيم وفلاتر الأعمدة كلها في سلسلة الاستعلام. - أقرنها مع TanStack Query بحيث يعيد تغيير الـ URL جلب البيانات الصحيحة تلقائيًا.
- استكشف
parseAsJsonمع مخطط Zod لتخزين حالة منظّمة معقّدة بأمان في الـ URL. - اطّلع على درسينا المرتبطين حول Zustand لحالة العميل والنموذج الذري في Jotai لتفهم متى تناسب كلٌّ من حالة الـ URL والحالة العامة والحالة الذرية.
الخلاصة
تحوّل nuqs الـ URL إلى حاوية حالة من الدرجة الأولى بسهولة useState وأمان TypeScript. تعلّمت كيف تقرأ وتكتب قيمًا مفردة عبر useQueryState، وتجمّع المترابطة منها عبر useQueryStates، وتتحقّق من كل شيء عبر المحلّلات، وتضبط السلوك بـ history و shallow و throttleMs، وتحلّل المعاملات نفسها على الخادم عبر createSearchParamsCache لعروض فورية قابلة للمشاركة وصامدة أمام إعادة التحميل.
النمط بسيط لكنه قوي: إذا كان جزء من الحالة يجب أن يكون قابلًا للمشاركة أو يصمد أمام إعادة التحميل، فمكانه في الـ URL — وتجعل nuqs وضعه هناك تغييرًا من سطر واحد.