Product listing page
A server-rendered ecommerce PLP with filters, sort, pagination, and SEO-friendly URLs. Combines the faceted search and pagination patterns into a complete page.
A product listing page (PLP) — /c/shoes, /brand/nike, /sale — is the workhorse of any ecommerce storefront. This recipe builds one with AACsearch as the data source, server-rendered for SEO, with all state in the URL.
What you build
app/c/[slug]/page.tsx — a Next.js App Router page that:
- Renders 24 products per page, server-side.
- Reads
?q=,?brand=,?sort=,?page=from URL. - Returns 200 OK + full HTML on first request (good for SEO and SSR caches).
- Updates without full page reload via
router.pushfor client interactions. - Renders pagination links with rel="prev"/"next".
Page
// 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, // default
"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) {
// Empty category page — render no-results recipe instead of 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} products</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>No products in {prettySlug(slug)} yet.</h2>
<a href="/">Browse all categories</a>
</div>
);
}Pagination component
// 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="Pagination">
{prev && (
<Link rel="prev" href={`?page=${prev}`}>
← Prev
</Link>
)}
<span>
Page {current} of {total}
</span>
{next && (
<Link rel="next" href={`?page=${next}`}>
Next →
</Link>
)}
</nav>
);
}rel="prev" / rel="next" are no longer used by Google as ranking signals, but they help screen readers and other crawlers. No harm including them.
SEO metadata
Generate per-page meta tags from the AACsearch response:
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, // facets/count only
});
const title = `${prettySlug(params.slug)} (${result.found}) — AACsearch Demo Store`;
const description = `Shop ${result.found} products in ${prettySlug(params.slug)}. Free shipping on orders over $50.`;
return {
title: page === 1 ? title : `${title} — Page ${page}`,
description,
alternates: { canonical: `/c/${params.slug}${page > 1 ? `?page=${page}` : ""}` },
robots: page > 50 ? { index: false } : undefined, // de-index very deep pages
};
}Cache tuning
Server Components fetch via the SDK on every request by default. For high-traffic categories, wrap in 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"] },
);Invalidate with revalidateTag("products") from your connector when documents change.
Related
- Faceted search — sidebar component
- Infinite scroll — alternative to pagination
- Filter, sort, pagination
- No-results recommendations — empty-category UI
Faceted search
Multi-select filter sidebar (brand, category, price range) driven by AACsearch facet counts. Filters AND-combined; counts updated reactively.
Infinite scroll
Load-more pagination using TanStack Query's `useInfiniteQuery` and an IntersectionObserver sentinel — one round-trip per scroll, no spinners-of-spinners.