Tutorials

Build a Product Catalog with Meno

In this tutorial you build a complete product catalog: a products CMS collection, a per-product detail page, a catalog grid that renders every product at build time, and live category, price, search, and sort controls. Everything compiles to static HTML — the grid is rendered at build time and the filtering runs in the browser with zero server code.

The catalog is a normal Astro project authored in the meno-astro dialect. You can build every piece visually in the Meno editor, or hand-edit the .astro files directly — the two stay in sync. This guide shows the resulting dialect source so you can read, copy, and version it.

If you have already followed Build a Blog, this will feel familiar: a collection plus a template route plus a list. The new parts here are the catalog grid and the filtering layer.

Step 1 — Plan the products collection

Start by deciding the fields each product needs. A catalog usually wants a display name, a URL slug, a price, a category, an image, a description, and a couple of flags for stock and featured status.

Field

Type

Notes

title

string

Product name, required

slug

string

URL identifier, required (wireless-headphones)

price

number

Numeric so you can range-filter and sort it

category

select

Fixed options, or a reference to a categories collection

image

file

Use accept: "image/*" — there is no separate image type

description

rich-text

Long-form copy for the detail page

inStock

boolean

Drives an "in stock only" filter

featured

boolean

Lets you highlight items on the homepage

Pick select for category when the set of categories is small and fixed; pick reference when categories are themselves content you want to manage (with their own names, slugs, or images). See CMS Fields for the full field-type reference, and CMS for how collections, schemas, and items fit together.

A numeric price matters: storing it as a number (not "$149") lets the price range and "low to high" sort compare values correctly.

Step 2 — Create the product template page

A collection's schema lives on its template page — the dynamic route that renders one product per item. Create src/pages/products/[slug].astro. The route directory (products/) comes from the urlPattern, and the meta.cms block is the source of truth for the collection's fields.

---
import { getCollection } from 'astro:content';
import { i18n, style } from 'meno-astro';
import { BaseLayout } from 'meno-astro/components';
import { cmsComponents } from '../../cmsComponents';
import { richTextWithComponents } from 'meno-astro';

export async function getStaticPaths() {
  const entries = await getCollection("products");
  return entries.map((entry) => ({
    params: { slug: entry.data.slug ?? entry.id },
    props: { cms: entry.data },
  }));
}

const { cms } = Astro.props;

const meta = {
  title: "{{cms.title}}",
  description: "{{cms.description}}",
  source: "cms",
  cms: {
    id: "products",
    name: "Products",
    slugField: "slug",
    urlPattern: "/products/{{slug}}",
    fields: {
      title: { type: "string", label: "Name", required: true },
      slug: { type: "string", label: "URL Slug", required: true },
      price: { type: "number", label: "Price" },
      category: {
        type: "select",
        label: "Category",
        options: ["electronics", "clothing", "accessories", "home"]
      },
      image: { type: "file", label: "Image", accept: "image/*" },
      description: { type: "rich-text", label: "Description" },
      inStock: { type: "boolean", label: "In Stock", default: true },
      featured: { type: "boolean", label: "Featured", default: false }
    }
  }
};
---

The getStaticPaths, the getCollection import, and const { cms } = Astro.props; are derived boilerplate — Meno regenerates them from meta.cms, so you edit the schema, not those lines. The directory src/pages/products/ is derived from urlPattern: "/products/{{slug}}". In the editor this template is addressed as /templates/products.

Now build the detail body. Plain fields render through i18n(cms.field); the rich-text description renders with richTextWithComponents so any components embedded in the copy render too. A bare {i18n(cms.description)} would print [object Object], since a rich-text value is a structured object, not a string.

<BaseLayout meta={meta}>
  <main class={style({
      base: { maxWidth: "1100px", margin: "0 auto", padding: "48px 24px" }
    })}>
    <div class={style({
        base: { display: "flex", gap: "48px" },
        tablet: {},
        mobile: { flexDirection: "column", gap: "24px" }
      })}>
      <div class={style({ base: { flex: "1", minWidth: "0" } })}>
        <img class={style({ base: { width: "100%", borderRadius: "8px" } })} src={i18n(cms.image)} alt={i18n(cms.title)} />
      </div>
      <div class={style({ base: { flex: "1", minWidth: "0" } })}>
        <span class={style({
            base: {
              display: "inline-block",
              fontSize: "13px",
              textTransform: "capitalize",
              color: "var(--text-light)",
              marginBottom: "12px"
            }
          })}>{i18n(cms.category)}</span>
        <h1 class={style({ base: { fontSize: "36px", marginBottom: "8px" } })}>{i18n(cms.title)}</h1>
        <p class={style({ base: { fontSize: "28px", fontWeight: "700", marginBottom: "24px" } })}>{`$${i18n(cms.price)}`}</p>
        <Fragment set:html={richTextWithComponents(cms.description, cmsComponents)} />
      </div>
    </div>
  </main>
</BaseLayout>

The cmsComponents registry (src/cmsComponents.ts) is generated for you and imported as derived boilerplate — you do not hand-edit it. For the full template-route mechanics, see CMS.

Step 3 — Add product items

Items live as JSON files under src/content/products/, one file per product. Each file's stem is its stable _id, and each item carries _createdAt. A <name>.draft.json sibling holds an unpublished edit; the file without .draft is the published one.

{
  "_id": "wireless-headphones",
  "slug": "wireless-headphones",
  "title": "Wireless Headphones",
  "description": "Premium noise-cancelling wireless headphones with 30-hour battery life",
  "category": "electronics",
  "price": 149,
  "image": "/images/headphones.webp",
  "inStock": true,
  "featured": true,
  "_createdAt": "2024-03-20T10:00:00.000Z"
}

Add enough items across categories and price points to make filtering meaningful — eight to a dozen is a good start. A second example:

{
  "_id": "cotton-tshirt",
  "slug": "cotton-tshirt",
  "title": "Organic Cotton T-Shirt",
  "description": "Soft, breathable organic cotton t-shirt available in multiple colors",
  "category": "clothing",
  "price": 29,
  "image": "/images/tshirt.webp",
  "inStock": true,
  "featured": false,
  "_createdAt": "2024-01-10T10:00:00.000Z"
}

In the Meno editor you add and edit items through the CMS panel; on disk they are just these JSON files, so you can also commit them with the rest of the project.

Step 4 — Build the catalog grid page

Now create the listing page, src/pages/products.astro. Resolve the collection at build time with getCollectionList, then map the result into a grid of cards. The list helper synthesizes a _url and _id per item, so you can link straight to each product's detail page.

---
import { getCollection } from 'astro:content';
import { getCollectionList, i18n, style } from 'meno-astro';
import { BaseLayout, Link } from 'meno-astro/components';

const meta = {
  title: "Products",
  description: "Browse our collection of quality products"
};

const productsList = await getCollectionList("products", { sort: { field: "title", order: "asc" } }, Astro, getCollection);
---
<BaseLayout meta={meta}>
  <main class={style({ base: { maxWidth: "1200px", margin: "0 auto", padding: "48px 24px" } })}>
    <h1 class={style({ base: { fontSize: "32px", marginBottom: "32px" } })}>Our Products</h1>
    <div class={style({
        base: { display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "24px" },
        tablet: { gridTemplateColumns: "repeat(2, 1fr)" },
        mobile: { gridTemplateColumns: "1fr" }
      })}>
      {productsList.map((product, productIndex) => (
        <Link href={i18n(product._url)} class={style({
            base: {
              display: "block",
              border: "1px solid var(--border)",
              borderRadius: "8px",
              overflow: "hidden",
              textDecoration: "none",
              color: "var(--text)"
            }
          })}>
          <img class={style({ base: { width: "100%", display: "block" } })} src={i18n(product.image)} alt={i18n(product.title)} />
          <div class={style({ base: { padding: "16px" } })}>
            <span class={style({
                base: {
                  fontSize: "12px",
                  textTransform: "capitalize",
                  color: "var(--text-light)"
                }
              })}>{i18n(product.category)}</span>
            <h3 class={style({ base: { fontSize: "18px", margin: "4px 0 8px" } })}>{i18n(product.title)}</h3>
            <span class={style({ base: { fontWeight: "700" } })}>{`$${i18n(product.price)}`}</span>
          </div>
        </Link>
      ))}
    </div>
  </main>
</BaseLayout>

A few things to note. The loop variable defaults to the singular of the source (productsproduct); the index is always <var>Index. The whole card is a <Link> whose href is the item's resolved _url. Every styled element uses style({...}) — never a raw class="..." string, which would not round-trip. Responsive overrides go in the tablet and mobile keys: the grid drops from four columns to two to one.

getCollectionList's query object accepts filter, sort, limit, offset, and items. Here sort: { field: "title", order: "asc" } orders the catalog alphabetically. See Lists for the full query shape and for prop-backed lists.

Step 5 — Add filtering and search

There are two complementary ways to slice a catalog: fixed sections computed at build time, and live controls that filter in the browser.

Build-time sections with queryList

When you want a fixed section — say, a "Featured" rail or a per-category shelf — filter the already-fetched list in memory with queryList. It runs at build time, so the output is plain static HTML with no client code.

---
const productsList = await getCollectionList("products", {}, Astro, getCollection);
---
<section>
  <h2 class={style({ base: { fontSize: "24px", marginBottom: "16px" } })}>Featured</h2>
  <div class={style({ base: { display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "24px" } })}>
    {queryList(productsList, { filter: { field: "featured", operator: "eq", value: true }, limit: 3 }).map((product, productIndex) => (
      <Link href={i18n(product._url)}>{i18n(product.title)}</Link>
    ))}
  </div>
</section>

Import queryList alongside getCollectionList from meno-astro. The filter operators (eq, and friends) and sort/limit options match the getCollectionList query shape — see Lists.

Live client-side filtering with MenoFilter

For interactive category, price, search, and sort controls, wrap the grid in a MenoFilter container. You add data-meno-* attributes to the elements; Meno includes the client script automatically in any built page that uses data-meno-filter. There is no JavaScript to write.

Put data-meno-filter on a wrapping element, give each card the data-* fields you want to filter on (rendered at build time from the item values), and add controls that target those fields. Tell MenoFilter which fields are numbers via data-meno-types so price range and numeric sort compare values rather than strings.

<div class={style({ base: { maxWidth: "1200px", margin: "0 auto", padding: "48px 24px" } })} data-meno-filter="products" data-meno-types={'{"price":"number"}'} data-meno-url-sync="true">
  <div class={style({ base: { display: "flex", flexDirection: "column", gap: "16px", marginBottom: "24px" } })}>
    <input class={style({ base: { padding: "8px 12px", border: "1px solid var(--border)", borderRadius: "6px" } })} type="text" placeholder="Search products..." data-meno-search="" data-meno-search-fields="title,description" />
    <div class={style({ base: { display: "flex", gap: "8px", flexWrap: "wrap" } })}>
      <button class={style({ base: { padding: "8px 12px", border: "1px solid var(--border)", borderRadius: "6px", cursor: "pointer" } })} data-meno-clear="">All</button>
      <button class={style({ base: { padding: "8px 12px", border: "1px solid var(--border)", borderRadius: "6px", cursor: "pointer" } })} data-meno-filter-field="category" data-meno-filter-value="electronics">Electronics</button>
      <button class={style({ base: { padding: "8px 12px", border: "1px solid var(--border)", borderRadius: "6px", cursor: "pointer" } })} data-meno-filter-field="category" data-meno-filter-value="clothing">Clothing</button>
      <button class={style({ base: { padding: "8px 12px", border: "1px solid var(--border)", borderRadius: "6px", cursor: "pointer" } })} data-meno-filter-field="category" data-meno-filter-value="accessories">Accessories</button>
      <button class={style({ base: { padding: "8px 12px", border: "1px solid var(--border)", borderRadius: "6px", cursor: "pointer" } })} data-meno-filter-field="category" data-meno-filter-value="home">Home</button>
    </div>
    <div class={style({ base: { display: "flex", gap: "8px", alignItems: "center" } })}>
      <span>Price:</span>
      <input class={style({ base: { width: "100px", padding: "8px", border: "1px solid var(--border)", borderRadius: "6px" } })} type="number" placeholder="Min" data-meno-range="price" data-meno-range-bound="min" />
      <input class={style({ base: { width: "100px", padding: "8px", border: "1px solid var(--border)", borderRadius: "6px" } })} type="number" placeholder="Max" data-meno-range="price" data-meno-range-bound="max" />
      <select class={style({ base: { padding: "8px", border: "1px solid var(--border)", borderRadius: "6px" } })} data-meno-sort="">
        <option value="">Sort by…</option>
        <option value="title:asc">Name A–Z</option>
        <option value="price:asc">Price: Low to High</option>
        <option value="price:desc">Price: High to Low</option>
      </select>
    </div>
    <p>
      <span data-meno-count="results">0</span>
      {" products found"}
    </p>
  </div>
  <div class={style({
      base: { display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "24px" },
      tablet: { gridTemplateColumns: "repeat(2, 1fr)" },
      mobile: { gridTemplateColumns: "1fr" }
    })} data-meno-list="">
    {productsList.map((product, productIndex) => (
      <Link href={i18n(product._url)} class={style({ base: { display: "block", border: "1px solid var(--border)", borderRadius: "8px", overflow: "hidden", textDecoration: "none", color: "var(--text)" } })} data-id={product._id} data-category={product.category} data-price={product.price} data-title={product.title} data-description={product.description}>
        <img class={style({ base: { width: "100%", display: "block" } })} src={i18n(product.image)} alt={i18n(product.title)} />
        <div class={style({ base: { padding: "16px" } })}>
          <h3 class={style({ base: { fontSize: "18px", margin: "4px 0 8px" } })}>{i18n(product.title)}</h3>
          <span class={style({ base: { fontWeight: "700" } })}>{`$${i18n(product.price)}`}</span>
        </div>
      </Link>
    ))}
  </div>
  <div class={style({ base: { padding: "40px", textAlign: "center", color: "var(--text-light)" } })} data-meno-empty="">No products match your filters.</div>
</div>

How the pieces connect:

  • data-meno-filter="products" on the wrapper turns on MenoFilter for that region.

  • data-meno-list="" marks the element whose children are the filterable items.

  • Each card carries data-category, data-price, data-title, data-description — bound to product.*, so the build prints the real values (data-price={product.price} becomes data-price="149").

  • data-meno-types={'{"price":"number"}'} coerces price to a number so range and sort are numeric.

  • data-meno-search plus data-meno-search-fields="title,description" gives live keyword search.

  • A data-meno-clear button shows all; each data-meno-filter-field/data-meno-filter-value button filters one category. MenoFilter adds an active class to the selected button so you can style its state.

  • data-meno-range="price" with data-meno-range-bound="min" / "max" builds a numeric range.

  • data-meno-sort reads field:direction option values and reorders the items.

  • data-meno-count="results" shows the match count; data-meno-empty is hidden when there are results and shown when none match.

  • data-meno-url-sync="true" writes the active filters to URL query parameters and restores them on load, so filtered views are shareable.

All of this runs against the pre-rendered HTML in the browser — no server round-trips. For the complete attribute catalog (including pagination, load-more, facet counts, and fuzzy search), see Filtering and Search.

Step 6 — Preview and publish

Preview the catalog in the Meno editor as you build: open /products to check the grid and the live filters, and open a product's URL (for example /products/wireless-headphones) to check the detail page. Edits you make visually and edits you make by hand to the .astro files stay in sync through the codec.

When the project builds, each item in src/content/products/ becomes its own static page under dist/products/<slug>/, the catalog renders at dist/products/, and the filtering script ships alongside it. The result is a normal static site you can deploy to any host.


You now have a full catalog: a products collection, a per-product template route, a build-time grid, fixed sections via queryList, and a live filtering layer via MenoFilter. To go further, link category to its own collection (see CMS and CMS Fields), extract the product card into a reusable component, or apply the same pattern to a content site in Build a Blog.

Building the future of digital experiences, one website at a time

Product

Resources

Comparisons

© 2026 Meno. All rights reserved.