AACsearch
SDKРецепты

Страница списка товаров

Серверно-рендеренная PLP электронной коммерции с фильтрами, сортировкой, пагинацией и SEO-дружественными URL. Объединяет паттерны фасетного поиска и пагинации в завершённую страницу.

Страница списка товаров (PLP) — /c/shoes, /brand/nike, /sale — это рабочая лошадка любой витрины электронной коммерции. Этот рецепт создаёт её с AACsearch в качестве источника данных, с серверным рендерингом для SEO и всем состоянием в URL.

Что вы создаёте

app/c/[slug]/page.tsx — страницу Next.js App Router, которая:

  • Отображает 24 товара на странице, на серверной стороне.
  • Читает ?q=, ?brand=, ?sort=, ?page= из URL.
  • Возвращает 200 OK + полный HTML при первом запросе (хорошо для SEO и SSR-кешей).
  • Обновляется без полной перезагрузки страницы через router.push для клиентских взаимодействий.
  • Отображает ссылки пагинации с rel="prev"/"next".

Страница

// app/c/[slug]/page.tsx
import { SearchClient } from "@aacsearch/client";
import { notFound } from "next/navigation";
import { FacetSidebar } from "@/components/facet-sidebar";
import { ResultGrid } from "@/components/result-grid";
import { Pagination } from "@/components/pagination";
import { SortDropdown } from "@/components/sort-dropdown";

const client = new SearchClient({
  baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
  apiKey: process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY!,
  indexSlug: "products",
});

const PER_PAGE = 24;

const SORT_OPTIONS = {
  relevance: undefined, // по умолчанию
  "price-asc": "price:asc",
  "price-desc": "price:desc",
  newest: "created_at:desc",
  bestseller: "sales_count:desc",
} as const;

type Params = { slug: string };
type SearchParams = Record<string, string | string[] | undefined>;

export default async function CategoryPage({
  params,
  searchParams,
}: {
  params: Params;
  searchParams: SearchParams;
}) {
  const page = Number(searchParams.page ?? 1);
  const sort = (searchParams.sort as keyof typeof SORT_OPTIONS) ?? "relevance";

  const filters = [`categories:=${JSON.stringify(params.slug)}`];
  if (searchParams.brand) {
    const brands = Array.isArray(searchParams.brand)
      ? searchParams.brand
      : [searchParams.brand];
    filters.push(`brand:[${brands.join(",")}]`);
  }
  if (searchParams.priceMin)
    filters.push(`price:>=${Number(searchParams.priceMin)}`);
  if (searchParams.priceMax)
    filters.push(`price:<=${Number(searchParams.priceMax)}`);

  const result = await client.search({
    q: (searchParams.q as string) ?? "*",
    queryBy: "title,brand,description",
    filterBy: filters.join(" && "),
    sortBy: SORT_OPTIONS[sort],
    facetBy: "brand,price",
    page,
    perPage: PER_PAGE,
  });

  if (page === 1 && result.found === 0) {
    // Пустая страница категории — отрисовать рецепт «ничего не найдено» вместо 404
    return <EmptyCategory slug={params.slug} />;
  }

  const totalPages = Math.ceil(result.found / PER_PAGE);

  return (
    <div className="plp">
      <header>
        <h1>{prettySlug(params.slug)}</h1>
        <p>{result.found} товаров</p>
        <SortDropdown current={sort} options={Object.keys(SORT_OPTIONS)} />
      </header>

      <div className="plp-body">
        <FacetSidebar facets={result.facetCounts} active={searchParams} />
        <ResultGrid hits={result.hits} />
      </div>

      <Pagination current={page} total={totalPages} />
    </div>
  );
}

function prettySlug(s: string) {
  return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

function EmptyCategory({ slug }: { slug: string }) {
  return (
    <div className="empty-category">
      <h2>В категории {prettySlug(slug)} пока нет товаров.</h2>
      <a href="/">Смотреть все категории</a>
    </div>
  );
}

Компонент пагинации

// components/pagination.tsx
import Link from "next/link";

export function Pagination({
  current,
  total,
}: {
  current: number;
  total: number;
}) {
  if (total <= 1) return null;

  const prev = current > 1 ? current - 1 : null;
  const next = current < total ? current + 1 : null;

  return (
    <nav aria-label="Пагинация">
      {prev && (
        <Link rel="prev" href={`?page=${prev}`}>
          ← Назад
        </Link>
      )}
      <span>
        Страница {current} из {total}
      </span>
      {next && (
        <Link rel="next" href={`?page=${next}`}>
          Вперёд →
        </Link>
      )}
    </nav>
  );
}

rel="prev" / rel="next" больше не используются Google как сигналы ранжирования, но они помогают скринридерам и другим краулерам. Включить их не помешает.

SEO-метаданные

Генерируйте мета-теги для каждой страницы из ответа AACsearch:

import type { Metadata } from "next";

export async function generateMetadata({
  params,
  searchParams,
}: {
  params: Params;
  searchParams: SearchParams;
}): Promise<Metadata> {
  const page = Number(searchParams.page ?? 1);
  const result = await client.search({
    q: "*",
    filterBy: `categories:=${JSON.stringify(params.slug)}`,
    perPage: 0, // только фасеты/подсчёты
  });

  const title = `${prettySlug(params.slug)} (${result.found}) — AACsearch Демо-магазин`;
  const description = `Купить ${result.found} товаров в категории ${prettySlug(params.slug)}. Бесплатная доставка при заказе от 50$.`;

  return {
    title: page === 1 ? title : `${title} — Страница ${page}`,
    description,
    alternates: {
      canonical: `/c/${params.slug}${page > 1 ? `?page=${page}` : ""}`,
    },
    robots: page > 50 ? { index: false } : undefined, // деиндексация очень глубоких страниц
  };
}

Настройка кеширования

Серверные компоненты по умолчанию выполняют запрос через SDK при каждом запросе. Для категорий с высоким трафиком оберните в unstable_cache:

import { unstable_cache } from "next/cache";

const cachedSearch = unstable_cache(
  (opts: Parameters<typeof client.search>[0]) => client.search(opts),
  ["plp-search"],
  { revalidate: 60, tags: ["products"] },
);

Инвалидируйте через revalidateTag("products") из вашего коннектора при изменении документов.

Связанные страницы

On this page