AACsearch
SDKsCookbook

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.push for 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.

On this page