В архитектуре современных веб-приложений адресная строка браузера представляет собой наиболее доступный, но исторически недооцененный глобальный стейт-менеджер. Сохранение фильтрации, сортировки, пагинации, открытых вкладок и поисковых параметров в изолированный 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!