Поиск в 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 London
description: Concerts and music events in London
h1: Music events in London

Страница /search/sports получает другой набор:

title: Sports events
description: Upcoming sports events
h1: 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.​