# Product catalog grid

> Products in a grid with prices, variants and a filter by collection.

- Block: [catalog](https://bettersupabase.com/blocks/catalog.md)
- Kind: screen
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/product-catalog-grid

## Install the block

```bash
npx better-supabase add catalog
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert badge button card empty skeleton
```

From `apps/examples/nextjs/src/features/shop/components/product-catalog-grid.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/shop/components/product-catalog-grid.tsx):

```tsx
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

/** Render inside `<Suspense>`: the organization's products as cards. */
export async function ProductCatalogGrid() {
  const session = await getSession();

  if (!can(session, "catalog.read")) {
    forbidden();
  }

  const [products, t, locale] = await Promise.all([
    getProducts(),
    getExtracted("shop"),
    getLocale(),
  ]);

  if (products.kind === "no-organization") {
    return null;
  }

  const canManage = can(session, "catalog.manage");

  const status = (product: Product) => {
    switch (product.status) {
      case "active": {
        return <Badge variant="success-light">{t("Active")}</Badge>;
      }

      case "draft": {
        return <Badge variant="secondary">{t("Draft")}</Badge>;
      }

      case "archived": {
        return <Badge variant="warning-light">{t("Archived")}</Badge>;
      }

      default: {
        const unknown: never = product.status;

        return unknown;
      }
    }
  };

  return (
    <div data-recipe="product-catalog-grid" className="flex flex-col gap-4">
      {products.kind === "error" ? (
        <Alert variant="destructive">
          <TriangleAlertIcon />
          <AlertTitle>{t("The products didn't load")}</AlertTitle>
          <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
          <AlertAction>
            <form action={retry}>
              <Button size="sm" variant="outline" type="submit">
                {t("Retry")}
              </Button>
            </form>
          </AlertAction>
        </Alert>
      ) : products.data.length === 0 ? (
        <Empty className="border">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <PackageIcon />
            </EmptyMedia>
            <EmptyTitle>{t("No products yet")}</EmptyTitle>
            <EmptyDescription>
              {t("Add a product with a price to start taking orders.")}
            </EmptyDescription>
          </EmptyHeader>
          {canManage ? (
            <EmptyContent>
              <CreateProductDialog />
            </EmptyContent>
          ) : null}
        </Empty>
      ) : (
        <>
          <div className="flex items-center justify-between gap-2">
            <p className="text-sm text-muted-foreground">
              {t("{count, plural, one {# product} other {# products}}", {
                count: products.data.length,
              })}
            </p>
            <div className="flex items-center gap-2">
              {can(session, "promotions.manage") ? (
                <CreatePromotionCodeDialog />
              ) : null}
              {canManage ? <CreateProductDialog variant="outline" /> : null}
            </div>
          </div>
          <ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
            {products.data.map((product) => (
              <li key={product.id}>
                <Card>
                  <CardHeader>
                    <CardTitle>{product.title}</CardTitle>
                    <CardDescription className="font-mono">
                      {product.sku}
                    </CardDescription>
                    <CardAction>{status(product)}</CardAction>
                  </CardHeader>
                  <CardContent className="flex items-end justify-between gap-2">
                    <span className="text-sm font-semibold tabular-nums">
                      {product.price
                        ? formatMoney(product.price, locale)
                        : t("No price")}
                    </span>
                    <span className="text-xs text-muted-foreground">
                      {product.available === null
                        ? t("Not stocked")
                        : t("{count, number} available", {
                            count: product.available,
                          })}
                    </span>
                  </CardContent>
                </Card>
              </li>
            ))}
          </ul>
        </>
      )}
    </div>
  );
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Product catalog grid" in a React web app that uses shadcn/ui components. Products in a grid with prices, variants and a filter by collection.

It uses the better-supabase `catalog` block (Catalog). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add catalog` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert badge button card empty skeleton`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the screen. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/shop/components/product-catalog-grid.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/product-catalog-grid.md
- Block docs: https://bettersupabase.com/docs/blocks/catalog
```
