Поиск в Next.js App Router связывает несколько механизмов — Server Components, dynamic routes, searchParams, metadata и клиентские фильтры. Если в проекте одна форма и несколько результатов, состояние можно держать в useState. В каталоге или агрегаторе каждый фильтр меняет URL, набор данных и правила индексации.
В статье на примере учебного проекта EventMap — агрегатора событий, разберем, как спроектировать систему фильтрации, которая генерирует чистые, индексируемые URL для поисковых роботов и обновляет выдачу при изменении фильтров. Страницы вроде «Концерты в Москве» или «Выставки в Петербурге» могут работать как отдельные landing pages. Для них нужны серверный HTML, постоянный URL, свой title, description, h1, canonical и отдельная политика index или noindex. Произвольные запросы, сортировки и глубокая пагинация остаются частью поиска и не попадают автоматически в индекс. Поиск построен через App Router, optional catch‑all route, серверный рендер результатов и общий набор функций для URL, metadata, canonical и sitemap.
Поиск по каталогу создаёт отдельное состояние для каждой комбинации фильтров. Категория, город, дата, сортировка, поисковая строка и номер страницы попадают в URL. При десяти категориях, пятидесяти городах и пяти вариантах сортировки получается 2500 комбинаций без учёта пагинации и свободного текста. Интерфейс должен обработать все допустимые комбинации. В поисковый индекс обычно попадает только часть из них. Страница категории music, страница города london и сочетание music/london могут содержать устойчивую подборку. Запрос ?q=free+jazz+tonight, сортировка по цене и восьмая страница выдачи относятся к текущему действию пользователя. Фильтры без отдельной политики создают большое пространство URL. Google описывает эту проблему в документации по faceted navigation. Изменение каждого параметра создаёт новый адрес, краулер тратит запросы на многочисленные комбинации и позже обнаруживает, что многие из них не содержат самостоятельного материала. (Google for Developers)
Индексируемая landing page содержит постоянный URL, серверный HTML, свой title, description, h1, внутренние ссылки и набор результатов. Список таких страниц задаёт приложение. Произвольная поисковая выдача остаётся доступной пользователю, но не входит автоматически в этот список. App Router принимает сегменты пути через params, query string через searchParams и строит metadata через generateMetadata. Pages и layouts работают как Server Components по умолчанию. Dynamic Segments подходят для категории, города и других частей адреса, известных только во время запроса или сборки. (Next.js)
Один parser должен обслуживать страницу и metadata. Один builder должен собирать внутренние ссылки, canonical и URL для sitemap. Отдельная функция определяет статус страницы: index, noindex или 404. В EventMap этот набор собран для каталога событий. Проект здесь используется только как пример реализации. Категория и город лежат в path, свободный запрос и пагинация остаются в query string, результаты рендерит сервер.
Поисковая выдача и landing pages
Поисковая форма принимает произвольный ввод:
/search?q=jazz/search?q=concert+tonight/search?q=free+events&page=3
Такие URL зависят от пользовательского текста. Количество вариантов не ограничено. Заголовок может повторять запрос, а результаты могут исчезнуть после обновления каталога.
Landing pages собираются из контролируемых значений:
/search/music/search/london/search/music/london
У приложения есть список допустимых категорий и городов. Для каждой комбинации можно заранее определить заголовок, описание, canonical и правила индексации.
В App Router несколько вариантов адреса обслуживает optional catch‑all route:
app/ [locale]/ search/ [[...segments]]/ page.tsx
[[...segments]] принимает базовый /search, один сегмент /search/music и несколько сегментов /search/music/london. Next.js передаёт их странице как массив в params. (Next.js)
Параметры страницы:
type PageProps = { params: Promise<{ locale: string; segments?: string[]; }>; searchParams: Promise< Record<string, string | string[] | undefined> >;};
В Next.js 15 params и searchParams стали асинхронными Dynamic APIs, поэтому страница и generateMetadata читают их через await. (Next.js)
Фильтры и структура URL
Категорию можно записать в path или query string:
/search/music/search?category=music
Google поддерживает оба варианта. Для query string используется форма ?key=value. Постоянные URL должны одинаково выглядеть во внутренних ссылках, sitemap и canonical. (Google for Developers)
Path удобен для короткого контролируемого набора фильтров:
/search/music/search/sports/search/london/search/music/london
Query string подходит для значений с большим или неограниченным числом вариантов:
/search/music?q=jazz/search/music/london?page=2/search/music?sort=date
Порядок частей path фиксируется внутри приложения. Иначе одна комбинация получает несколько адресов:
/search/music/london/search/london/music
Повторяющиеся сегменты тоже требуют проверки:
/search/music/sports/search/london/berlin
Список поддерживаемых значений хранится рядом с parser:
export const knownCategories = [ "music", "sports", "arts", "film", "family", "design", "food", "art",];export const knownCities = [ "london", "berlin", "new-york",];
parseSearchSegments проходит по path и собирает фильтры:
export function parseSearchSegments( segments?: string[],): Partial<SearchFilters> { const filters: Partial<SearchFilters> = {}; for (const segment of segments ?? []) { const value = normaliseSearchValue(segment); if ( !filters.category && knownCategories.includes(value) ) { filters.category = value; continue; } if ( !filters.city && knownCities.includes(value) ) { filters.city = value; } } return filters;}
В production‑версии parser должен отдельно возвращать неизвестные и повторяющиеся сегменты. Адрес /search/music/unknown не должен показывать ту же страницу, что и /search/music.
Сборка ссылок
Фильтры собирают URL в нескольких местах — навигация, пагинация, canonical, sitemap, языковой переключатель. Ручная конкатенация создаёт разные варианты одного адреса.
Общий builder фиксирует порядок path и query string:
export function buildSearchHref({ locale, filters,}: { locale: Locale; filters: Partial<SearchFilters>;}): string { const params = new URLSearchParams(); const cleanParts = [ filters.category, filters.city, ].filter(Boolean); if (filters.q) { params.set("q", filters.q); } if (filters.page && filters.page > 1) { params.set("page", String(filters.page)); } const path = cleanParts.length > 0 ? `/${locale}/search/${cleanParts.join("/")}` : `/${locale}/search`; const query = params.toString(); return query ? `${path}?${query}` : path;}
Builder удаляет page=1 и всегда ставит категорию перед городом:
buildSearchHref({ locale: "en", filters: { category: "music", city: "london", page: 1, },});// /en/search/music/london
Фильтры выводятся через Link:
<Link href={buildSearchHref({ locale, filters: { category: category.slug, }, })}> {category.label}</Link>
В HTML остаётся обычный <a href>. Google использует такие ссылки для обнаружения страниц и не выполняет пользовательские действия с кнопками при обходе пагинации и каталога. (Google for Developers)
Server‑rendered search в App Router
Client‑side поиск начинает загрузку после первого рендера:
"use client";useEffect(() => { fetch(`/api/events?category=${category}`) .then((response) => response.json()) .then(setEvents);}, [category]);
Исходный HTML такой страницы может содержать общий заголовок, пустой список и индикатор загрузки. Результаты появляются после выполнения клиентского JavaScript.
Server Component читает URL до возврата JSX:
export default async function SearchPage({ params, searchParams,}: PageProps) { const locale = await getLocaleFromParams(params); const { segments } = await params; const filters = parseSearchInput({ segments, searchParams: await searchParams, }); return ( <SearchResultsSection filters={filters} locale={locale} /> );}
SearchResultsSection получает данные на сервере:
export async function SearchResultsSection({ filters, locale,}: SearchResultsSectionProps) { const searchResult = await getSearchPageEvents({ filters, locale, }); return ( <div className="events-grid"> {searchResult.events.map((event) => ( <EventCard event={event} key={event.id} locale={locale} /> ))} </div> );}
Карточки и ссылки detail pages входят в серверный ответ. Client Components остаются в кнопках избранного, переключателях и других интерактивных участках. Server render закрывает доставку HTML. Политику индексации он не определяет. Страница с серверной выдачей всё равно может оказаться дублем, пустой комбинацией или произвольным пользовательским запросом.
Metadata из URL
generateMetadata получает те же route props, что и page. Dynamic metadata может зависеть от сегментов пути, query string и серверных данных. Next.js включает результат в исходный HTML, если страница рендерит metadata на сервере. (Next.js)
Страница /search/music/london может получить:
title: Music events in Londondescription: Concerts and music events in Londonh1: Music events in London
Страница /search/sports получает другой набор:
title: Sports eventsdescription: Upcoming sports eventsh1: Sports events
Page и generateMetadata должны использовать один parser. Иначе результаты могут относиться к music/london, а title — только к music.
Обе функции вызывают parseSearchInput:
export async function generateMetadata({ params, searchParams,}: PageProps): Promise<Metadata> { const locale = await getLocaleFromParams(params); const { segments } = await params; const filters = parseSearchInput({ segments, searchParams: await searchParams, }); const meta = buildSearchMeta({ filters, locale, currentPage: Math.max(filters.page, 1), totalCount: 1, totalPages: 1, }); const seoPaths = buildSearchSeoPaths({ filters, locale, }); return toNextMetadata({ title: meta.title, description: meta.description, noIndex: !meta.indexPolicy.shouldIndex, ...seoPaths, });}
totalCount и totalPages заданы учебными значениями. В production index policy должна читать стабильный источник данных или отдельный SEO‑каталог, а не случайный ответ внешнего API.
Canonical и дубли search pages
Одна выдача может открываться по нескольким адресам:
/search?category=music&city=london/search/music/london/search/london/music/search/music/london?page=1
Canonical указывает предпочтительную версию среди одинаковых или близких страниц. Google относит rel="canonical" к сильным сигналам, sitemap к слабым. Внутренние ссылки должны вести на тот же выбранный URL. Индексируемая страница получает self‑referencing canonical. (Google for Developers)
Parser сначала приводит входные данные к одному объекту:
{ category: "music", city: "london", q: "", page: 1}
Builder собирает один адрес:
/search/music/london
Canonical строится через тот же buildSearchHref:
export function buildSearchSeoPaths({ filters, locale,}: { filters: SearchFilters; locale: Locale;}): SeoPaths { const getPath = (targetLocale: Locale) => buildSearchHref({ filters, locale: targetLocale, }); return { canonicalPath: getPath(locale), languageAlternates: buildLocalizedAlternates(getPath), };}
Дальше путь попадает в Metadata API:
return { title, description, alternates: { canonical, languages, },};
Категории с разными результатами получают собственные canonical:
/search/music → /search/music/search/sports → /search/sports
Canonical всех категорий на базовый /search склеит страницы с разным содержимым.
Noindex, 404 и пагинация
Search URL может быть технически корректным и слабым для индексации:
/search/search?q=jazz/search/music?sort=price
Такие страницы могут остаться доступными пользователю и получить noindex.
Неизвестный slug описывает другой случай:
/search/unknown-category/search/music/unknown-city
Приложение не поддерживает такие значения. Страница должна вернуть 404, а не показать общую выдачу под случайным адресом. Пустая категория обрабатывается по состоянию данных. Google рекомендует noindex для доступной категории без полезного содержимого. После удаления категории из навигации и внутреннего поиска можно возвращать 404.
Директива noindex работает после обхода страницы. Запрет URL через robots.txt может закрыть краулеру доступ к robots meta tag. Google определяет noindex как запрет показывать страницу в результатах поиска.
В проекте индексируемыми считаются страницы с категорией или городом и непустым controlled result:
function buildIndexPolicy({ filters, totalCount, totalPages,}: { filters: SearchFilters; totalCount: number; totalPages: number;}): SearchIndexPolicy { const hasLandingFilter = Boolean( filters.category || filters.city ); const isQueryOnly = Boolean( filters.q && !hasLandingFilter ); const isOutOfRange = filters.page > totalPages; if (totalCount === 0) { return { shouldIndex: false, label: "Noindex preview", reason: "empty results", }; } if (isQueryOnly || isOutOfRange) { return { shouldIndex: false, label: "Noindex preview", reason: "weak search URL", }; } return { shouldIndex: hasLandingFilter, label: hasLandingFilter ? "Index preview" : "Noindex preview", reason: hasLandingFilter ? "controlled landing" : "base search route", };}
Для production у результата нужны три состояния:
type IndexPolicy = | { type: "index" } | { type: "noindex" } | { type: "not-found" };
Страница за пределами пагинации получает 404. Индексируемые страницы пагинации используют отдельные URL и собственные canonical. Google отдельно рекомендует не направлять canonical всех страниц пагинации на первую.
Sitemap и набор landing pages
Sitemap содержит URL, которые сайт считает каноническими и пригодными для индексации. Произвольные поисковые строки туда не попадают:
/search?q=jazz/search?q=events+near+me/search/music?q=free
Внешний API тоже не должен создавать sitemap при каждом вызове. Список provider может измениться, вернуть пустой ответ или завершиться ошибкой. URL inventory хранится в контролируемом источнике — конфигурации, базе данных или CMS.
App Router создаёт /sitemap.xml через специальный файл app/sitemap.ts. Функция возвращает массив MetadataRoute.Sitemap. (Next.js)
search entries собираются из локалей и фиксированных категорий:
export function buildSearchSitemapEntries(): MetadataRoute.Sitemap { return locales.flatMap((locale) => [ createSitemapEntry({ path: routes.search(locale), changeFrequency: "daily", priority: 0.9, }), ...searchCategories.map((category) => createSitemapEntry({ path: `${routes.search(locale)}` + `/${category.slug}`, changeFrequency: "daily", priority: 0.8, }), ), ]);}
Файл app/sitemap.ts вызывает builder:
import type { MetadataRoute } from "next";import { buildSitemapEntries,} from "@/entities/seo/sitemapEntries";export default function sitemap(): MetadataRoute.Sitemap { return buildSitemapEntries();}
Текущая версия проекта добавляет базовый /search в sitemap, а metadata ставит для него noindex. Перед production базовый URL нужно убрать из sitemap либо изменить его index policy. Live Ticketmaster отвечает только за карточки на странице. Категории для sitemap, canonical и внутренней навигации остаются внутри приложения.
Состав search‑раздела
В готовой схеме URL проходит одну цепочку:
params + searchParams ↓parseSearchInput ↓SearchFilters ↓buildSearchHref ↓page + metadata + canonical + sitemap
Server Component возвращает h1, результаты и ссылки. generateMetadata возвращает title, description, canonical и robots. Index policy отделяет landing pages от произвольных запросов. Sitemap получает только контролируемые URL.
В проекте остаются две production‑правки — неизвестные сегменты должны возвращать 404, базовый /search нужно удалить из sitemap при сохранении noindex.
ссылка на оригинал статьи https://habr.com/ru/articles/1063570/