# Listing Page

Filterable listing page with search, facets, sorting, and pagination.

## Installation

```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/listing-page.json
```

[Registry JSON](https://ui.uptoolkit.com/r/listing-page.json)

## Preview

```tsx
import Page from "app/listings/page";

export function Preview() {
  return <Page />;
}
```


## Source

### page.tsx

```tsx
import { Button } from "@/components/ui/button";

import { ListingBrowser } from "@/components/listing-browser";
import { sampleListings } from "@/lib/listing-data";

/**
 * Listing page shell.
 *
 * The page itself stays a server component: it renders the heading and hands
 * the records to `ListingBrowser`, which owns the search, facet, sort, and
 * pagination state on the client. Replace `sampleListings` with your own query.
 */
export default function Page() {
  const listings = sampleListings;

  return (
    <main className="min-h-svh bg-muted/40">
      <div className="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-8">
        <header className="flex flex-wrap items-end justify-between gap-4">
          <div className="flex flex-col gap-1">
            <h1 className="m-0 text-2xl font-semibold tracking-tight">Listings</h1>
            <p className="m-0 text-sm text-muted-foreground">
              Browse {listings.length} tools. Filter by category and location, then sort the
              results.
            </p>
          </div>
          <Button>Submit a listing</Button>
        </header>

        <ListingBrowser listings={listings} />
      </div>
    </main>
  );
}
```


### components/listing-browser.tsx

```tsx
"use client";

import * as React from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";

import {
  filterListings,
  formatListingPrice,
  getListingFacets,
  listingSortOptions,
  sortListings,
  type Listing,
  type ListingSort,
} from "@/lib/listing-data";

type ListingBrowserProps = {
  listings: readonly Listing[];
  /** Results per page. */
  pageSize?: number;
};

/**
 * Client half of the listing page: search, facets, sorting, and pagination all
 * run against the `listings` prop, so the server component above can fetch
 * however it likes and stay a server component.
 */
function ListingBrowser({ listings, pageSize = 4 }: ListingBrowserProps) {
  const [query, setQuery] = React.useState("");
  const [categories, setCategories] = React.useState<string[]>([]);
  const [locations, setLocations] = React.useState<string[]>([]);
  const [sort, setSort] = React.useState<ListingSort>("relevance");
  const [page, setPage] = React.useState(1);

  const categoryFacets = React.useMemo(() => getListingFacets(listings, "category"), [listings]);
  const locationFacets = React.useMemo(() => getListingFacets(listings, "location"), [listings]);

  const results = React.useMemo(
    () => sortListings(filterListings(listings, { query, categories, locations }), sort),
    [listings, query, categories, locations, sort],
  );

  const pageCount = Math.max(1, Math.ceil(results.length / pageSize));
  // Filters can shrink the result set below the active page; clamp instead of
  // resetting so the visible page never goes blank.
  const currentPage = Math.min(page, pageCount);
  const visibleResults = results.slice((currentPage - 1) * pageSize, currentPage * pageSize);
  const activeFilterCount = categories.length + locations.length + (query.trim() ? 1 : 0);

  function toggleFacet(
    value: string,
    selected: string[],
    setSelected: React.Dispatch<React.SetStateAction<string[]>>,
  ) {
    setPage(1);
    setSelected(
      selected.includes(value) ? selected.filter((entry) => entry !== value) : [...selected, value],
    );
  }

  function clearFilters() {
    setQuery("");
    setCategories([]);
    setLocations([]);
    setPage(1);
  }

  return (
    <div className="flex flex-col gap-6 lg:flex-row">
      <aside className="w-full shrink-0 lg:w-64">
        <Card className="gap-4 py-4">
          <CardHeader className="flex-row items-center justify-between">
            <CardTitle className="text-base">Filters</CardTitle>
            {activeFilterCount > 0 ? (
              <Button variant="ghost" size="xs" onClick={clearFilters}>
                Clear
              </Button>
            ) : null}
          </CardHeader>
          <CardContent className="flex flex-col gap-4">
            <div className="flex flex-col gap-1.5">
              <Label htmlFor="listing-search">Search</Label>
              <Input
                id="listing-search"
                value={query}
                placeholder="Search listings..."
                onChange={(event) => {
                  setQuery(event.target.value);
                  setPage(1);
                }}
              />
            </div>

            <Separator />

            <FacetGroup
              legend="Category"
              facets={categoryFacets}
              selected={categories}
              onToggle={(value) => toggleFacet(value, categories, setCategories)}
            />

            <Separator />

            <FacetGroup
              legend="Location"
              facets={locationFacets}
              selected={locations}
              onToggle={(value) => toggleFacet(value, locations, setLocations)}
            />
          </CardContent>
        </Card>
      </aside>

      <div className="flex min-w-0 flex-1 flex-col gap-4">
        <div className="flex flex-wrap items-center justify-between gap-3">
          <p className="m-0 text-sm text-muted-foreground">
            {results.length} {results.length === 1 ? "result" : "results"}
          </p>
          <div className="flex flex-wrap items-center gap-1">
            {listingSortOptions.map((option) => (
              <Button
                key={option.value}
                variant={sort === option.value ? "secondary" : "ghost"}
                size="sm"
                aria-pressed={sort === option.value}
                onClick={() => {
                  setSort(option.value);
                  setPage(1);
                }}
              >
                {option.label}
              </Button>
            ))}
          </div>
        </div>

        {visibleResults.length === 0 ? (
          <Card className="items-center py-12 text-center">
            <CardContent className="flex flex-col items-center gap-2">
              <p className="m-0 text-sm font-medium">No listings match those filters</p>
              <p className="m-0 text-sm text-muted-foreground">
                Try a broader search or clear a facet.
              </p>
              <Button variant="outline" size="sm" className="mt-2" onClick={clearFilters}>
                Clear filters
              </Button>
            </CardContent>
          </Card>
        ) : (
          <ul className="m-0 grid list-none gap-4 p-0 md:grid-cols-2">
            {visibleResults.map((listing) => (
              <li key={listing.id}>
                <ListingCard listing={listing} />
              </li>
            ))}
          </ul>
        )}

        <Pagination page={currentPage} pageCount={pageCount} onPageChange={setPage} />
      </div>
    </div>
  );
}

function FacetGroup({
  legend,
  facets,
  selected,
  onToggle,
}: {
  legend: string;
  facets: readonly { value: string; count: number }[];
  selected: readonly string[];
  onToggle: (value: string) => void;
}) {
  return (
    <fieldset className="flex flex-col gap-2 border-0 p-0">
      <legend className="mb-1 text-sm font-medium">{legend}</legend>
      {facets.map((facet) => {
        const isSelected = selected.includes(facet.value);

        return (
          <Button
            key={facet.value}
            variant={isSelected ? "secondary" : "ghost"}
            size="sm"
            aria-pressed={isSelected}
            className="justify-between"
            onClick={() => onToggle(facet.value)}
          >
            <span>{facet.value}</span>
            <span className="text-muted-foreground">{facet.count}</span>
          </Button>
        );
      })}
    </fieldset>
  );
}

function ListingCard({ listing }: { listing: Listing }) {
  return (
    <Card className="h-full gap-3">
      <CardHeader>
        <div className="flex items-start justify-between gap-2">
          <CardTitle className="text-base">{listing.title}</CardTitle>
          {listing.featured ? <Badge variant="secondary">Featured</Badge> : null}
        </div>
        <CardDescription>{listing.summary}</CardDescription>
      </CardHeader>
      <CardContent className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
        <Badge variant="outline">{listing.category}</Badge>
        <span>{listing.location}</span>
        <span aria-hidden>&middot;</span>
        <span>{listing.rating.toFixed(1)} &#9733;</span>
        <span aria-hidden>&middot;</span>
        <span className="font-medium text-foreground">{formatListingPrice(listing.price)}</span>
        <span className="ml-auto text-xs">Updated {listing.updatedAt}</span>
      </CardContent>
    </Card>
  );
}

function Pagination({
  page,
  pageCount,
  onPageChange,
}: {
  page: number;
  pageCount: number;
  onPageChange: (page: number) => void;
}) {
  return (
    <nav aria-label="Pagination" className="flex items-center justify-between gap-2">
      <Button
        variant="outline"
        size="sm"
        disabled={page <= 1}
        onClick={() => onPageChange(page - 1)}
      >
        Previous
      </Button>
      <p className="m-0 text-sm text-muted-foreground">
        Page {page} of {pageCount}
      </p>
      <Button
        variant="outline"
        size="sm"
        disabled={page >= pageCount}
        onClick={() => onPageChange(page + 1)}
      >
        Next
      </Button>
    </nav>
  );
}

export { ListingBrowser, type ListingBrowserProps };
```


### lib/listing-data.ts

```ts
/** A single row in the listing. Swap this for your own record shape. */
export type Listing = {
  id: string;
  title: string;
  summary: string;
  category: string;
  location: string;
  price: number;
  rating: number;
  updatedAt: string;
  featured?: boolean;
};

export type ListingSort = "relevance" | "price-asc" | "price-desc" | "rating";

export const listingSortOptions = [
  { value: "relevance", label: "Most relevant" },
  { value: "price-asc", label: "Price: low to high" },
  { value: "price-desc", label: "Price: high to low" },
  { value: "rating", label: "Top rated" },
] as const satisfies readonly { value: ListingSort; label: string }[];

export const sampleListings: readonly Listing[] = [
  {
    id: "atlas-analytics",
    title: "Atlas Analytics",
    summary: "Self-hosted product analytics with a warehouse-native event model.",
    category: "Analytics",
    location: "Remote",
    price: 49,
    rating: 4.8,
    updatedAt: "2 days ago",
    featured: true,
  },
  {
    id: "beacon-status",
    title: "Beacon Status",
    summary: "Status pages and incident timelines that stay up when you do not.",
    category: "Monitoring",
    location: "Berlin",
    price: 19,
    rating: 4.5,
    updatedAt: "5 days ago",
  },
  {
    id: "cadence-scheduler",
    title: "Cadence Scheduler",
    summary: "Cron jobs with retries, backfills, and an audit trail per run.",
    category: "Infrastructure",
    location: "Remote",
    price: 0,
    rating: 4.2,
    updatedAt: "1 week ago",
  },
  {
    id: "drift-cms",
    title: "Drift CMS",
    summary: "Git-backed content modeling with typed queries and previews.",
    category: "Content",
    location: "Lisbon",
    price: 79,
    rating: 4.6,
    updatedAt: "1 week ago",
    featured: true,
  },
  {
    id: "ember-mail",
    title: "Ember Mail",
    summary: "Transactional email templates you can render and test locally.",
    category: "Content",
    location: "Remote",
    price: 29,
    rating: 4.1,
    updatedAt: "2 weeks ago",
  },
  {
    id: "flux-queue",
    title: "Flux Queue",
    summary: "A durable job queue with exactly-once delivery semantics.",
    category: "Infrastructure",
    location: "Toronto",
    price: 39,
    rating: 4.7,
    updatedAt: "3 weeks ago",
  },
  {
    id: "grid-insights",
    title: "Grid Insights",
    summary: "Dashboards that compile to static charts at build time.",
    category: "Analytics",
    location: "Remote",
    price: 0,
    rating: 3.9,
    updatedAt: "1 month ago",
  },
  {
    id: "halo-uptime",
    title: "Halo Uptime",
    summary: "Synthetic checks from twelve regions with alert deduplication.",
    category: "Monitoring",
    location: "Sydney",
    price: 25,
    rating: 4.4,
    updatedAt: "1 month ago",
  },
  {
    id: "ion-search",
    title: "Ion Search",
    summary: "Typo-tolerant search that indexes straight from your database.",
    category: "Infrastructure",
    location: "Remote",
    price: 59,
    rating: 4.9,
    updatedAt: "2 months ago",
  },
];

/** Distinct facet values with counts, derived from the listings themselves. */
export function getListingFacets(
  listings: readonly Listing[],
  key: "category" | "location",
): { value: string; count: number }[] {
  const counts = new Map<string, number>();

  for (const listing of listings) {
    counts.set(listing[key], (counts.get(listing[key]) ?? 0) + 1);
  }

  return Array.from(counts, ([value, count]) => ({ value, count })).toSorted((a, b) =>
    a.value.localeCompare(b.value),
  );
}

export function filterListings(
  listings: readonly Listing[],
  { query, categories, locations }: { query: string; categories: string[]; locations: string[] },
): Listing[] {
  const normalizedQuery = query.trim().toLowerCase();

  return listings.filter((listing) => {
    const matchesQuery =
      normalizedQuery.length === 0 ||
      `${listing.title} ${listing.summary}`.toLowerCase().includes(normalizedQuery);
    const matchesCategory = categories.length === 0 || categories.includes(listing.category);
    const matchesLocation = locations.length === 0 || locations.includes(listing.location);

    return matchesQuery && matchesCategory && matchesLocation;
  });
}

const listingComparators = {
  relevance: (a: Listing, b: Listing) => Number(b.featured ?? false) - Number(a.featured ?? false),
  "price-asc": (a: Listing, b: Listing) => a.price - b.price,
  "price-desc": (a: Listing, b: Listing) => b.price - a.price,
  rating: (a: Listing, b: Listing) => b.rating - a.rating,
} as const satisfies Record<ListingSort, (a: Listing, b: Listing) => number>;

export function sortListings(listings: readonly Listing[], sort: ListingSort): Listing[] {
  return [...listings].toSorted(listingComparators[sort]);
}

export function formatListingPrice(price: number): string {
  return price === 0 ? "Free" : `$${price}/mo`;
}
```



## Usage

A results page split the way most listing pages want to be split: the route stays a server
component and only the interactive shell is a client component.

```sh
npx shadcn@latest add @_cn/listing-page
```

Installs `app/listings/page.tsx` plus the `ListingBrowser` client component and the `listing-data`
helpers.

### Wiring it to real data

`page.tsx` passes an array of `Listing` records down; everything else is derived. Fetch in the
server component and the client shell keeps working unchanged:

```tsx
import { ListingBrowser } from "@/components/listing-browser";

export default async function Page() {
  const listings = await db.listing.findMany({ where: { published: true } });

  return <ListingBrowser listings={listings} pageSize={12} />;
}
```

### Facets

`getListingFacets` derives the facet values and their counts from the listings themselves, so a new
category shows up in the sidebar without a second source of truth. Filtering, sorting, and price
formatting live in `listing-data.ts` as pure functions, which keeps them testable and lets you move
any of them server-side once the result set outgrows the client.

### Pagination

Paging is clamped rather than reset: narrowing the filters while on page 3 lands you on the last
page that still has results instead of an empty one.

