В архітектурі сучасних вебзастосунків адресний рядок браузера є найбільш доступним, але історично недооціненим глобальним стейт-менеджером. Збереження фільтрації, сортування, пагінації, відкритих вкладок та пошукових параметрів в ізольованому useState або клієнтських сховищах (Zustand/Redux) позбавляє застосунок головної переваги вебу — адресованості стану.
Адресний рядок браузера здатний виступати глобальною шиною стану. Бібліотека nuqs (раніше next-usequerystate) усуває розрив між нетипізованим рядком window.location.search та суворим TypeScript-кодом: вона надає двостороннє зв'язування через Type-Safe Parsers, Shallow Routing та серверну інтеграцію з RSC через createSearchParamsCache.
Зберігання навігаційних та фільтраційних параметрів в оперативній пам'яті клієнта породжує системні дефекти UX:
Взаємодія компонентів через стан в URL будується на принципі реактивної шини:
?q=react, а незалежний компонент списку товарів зчитує це значення без props drilling і без Context Provider.?page=foobar), парсер перехоплює збій і підставляє детерміноване значення за замовчуванням (withDefault(1)).replaceState з дебаунсом, виключаючи спам стека історії.// Читання та запис параметра в URL так само просто, як useState:
const [query, setQuery] = useQueryState('q', queryParser);Виділіть параметри, що впливають на вибірку даних: фільтри, сортування, пагінація, модальні вікна, ID вибраних елементів.
Опишіть парсери nuqs із валідацією типів, дефолтними значеннями та політикою історії (replace для пошуку, push для сторінок).
Сконфігуруйте кеш парсингу для RSC page.tsx, щоб серверний пререндер миттєво віддавав відфільтрований HTML.
Замініть useState на useQueryState у клієнтських компонентах із прапорцем shallow: true для виключення серверних водоспадів.
Реалізуйте функцію скидання параметрів через передачу null і відобразіть активні бейджі застосованих критеріїв.
Що станеться в рантаймі, якщо користувач відкриє URL /catalog?page=abc&sort=unknown, де page описаний як parseAsInteger.withDefault(1), а sort як parseAsStringEnum(['asc', 'desc']).withDefault('asc')?
?modal=edit-user&userId=123), що дозволяє відкривати діалоги за прямими посиланнями з поштових сповіщень.На відміну від нативного useSearchParams, що повертає сирі рядки (string | null), бібліотека nuqs надає фабрики парсерів із суворою валідацією та автоматичним видаленням дефолтних значень із рядка запиту:
import {
parseAsInteger,
parseAsString,
parseAsStringEnum,
parseAsBoolean,
parseAsArrayOf,
createParser,
} from 'nuqs';
import { z } from 'zod';
// Перелік допустимих варіантів сортування
export const SortOrderEnum = {
POPULAR: 'popular',
PRICE_ASC: 'price_asc',
PRICE_DESC: 'price_desc',
NEWEST: 'newest',
} as const;
export type SortOrder = (typeof SortOrderEnum)[keyof typeof SortOrderEnum];
// 1. Примітивні парсери
export const pageParser = parseAsInteger.withDefault(1);
export const queryParser = parseAsString.withDefault('').withOptions({
throttleMs: 300, // Дебаунс синхронізації з адресним рядком
shallow: true, // Без повторного запиту серверних компонентів Next.js
});
export const inStockOnlyParser = parseAsBoolean.withDefault(false);
// 2. Суворий Enum-парсер
export const sortOrderParser = parseAsStringEnum<SortOrder>(
Object.values(SortOrderEnum)
).withDefault(SortOrderEnum.POPULAR);
// 3. Парсер масиву рядків (список категорій, розділених комою)
export const tagsParser = parseAsArrayOf(parseAsString, ',').withDefault([]);
// 4. Кастомний парсер на базі Zod-схеми (JSON в URL)
const PriceRangeSchema = z.object({
min: z.number().nonnegative(),
max: z.number().positive(),
});
export type PriceRange = z.infer<typeof PriceRangeSchema>;
export const priceRangeParser = createParser<PriceRange>({
parse: (raw: string) => {
try {
const parsed = JSON.parse(raw);
const validated = PriceRangeSchema.safeParse(parsed);
return validated.success ? validated.data : null;
} catch {
return null;
}
},
serialize: (value: PriceRange) => JSON.stringify(value),
}).withDefault({ min: 0, max: 10000 });Компоненти можуть підписуватися як на один ізольований параметр, так і на групу пов'язаних фільтрів:
'use client';
import React from 'react';
import { useQueryState } from 'nuqs';
import { queryParser } from '@/lib/searchParams';
export function CatalogSearchInput() {
const [query, setQuery] = useQueryState('q', queryParser);
return (
<div className="relative w-full max-w-md">
<input
type="search"
id="catalog-search"
value={query}
placeholder="Пошук курсів та навичок..."
onChange={(e) => setQuery(e.target.value)}
className="w-full px-4 py-2 bg-bg-surface border border-border rounded-lg text-text-primary focus:outline-none focus:border-accent-cyan"
/>
{query && (
<button
onClick={() => setQuery(null)} // Передача null видаляє параметр з URL!
className="absolute right-3 top-2.5 text-xs text-text-muted hover:text-text-primary"
>
Очистити
</button>
)}
</div>
);
}'use client';
import React from 'react';
import { useQueryStates } from 'nuqs';
import {
pageParser,
sortOrderParser,
inStockOnlyParser,
SortOrderEnum,
} from '@/lib/searchParams';
export function CatalogFiltersBar() {
// Атомарне оновлення кількох параметрів в одній транзакції URL
const [filters, setFilters] = useQueryStates(
{
page: pageParser,
sort: sortOrderParser,
inStock: inStockOnlyParser,
},
{
shallow: true,
history: 'push', // Додає запис до історії при зміні фільтра
}
);
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
// При зміні сортування синхронно скидаємо сторінку на 1
void setFilters({
sort: e.target.value as typeof filters.sort,
page: 1,
});
};
return (
<div className="flex items-center gap-4 py-4 border-b border-border">
<label className="flex items-center gap-2 text-sm text-text-muted">
<span>Сортування:</span>
<select
value={filters.sort}
onChange={handleSortChange}
className="bg-bg-surface border border-border rounded px-2 py-1 text-text-primary text-sm"
>
<option value={SortOrderEnum.POPULAR}>За популярністю</option>
<option value={SortOrderEnum.PRICE_ASC}>Спочатку дешевші</option>
<option value={SortOrderEnum.PRICE_DESC}>Спочатку дорожчі</option>
<option value={SortOrderEnum.NEWEST}>Спочатку нові</option>
</select>
</label>
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={filters.inStock}
onChange={(e) => setFilters({ inStock: e.target.checked, page: 1 })}
className="rounded border-border"
/>
<span>Тільки в наявності</span>
</label>
<div className="ml-auto text-xs font-mono text-accent-cyan">
Сторінка: {filters.page}
</div>
</div>
);
}Однією з найпотужніших можливостей nuqs є утиліта createSearchParamsCache, яка дозволяє серверному компоненту безпечно та синхронно розпарсити параметри сторінки для виконання SSR-запиту до бази даних:
import { createSearchParamsCache } from 'nuqs/server';
import {
queryParser,
pageParser,
sortOrderParser,
inStockOnlyParser,
} from '@/lib/searchParams';
import { CatalogSearchInput } from '@/components/CatalogSearchInput';
import { CatalogFiltersBar } from '@/components/CatalogFiltersBar';
import { ProductGrid } from '@/components/ProductGrid';
import { fetchProductsFromDb } from '@/lib/db/products';
// Ініціалізуємо серверний кеш парсингу
const catalogSearchParamsCache = createSearchParamsCache({
q: queryParser,
page: pageParser,
sort: sortOrderParser,
inStock: inStockOnlyParser,
});
interface PageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function CatalogPage({ searchParams }: PageProps) {
// Синхронний парсинг із гарантією типів на стороні Node.js/Edge
const parsedParams = catalogSearchParamsCache.parse(await searchParams);
// Прямий запит до БД із валідними типами без ін'єкцій та undefined
const products = await fetchProductsFromDb({
search: parsedParams.q,
page: parsedParams.page,
sortBy: parsedParams.sort,
availableOnly: parsedParams.inStock,
});
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6 font-sans">Каталог специфікацій</h1>
{/* Клієнтські контролери, що синхронізуються через URL */}
<CatalogSearchInput />
<CatalogFiltersBar />
{/* Відрендерена на сервері сітка товарів */}
<ProductGrid initialProducts={products} />
</div>
);
}Інженер взаємодіє з полем введення, селектором сортування або перемикачем пагінації.
Парсер валідує значення на відповідність типу та перетворює його на URL-safe рядок. Якщо значення дорівнює default, ключ очищається.
nuqs оновлює адресний рядок через history.replaceState/pushState із заданим throttleMs без виклику серверного рендеру Next.js.
Внутрішня шина nuqs сповіщає всі підписані компоненти в дереві, викликаючи їхній рендер із новими типізованими значеннями.
Чому для рядкових полів швидкого пошуку в useQueryState обов'язково встановлювати опції history: 'replace' та throttleMs: 300?
import { useQueryStates, parseAsInteger, parseAsString, parseAsStringEnum, parseAsBoolean } from 'nuqs';
import { useCallback, useMemo } from 'react';
export const CatalogSort = {
RELEVANCE: 'relevance',
PRICE_LOW: 'price_low',
PRICE_HIGH: 'price_high',
RATING: 'rating',
} as const;
export type CatalogSortType = (typeof CatalogSort)[keyof typeof CatalogSort];
export const catalogFilterParsers = {
q: parseAsString.withDefault('').withOptions({ throttleMs: 300, shallow: true }),
page: parseAsInteger.withDefault(1).withOptions({ shallow: true }),
limit: parseAsInteger.withDefault(20).withOptions({ shallow: true }),
sort: parseAsStringEnum<CatalogSortType>(Object.values(CatalogSort))
.withDefault(CatalogSort.RELEVANCE)
.withOptions({ shallow: true }),
inStock: parseAsBoolean.withDefault(false).withOptions({ shallow: true }),
category: parseAsString.withDefault('all').withOptions({ shallow: true }),
};
export function useCatalogUrlFilters() {
const [filters, setFilters] = useQueryStates(catalogFilterParsers);
// Скидання всіх фільтрів до дефолтних значень одним викликом
const resetFilters = useCallback(async () => {
await setFilters({
q: null,
page: null,
sort: null,
inStock: null,
category: null,
});
}, [setFilters]);
// Перевірка: чи застосовані не-дефолтні фільтри
const hasActiveFilters = useMemo(() => {
return (
filters.q !== '' ||
filters.page !== 1 ||
filters.sort !== CatalogSort.RELEVANCE ||
filters.inStock !== false ||
filters.category !== 'all'
);
}, [filters]);
return {
filters,
setFilters,
resetFilters,
hasActiveFilters,
};
}Якщо використовувати опцію history: 'push' без троттлінгу або дебаунсу при кожному натисканні клавіші в рядку пошуку, кожне натискання літери створить окремий запис у window.history. Щоб повернутися на попередню сторінку, користувачеві доведеться натиснути кнопку «Назад» 50 разів.
// ❌ АНТИПАТЕРН: Натискання кожної літери створює новий запис в історії!
const [query, setQuery] = useQueryState('q', {
history: 'push', // Спам історії браузера
});
// ✅ ПРАВИЛЬНО: history: 'replace' та обов'язковий throttleMs для пошуку
const [query, setQuery] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({
history: 'replace', // Перезаписує поточний запис
throttleMs: 350, // Троттлить запис в URL
shallow: true,
})
);Спроба вручну парсити URL-параметри через parseInt(searchParams.get('page')) без валідації призводить до NaN у рантаймі, якщо користувач вручну введе /catalog?page=foobar. Це ламає серверний рендер і породжує важковловимі 500-помилки.
// ❌ АНТИПАТЕРН: При ?page=abc поверне NaN!
export function BadComponent({ searchParams }: any) {
const page = parseInt(searchParams.page) || 1; // Вразливо до крайових значень
}
// ✅ ПРАВИЛЬНО: Парсер nuqs гарантує валідний fallback
import { parseAsInteger } from 'nuqs';
const safePage = parseAsInteger.withDefault(1).parseServerSide(searchParams.page);За замовчуванням у Next.js зміна маршруту запитує новий RSC Payload у сервера. Якщо в чисто клієнтському інтерфейсі з TanStack Query забути прапорець shallow: true, кожне перемикання фільтра надсилатиме зайвий важкий HTTP-запит до Next.js App Router, викликаючи підвисання анімацій.
// ❌ АНТИПАТЕРН: Викликає важкий RSC refetch на кожен клік
const [tab, setTab] = useQueryState('tab');
// ✅ ПРАВИЛЬНО: Чисто клієнтське перемикання без навантаження на Next.js сервер
const [tab, setTab] = useQueryState('tab', {
shallow: true,
});Якщо серіалізатор записує параметри, що збігаються з дефолтними значеннями (?page=1&sort=relevance&q=), адресний рядок перетворюється на нечитабельну «кашу», погіршуючи SEO та знижуючи конверсію поширення посилань.
// ✅ ПРАВИЛЬНО: nuqs автоматично видаляє ключ із URL, якщо його значення дорівнює default:
const pageParser = parseAsInteger.withDefault(1);
// При page === 1 параметр ?page=1 буде автоматично видалено з рядка URL!