Страница списка товаров
Серверно-рендеренная 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") из вашего коннектора при изменении документов.
Связанные страницы
- Фасетный поиск — компонент боковой панели
- Бесконечная прокрутка — альтернатива пагинации
- Фильтрация, сортировка, пагинация
- Рекомендации при отсутствии результатов — UI пустой категории
Фасетный поиск
Боковая панель с множественным выбором фильтров (бренд, категория, диапазон цен) на основе фасетных подсчётов AACsearch. Фильтры комбинируются через И; подсчёты обновляются реактивно.
Бесконечная прокрутка
Пагинация с подгрузкой через `useInfiniteQuery` TanStack Query и sentinel на IntersectionObserver — один round-trip на скролл, без бесконечных спиннеров.