Product listing with sidebar filters, sorting, pagination, and active filter chips, powered by the shared product-filters engine. Fifteen filter types including checkboxes, chips, color swatches, price sliders, rating, search, dropdowns, and cascading selects. Runs fully client-side on a given dataset, self-fetches from your API via productsUrl with an SSR-friendly first page, or emits queries in controlled server mode.
Product listing section with a filter sidebar, sorting, pagination, active filter chips, and a mobile filter toggle. Filtering is powered by the shared product-filters engine, which installs automatically with this block. It works in two modes: fully client-side on a dataset you pass in, or server-driven where every user action emits a query object for your backend.
Pro blocks install through the shadcn CLI with your license key and ship their full source. Docs and live previews stay open to everyone, so you can read every block's details first.
Swap YOUR_EMAIL and YOUR_KEY for the email and license key on your account. Find your license key on your account page.
Radix flavor
npx shadcn add "https://ui.beste.co/r/productlist1?email=YOUR_EMAIL&license_key=YOUR_KEY"Base UI flavor
npx shadcn add "https://ui.beste.co/r-base/productlist1?email=YOUR_EMAIL&license_key=YOUR_KEY"This installs the block to components/beste/block/productlist1.tsx, the engine to components/beste/component/product-filters.tsx, the 14 individual filter controls next to it, and the shadcn/ui dependencies they need.
The installed file exports productlist1Demo alongside the block: the exact props behind the preview above, self-fetching from a hosted sample API. Spread it to get a working grid in one line.
import { Productlist1, productlist1Demo } from "@/components/beste/block/productlist1";
export default function ShopPage() {
return <Productlist1 {...productlist1Demo} />;
}Then replace the demo with your own props. The example below runs in client mode: pass the full dataset and filtering, sorting, and pagination all run locally, no callbacks needed.
import { Productlist1 } from "@/components/beste/block/productlist1";
export default function ShopPage() {
return (
<Productlist1
heading="Shop all products"
pageSize={9}
sortOptions={[
{ label: "Featured", value: "featured" },
{ label: "Price: Low to High", value: "price-asc" },
]}
filterGroups={[
{
id: "category",
label: "Category",
type: "checkbox",
options: [
{ label: "Sneakers", value: "sneakers", count: 4 },
{ label: "T-Shirts", value: "t-shirts", count: 3 },
],
},
{ id: "price", label: "Price", type: "slider", min: 0, max: 300, step: 5, unit: "$" },
]}
products={[
{
title: "Velocity Running Sneakers",
href: "/products/velocity-sneakers",
price: 129,
compareAtPrice: 159,
image: { src: "/img/velocity.jpg", alt: "Red running sneakers" },
badge: "Sale",
rating: 4.8,
reviewCount: 214,
keywords: ["running", "red"],
attributes: { category: ["sneakers"] },
},
// ...
]}
/>
);
}Key rule for client mode: each product's attributes object must be keyed by the same id values you use in filterGroups. A product matches a group when any of its attribute values is selected. Products without the attribute are filtered out once the group has a selection.
| Prop | Type | Default | Description |
|---|---|---|---|
heading | string | – | Section heading |
description | string | – | Section intro text |
productsUrl | string | – | Endpoint the block fetches itself on every query (see remote mode below); products is ignored while set |
initialProducts | ProductItem[] | – | SSR-fetched first page; skips the initial client fetch |
initialTotalCount | number | – | Total matches for initialProducts |
products | ProductItem[] | [] | Client mode: the full dataset. Server mode: the current page only |
filterGroups | ProductFilterGroup[] | [] | Sidebar filter definitions, rendered in order |
sortOptions | ProductSortOption[] | [] | Sort dropdown entries. Omit to hide the dropdown |
defaultSort | string | first sort option | Initially selected sort value |
defaultFilters | Record<string, string[]> | {} | Initial selections keyed by group id, e.g. { category: ["sneakers"] }. Useful for restoring state from the URL |
pageSize | number | 9 | Products per page |
mode | "client" | "server" | "client" | See modes below |
totalCount | number | products.length | Server mode only: total matches across all pages, drives pagination |
isLoading | boolean | false | Server mode: shows a skeleton grid and disables pagination while fetching |
onQueryChange | (query: ProductQuery) => void | – | Called on every filter, sort, and page action |
className | string | – | Extra classes for the outer <section> |
type ProductItem = {
title: string;
href: string;
price: number;
compareAtPrice?: number;
image: { src: string; alt: string };
badge?: string;
rating?: number;
reviewCount?: number;
keywords?: string[]; // extra terms matched by "search" filter groups
attributes?: Record<string, string[]>; // filterable values keyed by group id
};Each entry in filterGroups is a ProductFilterGroup. Option-based groups match product.attributes[id].
type ProductFilterGroup = {
id: string;
label: string;
type?: string;
options?: ProductFilterOption[];
min?: number;
max?: number;
step?: number;
unit?: string;
placeholder?: string;
searchPlaceholder?: string;
clearLabel?: string;
multiple?: boolean;
dependsOn?: string;
};
type ProductFilterOption = {
label: string;
value: string;
count?: number;
swatch?: string;
parentValue?: string;
children?: ProductFilterOption[];
};type | Control | Selection | Matched against |
|---|---|---|---|
checkbox | checkbox list | multi | attributes[id] |
radio | radio list | single (click active to clear) | attributes[id] |
chips | button chips | multi | attributes[id] |
segmented | segmented tabs | single (click active to clear) | attributes[id] |
swatch | color circles | multi | attributes[id] |
toggle | switch rows | multi | attributes[id] |
select | dropdown | single | attributes[id] |
multiselect | checkbox dropdown | multi | attributes[id] |
combobox | searchable dropdown | single or multi (multiple) | attributes[id] |
tree | nested checkboxes | multi (parent covers subtree) | attributes[id] |
rating | star rows ("n & up") | single | product.rating (minimum) |
price | checkbox buckets | multi | product.price (values are "min-max") |
slider | range slider | single range | product.price; needs min/max/step |
range | min/max number inputs | single range, open-ended | product.price |
search | text input | text | product.title + product.keywords |
dependsOn)A group with dependsOn: "<parentId>" stays disabled until its parent has a selection and clears whenever the parent changes. Static: tag options with parentValue. Dynamic: leave options empty and pass loadOptions to the engine.
{ id: "category", label: "Category", type: "checkbox",
options: [{ label: "Shoes", value: "shoes" }] },
{ id: "subcategory", label: "Subcategory", type: "select", dependsOn: "category",
options: [{ label: "Sneakers", value: "sneakers", parentValue: "shoes" }] },ProductQueryWhat onQueryChange and the productsUrl request encode:
{
filters: Record<string, string[]>; // selections per group id
sort: string; // current sort value
page: number; // 1-based
pageSize: number;
}Filter value shapes inside filters: option groups → arrays of option values; slider/range → a single "min-max" string (open ends allowed, "50-" = 50 and up); search → a single string.
Every control this block renders is an individual Beste UI filter component from the Filter category — browse, preview, and install any of them on their own there:
filter-checkbox, filter-radio, filter-chips, filter-segmented, filter-swatch, filter-toggle, filter-select, filter-multiselect, filter-combobox, filter-tree, filter-rating, filter-slider, filter-range, filter-search.
They are wired together by the hidden product-filters engine that ships with this block, so a single install pulls in every control plus the state, debounce, cascading, and query logic. To build your own layout, use the engine directly: useProductFilters + ProductFilterField (renders the right control per group) + ProductFilterChips, with matchesProductFilters and sortProducts backing client mode.
productsUrl): the easiest server-driven setupPoint the block at an endpoint and it fetches its own data on every filter, sort, and page action. The endpoint receives ?filters=<json>&sort=&page=&pageSize= and must return { products, totalCount }. Filter semantics by group id: q is word search, price gets "min-max" range strings, rating a minimum threshold, everything else attribute-value arrays (this repo's /api/dummy-productlist route is a reference implementation with 100 products).
// Client-only: first page loads with a spinner
<Productlist1 productsUrl="/api/products" pageSize={20} filterGroups={...} />For an SSR first page, fetch page 1 in a server component and pass it down; the block then skips the initial client fetch and only subsequent interactions hit the network from the client:
// app/shop/page.tsx (server component)
export default async function ShopPage() {
const res = await fetch(
"https://your-site.com/api/products?filters={}&sort=featured&page=1&pageSize=20",
{ cache: "no-store" }
);
const data = await res.json();
return (
<Productlist1
productsUrl="/api/products"
initialProducts={data.products}
initialTotalCount={data.totalCount}
pageSize={20}
filterGroups={/* ... */ []}
/>
);
}mode: "server"): full controlIf you want to own the fetching yourself (custom caching, URL sync), use server mode; the component owns only the UI state and you feed data back through props:
"use client";
import { useCallback, useEffect, useState } from "react";
import { Productlist1 } from "@/components/beste/block/productlist1";
const PAGE_SIZE = 12;
export function CatalogPage() {
const [products, setProducts] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const fetchProducts = useCallback(async (query) => {
setIsLoading(true);
const params = new URLSearchParams({
sort: query.sort,
page: String(query.page),
pageSize: String(query.pageSize),
filters: JSON.stringify(query.filters),
});
const res = await fetch(`/api/products?${params}`);
const data = await res.json();
setProducts(data.products);
setTotalCount(data.totalCount);
setIsLoading(false);
}, []);
useEffect(() => {
// Initial load: the component does not emit a query on mount
fetchProducts({ filters: {}, sort: "featured", page: 1, pageSize: PAGE_SIZE });
}, [fetchProducts]);
return (
<Productlist1
mode="server"
products={products}
totalCount={totalCount}
isLoading={isLoading}
pageSize={PAGE_SIZE}
onQueryChange={fetchProducts}
filterGroups={/* same as client mode */ []}
sortOptions={[{ label: "Featured", value: "featured" }]}
/>
);
}Server mode notes:
products is treated as the already filtered, sorted, current page. No client-side processing happens.totalCount drives the "Showing x-y of z" text and the number of pagination buttons. Without it, pagination assumes a single page."newest", "bestselling"), not just the four built-in ones.onQueryChange on mount. Do the initial fetch yourself (see the useEffect above).onQueryChange by 300ms. All other actions emit immediately.dependsOn it."featured" (input order), "price-asc", "price-desc", and "rating". Other values fall back to input order.radio, segmented, or rating option clears that group.lg, the sidebar is hidden behind a "Filters" button that shows the active filter count.$ prefix on product cards. Change the two ${product.price} spots in the grid markup if you need another currency.next/link). Product images render with a plain <img>, so no next.config images.remotePatterns setup is needed for remote hosts."use client"), so it composes into any App Router page.-, so negative prices are not supported.Wiring the form up
This block ships the form markup only; state, validation, and submit are yours to add. Our guide wires the shadcn Field primitives to React Hook Form, TanStack Form, and Formisch on one field system.
productlist3
Dense product grid with an always-visible inline filter toolbar: search, a segmented category, a cascading subcategory select, and a searchable brand combobox, powered by the shared product-filters engine. Self-fetches from your API via productsUrl with an SSR-friendly first page, runs client-side on a dataset, or emits queries in controlled server mode.
productlist2
Product catalog with a single Filters button that opens a side drawer: keyword search, cascading category and subcategory selects, a searchable multi-brand combobox, a size multiselect, and a segmented gender control, powered by the shared product-filters engine. Runs fully client-side on a given dataset, self-fetches from your API via productsUrl with an SSR-friendly first page, or emits queries in controlled server mode.
productlist4
Product grid with a left filter sidebar where hovering a card reveals a quick-shop tray with sizes and an add-to-cart button, plus a category tag and sale badge, powered by the shared product-filters engine. Self-fetches from your API via productsUrl with an SSR-friendly first page, runs client-side on a dataset, or emits queries in controlled server mode.
productlist5
Three-column product feed led by a row of category pills, with a search and sort, and a Load more button plus progress bar that appends the next batch instead of paginating, powered by the shared product-filters engine. Self-fetches from your API via productsUrl with an SSR-friendly first page, runs client-side on a dataset, or emits queries in controlled server mode.