Tutorials

Build a Product Catalog with Meno

Create a product catalog with filterable listings, price range controls, sorting, pagination, and individual product pages -- all generating static HTML from CMS data.

Prerequisites: Meno is running at localhost:3000. You have completed the Quickstart. Familiarity with the Build a Blog tutorial is helpful but not required.

What you will build:

  • A "products" CMS collection with structured product data

  • A product listing page with category filters, price range, search, sort, and pagination

  • Individual product pages generated from a template

  • Static HTML with client-side interactivity powered by MenoFilter


Step 1: Create the Products Collection

  1. Click the CMS tab in the left sidebar.

  2. Click the + button to create a new collection.

  3. Set the ID to products and the Display Name to Products.

  4. Add the following fields:

| Field | Type | Required | Notes | |-------|------|----------|-------| | name | string | Yes | Product name | | slug | string | Yes | URL identifier (e.g. wireless-headphones) | | description | text | No | Detailed product description | | price | number | No | Price in dollars | | image | image | No | Product photo | | category | select | No | Options: Electronics, Clothing, Accessories, Home | | inStock | boolean | No | Default value: true | | rating | number | No | Star rating from 1 to 5 |

  1. Set the Slug Field to slug.

  2. Set the URL Pattern to /products/{{slug}}.

  3. Click Create.

Meno creates the template page at pages/templates/product.json and the data folder at cms/products/.


Step 2: Add Products

Populate the catalog with enough items to make filtering and pagination meaningful.

Add items manually

  1. In the CMS tab, click Products to expand the collection.

  2. Click + to create a new item.

  3. Fill in the fields: - name: Wireless Headphones - slug: wireless-headphones - description: Premium noise-cancelling headphones with 30-hour battery life. - price: 149 - image: Select an image or type /images/headphones.jpg. - category: Electronics - inStock: Toggle on. - rating: 4

  4. Click Save.

  5. Add at least 8-10 more products across different categories and price points. Here are some examples:

| Name | Category | Price | Rating | |------|----------|-------|--------| | Cotton T-Shirt | Clothing | 29 | 5 | | Leather Wallet | Accessories | 59 | 4 | | Desk Lamp | Home | 89 | 3 | | Bluetooth Speaker | Electronics | 79 | 5 | | Running Shoes | Clothing | 119 | 4 | | Watch Band | Accessories | 25 | 3 | | Throw Pillow | Home | 39 | 4 | | USB-C Hub | Electronics | 45 | 5 | | Sunglasses | Accessories | 69 | 4 | | Scented Candle | Home | 19 | 5 |

Bulk import with CSV

If you have product data in a spreadsheet, use CSV import instead of manual entry.

  1. Click the Import button at the top of the products item list.

  2. Select your CSV file. Column headers must match the field names: name, slug, description, price, image, category, inStock, rating.

  3. Each row creates one CMS item. Review the imported items in the list.


Step 3: Build the Product Listing Page

Create the page

  1. Switch to the Pages tab and click +. Name the page products.

  2. The page opens with an empty root div.

Add the page header

  1. With the root div selected, press Cmd+E and add a Navigation component.

  2. Add a Section below the Navigation. Inside it, add a Heading with text Our Products.

  3. Add a Text element below the heading: Browse our collection of quality products.

Add the filter container

  1. Select the root div and add another Section below the header section. This will hold all the filters, the product grid, and pagination controls.

  2. Select the new Section. In the Attributes panel, add: - data-meno-filter = products - data-meno-per-page = 8 - data-meno-types = {"price":"number","rating":"number"} - data-meno-url-sync = true

The data-meno-types attribute tells MenoFilter to treat price and rating as numbers rather than strings. This is essential for range filters and numeric sorting to work correctly.

Add the product grid

  1. Inside the filter Section, add a List element (CMS List).

  2. Configure the List in the Properties panel: - Source Type: collection - Source: products

  3. Inside the List, build the product card template: - Add a div as the card wrapper. - In the card's Attributes, add: - data-category = {{item.category}} - data-price = {{item.price}} - data-rating = {{item.rating}} - data-name = {{item.name}} - data-description = {{item.description}} - data-in-stock = {{item.inStock}} - Inside the card, add a link element with href set to /products/{{item.slug}}. - Inside the link, add an img with src = {{item.image}} and alt = {{item.name}}. - Below the image, add an h3 with content {{item.name}}. - Add a span with content ${{item.price}} for the price. - Add a span with content {{item.category}}. Style it as a small badge with a background color and border-radius.

Style the grid

  1. Select the List container element. Set display to grid, gridTemplateColumns to repeat(4, 1fr), and gap to 24px.

  2. At the Tablet breakpoint, change gridTemplateColumns to repeat(2, 1fr).

  3. At the Mobile breakpoint, change it to 1fr.


Step 4: Add Filtering and Search

Now add the interactive controls above the product grid. All of these work through MenoFilter data attributes -- no JavaScript to write.

Add the search bar

  1. Inside the filter Section, above the CMS List, add a div for the controls area. Style it with display flex, flexDirection column, and gap 16px.

  2. Inside the controls div, add an input element.

  3. Set its attributes: - type = text - placeholder = Search products... - data-meno-search (empty value) - data-meno-search-fields = name,description

Visitors can now type to filter products by name or description in real time.

Add category filter buttons

  1. Below the search input, add a div for the category buttons. Style it as a horizontal flex row with gap 8px and flexWrap wrap.

  2. Add a "show all" button: - Element: button - Text: All - Attribute: data-meno-clear (empty value)

  3. Add one button for each category:

| Button Text | Attributes | |-------------|------------| | Electronics | data-meno-filter-field="category" data-meno-filter-value="Electronics" | | Clothing | data-meno-filter-field="category" data-meno-filter-value="Clothing" | | Accessories | data-meno-filter-field="category" data-meno-filter-value="Accessories" | | Home | data-meno-filter-field="category" data-meno-filter-value="Home" |

MenoFilter adds an active class to the currently selected button. Style the default and active states so users can see which filter is applied.

Add price range inputs

  1. Below the category buttons, add a div for the price controls. Style it as a flex row with a label.

  2. Add a span with text Price: as the label.

  3. Add two input elements for the minimum and maximum price:

Min price input:

  • type = number

  • placeholder = Min

  • data-meno-range = price

  • data-meno-range-bound = min

Max price input:

  • type = number

  • placeholder = Max

  • data-meno-range = price

  • data-meno-range-bound = max

Visitors can enter a minimum and/or maximum price. MenoFilter compares these against each item's data-price attribute using the numeric type coercion you configured in Step 3.

Add sort controls

  1. Next to or below the price inputs, add a select dropdown for sorting.

  2. Set the attribute data-meno-sort (empty value) on the select element.

  3. Add option elements inside it:

| Option Text | Value | |-------------|-------| | Sort by... | (empty -- this is the default/no-sort option) | | Name A-Z | name:asc | | Name Z-A | name:desc | | Price: Low to High | price:asc | | Price: High to Low | price:desc | | Top Rated | rating:desc |

The value format is field:direction. MenoFilter reads the selected option and re-orders the items accordingly. Because you set data-meno-types with "price":"number" and "rating":"number", sorting compares numeric values rather than alphabetical strings.

Add a results count and empty state

  1. Below the controls area and above the product grid, add a p element.

  2. Inside it, add a span with attribute data-meno-count set to results. MenoFilter populates this with the count of matching items.

  3. Add text next to it: products found.

  4. Below the CMS List, add a div with attribute data-meno-empty (empty value). Set its content to No products match your filters. Try adjusting your search or price range.

This div is hidden when there are results and automatically shown when the filtered list is empty.


Step 5: Add Pagination

You already set data-meno-per-page="8" on the filter container in Step 3. Now add navigation controls so visitors can browse through pages.

Option A: Page navigation buttons

  1. Below the CMS List (but still inside the filter Section), add a div for the pagination bar. Style it as a centered flex row with gap 16px.

  2. Add a button with text Previous and attribute data-meno-page = prev. MenoFilter disables this button automatically when the user is on the first page.

  3. Add a span to show the current position. Inside it, place: - A span with attribute data-meno-page-current (MenoFilter fills in the current page number) - The text / - A span with attribute data-meno-page-total (MenoFilter fills in the total page count)

  4. Add a button with text Next and attribute data-meno-page = next. Automatically disabled on the last page.

Option B: Load More button

If you prefer a "load more" pattern instead of page numbers, replace the pagination bar with a single button.

  1. Remove the pagination div from Option A (if you added it).

  2. Below the CMS List, add a button with text Load More Products.

  3. Set the attribute data-meno-load-more = 8. Each click reveals 8 more items.

  4. MenoFilter automatically hides this button when all items are visible.

  5. To show how many items are left, add a span inside the button with attribute data-meno-remaining. The button text becomes: Load More (X remaining).

Choose either Option A or Option B -- they are mutually exclusive. Pagination resets the view to a new page; Load More appends items to the current view.

Add a per-page selector (optional)

  1. If you chose Option A, you can add a dropdown that lets visitors choose how many items to show per page.

  2. Add a select element with attribute data-meno-per-page-select (empty value).

  3. Add option elements: 8, 16, 24, All. The All option should have value 0.


Step 6: Create the Product Detail Template

Each product gets its own page generated from the template.

  1. In the Pages tab, navigate to templates/ and click product to open it.

  2. Build the product page layout:

Hero section

  1. Add a Navigation component at the top.

  2. Add a Section below it. Inside, create a two-column layout: - Set the Section's inner div to display flex, gap 48px. - Add a div on the left for the image. Add an img with src = {{cms.image}} and alt = {{cms.name}}. Style it with width 100% and borderRadius 8px. - Add a div on the right for the product info.

Product info

  1. Inside the right column div, add: - An h1 with content {{cms.name}}. - A span with content {{cms.category}}. Style it as a category badge. - A p with content ${{cms.price}}. Style it with a large font size and bold weight. - A p with content {{cms.description}}. - A span showing the rating: Rating: {{cms.rating}} / 5.

Responsive adjustments

  1. At the Tablet breakpoint, change the two-column flex to flexDirection column so the image stacks above the info.

Page metadata

  1. Click the page background to open page-level settings.

  2. Set title to {{cms.name}} | Products.

  3. Set description to {{cms.description}}.

  4. Verify the cms configuration: - id: products - slugField: slug - urlPattern: /products/{{slug}}


Step 7: Build and Deploy

  1. Run the build:

``bash bun run build ``

  1. Check the dist/ folder:

`` dist/ products/ index.html # Product listing page wireless-headphones/ index.html # Individual product page cotton-t-shirt/ index.html leather-wallet/ index.html ... (one folder per product) sitemap.xml robots.txt ``

  1. Open dist/products/index.html in a browser and verify each feature: - Search: Type "headphones" -- only matching products appear. - Category filters: Click "Electronics" -- the grid shows only electronics. The button receives the active class. - Price range: Enter 20 in min and 80 in max -- products outside that range disappear. - Sort: Select "Price: Low to High" -- the grid re-orders by price. - Pagination: If you have more than 8 products, the page buttons appear. Navigate forward and back. - Empty state: Apply conflicting filters (e.g. category "Electronics" with max price $10) -- the "No products match" message appears. - URL sync: After applying filters, check the browser URL. It contains query parameters like ?category=Electronics&price_min=20. Copy the URL, open it in a new tab, and the same filters are applied.

  1. Click a product card to visit its detail page. Verify the name, image, price, description, and rating render correctly.

  1. Deploy the dist/ folder:

```bash # Netlify netlify deploy --dir=dist --prod

# Cloudflare Pages npx wrangler pages deploy dist

# Or preview locally bunx serve dist ```


Under the Hood

Product Card with Data Attributes

Each product card inside the CMS List carries data-* attributes that MenoFilter reads for filtering. At build time, {{item.price}} becomes the actual value (e.g. 149), so the rendered HTML contains <div data-price="149">. MenoFilter coerces it to a number (via data-meno-types) and compares it against range inputs.

{
  "type": "list",
  "sourceType": "collection",
  "source": "products",
  "children": [
    {
      "type": "node",
      "tag": "div",
      "attributes": {
        "data-category": "{{item.category}}",
        "data-price": "{{item.price}}",
        "data-rating": "{{item.rating}}",
        "data-name": "{{item.name}}"
      },
      "children": [
        {
          "type": "link",
          "href": "/products/{{item.slug}}",
          "children": [
            { "type": "node", "tag": "img", "attributes": { "src": "{{item.image}}", "alt": "{{item.name}}" } },
            { "type": "node", "tag": "h3", "children": "{{item.name}}" },
            { "type": "node", "tag": "span", "children": "${{item.price}}" }
          ]
        }
      ]
    }
  ]
}

Product Template Page

The template at pages/templates/product.json uses {{cms.*}} expressions replaced per item at build time:

{
  "meta": {
    "title": "{{cms.name}} | Products",
    "description": "{{cms.description}}",
    "source": "cms",
    "cms": {
      "id": "products",
      "name": "Products",
      "slugField": "slug",
      "urlPattern": "/products/{{slug}}",
      "fields": {
        "name": { "type": "string", "required": true },
        "slug": { "type": "string", "required": true },
        "description": { "type": "text" },
        "price": { "type": "number" },
        "image": { "type": "image" },
        "category": { "type": "select", "options": ["Electronics", "Clothing", "Accessories", "Home"] },
        "inStock": { "type": "boolean", "default": true },
        "rating": { "type": "number" }
      }
    }
  },
  "root": {
    "type": "node",
    "tag": "div",
    "children": [
      { "type": "node", "tag": "h1", "children": "{{cms.name}}" },
      { "type": "node", "tag": "p", "children": "${{cms.price}}" },
      { "type": "node", "tag": "p", "children": "{{cms.description}}" }
    ]
  }
}

How MenoFilter Works at Runtime

MenoFilter is a lightweight client-side script that Meno automatically includes in built pages that use data-meno-filter. Here is the sequence:

  1. On page load, MenoFilter finds all elements with data-meno-filter and initializes a filter instance for each.

  2. It discovers the list items (children of data-meno-list or the CMS List output), filter buttons, search inputs, range inputs, sort controls, and pagination elements.

  3. When a visitor interacts with any control, MenoFilter reads the DOM state, applies filters to the item set, sorts and paginates the result, and shows/hides items by toggling display: none.

  4. Count elements, page indicators, and empty states update automatically.

  5. If data-meno-url-sync is enabled, filter state is written to URL query parameters and read back on page load.

No server round-trips occur. Everything happens in the browser against the pre-rendered HTML.


What You Built

By following this tutorial, you created:

  • A products CMS collection with 8 fields covering name, price, category, stock status, and rating.

  • A product listing page with a responsive grid that renders all products at build time.

  • Client-side filtering with category buttons, a price range slider, keyword search, and sorting -- all declarative via data attributes, no custom JavaScript.

  • Pagination (or Load More) to handle large catalogs without overwhelming the page.

  • Individual product pages generated from a template, each with its own URL and SEO metadata.

  • URL sync so filtered views are bookmarkable and shareable.

  • Static HTML output that works on any hosting provider with zero server dependencies.


Next Steps

  • CMS -- Add reference fields to link products to a "brands" or "collections" collection.

  • Building Components -- Extract the product card into a reusable component with variant styles.

  • Styling -- Add hover effects to product cards and style the active filter states.

  • Build a Blog -- Apply the same patterns to a content-driven blog.

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

© 2026 Company. All rights reserved.