SEO‑поиск в Next.js App Router, индексируемые landing pages из фильтров и URL

от автора

Поиск в 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/