---
title: "Step 3: Add pages"
space: "Commera"
url: "https://docs.bwh.tech/commera/build-a-theme/add-pages"
updated: "2026-10-08"
---

In this step, you add the Acme home page and product page. Each page is a template, plus an optional Python controller that gets the data.

## What you'll build

- A home page with a hero and the eight newest products
- A product card macro that the home page uses
- A product page with sizes and an **Add to cart** button

## How a file becomes a URL

The **Themed Routes** table in **Shop Theme Settings** maps each URL to a file in `pages/`. Commera fills it when you install it:

| URL | File |
| --- | --- |
| `/en`, `/ar` | `pages/index.html` |
| `/en/products` | `pages/products/list.html` |
| `/en/products/<route>` | `pages/products/details.html` |
| `/en/cart` | `pages/cart/cart.html` |

The full table is in [Routes and pages](/commera/theme-reference/routes-and-pages). Acme has only two pages. For every other URL, Commera looks in Shop Base Theme, which has no pages, and then serves its core page.

## 1. Add the home page controller

Create `acme_theme/themes/acme/pages/index.py`:

```python
from commera.utils import get_product_list
from commera.www import index


def get_context(context):
	index.get_context(context)
	context.acme_tagline = "Everyday goods, honestly made."
	context.latest_products = get_product_list(page_length=8, sort_by="new_arrival")
	return context
```

The controller first calls Commera's own home page controller, `commera.www.index.get_context`. That call sets the SEO data and the home page settings. Then it adds the two values that the Acme template uses. `sort_by="new_arrival"` puts the newest products first. Without it, the list is in name order.

`commera.www.index`, `commera.www.products.details` and `commera.utils.get_product_list` are Commera's own page code, not a fixed API. Check your controllers after each Commera update.

A controller has these rules:

- Its name is the page's name with `.py`, in the same folder as the page.
- It defines `get_context(context)`. It can change `context`, or return a dict that Commera adds to the context.
- It uses absolute imports only, such as `from commera.www import index`.

:::caution[Use absolute imports in a controller]

Commera loads a controller from its file path, not as a module of your app. A relative import, such as `from . import helpers`, fails. Write `from acme_theme.helpers import ...` instead.

:::

## 2. Add a product card macro

Create `acme_theme/themes/acme/components/macros/product_card.html`:

```jinja
{% macro product_card(product) %}
<a class="acme-card" href="/{{ frappe.lang or 'en' }}/products/{{ product.route }}">
	<img src="{{ product.image or '/assets/commera/themes/shop_base_theme/images/placeholder.svg' }}" alt="{{ product.item_name }}" loading="lazy">
	<span class="acme-card-title">{{ product.item_name }}</span>
	<span class="acme-price">{{ get_currency_symbol() }}{{ frappe.utils.fmt_money(product.sale_price or 0, precision=2) }}</span>
</a>
{% endmacro %}
```

Shop Base Theme ships `placeholder.svg` for products without an image.

## 3. Add the home page template

Create `acme_theme/themes/acme/pages/index.html`:

```jinja
{% extends "components/theme_layout.html" %}
{% from "components/macros/product_card.html" import product_card %}

{% block body %}
<section class="acme-hero">
	<h1>{{ _("Welcome to {0}").format(app_name) }}</h1>
	<p>{{ acme_tagline }}</p>
	<a class="acme-button" href="/{{ frappe.lang or 'en' }}/products">{{ _("Shop all products") }}</a>
</section>

<section class="acme-section">
	<h2>{{ _("Latest products") }}</h2>
	<div class="acme-grid">
		{% for product in latest_products %}
			{{ product_card(product) }}
		{% endfor %}
	</div>
</section>
{% endblock %}
```

A page extends your layout and fills the `body` block. If a page adds to the `head` block, call `{{ super() }}` first, or the page loses the layout's stylesheets.

## 4. Add the product page

The product page uses Commera's product controller as it is. Create `acme_theme/themes/acme/pages/products/details.py`:

```python
from commera.www.products import details


def get_context(context):
	details.get_context(context)
	return context
```

Then create `acme_theme/themes/acme/pages/products/details.html`:

```jinja
{% extends "components/theme_layout.html" %}

{% block body %}
<section class="acme-product" x-data='{
		product: JSON.parse({{ product_variant.as_json() | tojson }}),
		selected_variant: {{ (json.dumps(selected_item) if size_selected else "") | tojson }},
		sizes: {{ available_sizes | tojson }},
		price: {{ (selected_price or 0) | tojson }},
		default_price: {{ (default_price or 0) | tojson }},
		brand: {{ (product.brand or "") | tojson }},
		select_size(size) {
			const url = new URL(window.location.href);
			url.searchParams.set("size", size);
			window.location.assign(url.toString());
		},
		add_to_cart() {
			if (!this.selected_variant) {
				toast($store.i18n.t("Please select a size first!"), { type: "warning" });
				return;
			}
			$store.cart.add(this.product, this.selected_variant, this.price, this.default_price, this.brand, this.sizes, 1);
			toast($store.i18n.t("Item added to your bag"), { type: "success" });
		}
	}'>
	<img class="acme-product-image" src="{{ (images or [None])[0] or '/assets/commera/themes/shop_base_theme/images/placeholder.svg' }}" alt="{{ product_variant.display_name }}">

	<div class="acme-product-info">
		<h1>{{ product_variant.display_name }}</h1>
		<p class="acme-price">{{ get_currency_symbol() }}{{ frappe.utils.fmt_money(selected_price or 0, precision=2) }}</p>

		{% if available_sizes | length > 1 %}
		<div class="acme-sizes">
			{% for size in available_sizes %}
			<button type="button" @click='select_size({{ size.size | tojson }})'
				class="acme-size{% if size_selected and selected_size == size.size %} is-selected{% endif %}">{{ size.size }}</button>
			{% endfor %}
		</div>
		{% endif %}

		<button type="button" class="acme-button" @click="add_to_cart()" {% if not product_in_stock %}disabled{% endif %}>
			{{ _("Add to cart") if product_in_stock else _("Out of stock") }}
		</button>
	</div>
</section>
{% endblock %}
```

The product controller reads the product from the URL and the size from `?size=`. It sets `product_variant`, `selected_item`, `available_sizes`, `selected_price`, `default_price` and `product_in_stock`. A size change reloads the page, so the controller can find the price and stock of that size.

`$store.cart.add()` and `toast()` come from Shop Base Theme. `$store.cart.add()` parses the variant as JSON and throws an error on an empty value. The check in `add_to_cart()` stops that and shows a message.

:::caution[Keep the query string when you change the size]

Build the new URL from `window.location.href`, as `select_size()` does. A plain link such as `href="?size=M"` removes the other query parameters. In a preview, that removes `preview_theme`, and the page jumps back to the live theme.

:::

Single-quote the `x-data` attribute. The `tojson` filter writes double quotes, and they would end a double-quoted attribute early.

## Check it works

Open `/en?preview_theme=Acme` and `/en/products/<a product route>?preview_theme=Acme`. The pages have no styles yet. You add them in the next step. [Preview and ship](/commera/build-a-theme/preview-and-ship) explains the preview parameter.

Changes to `.html` files show when you reload the page. Changes to a `.py` controller also show on reload, because Commera loads the file again when it changes.

## Next steps

- [Step 4: Add styles](/commera/build-a-theme/add-styles)
- [Routes and pages](/commera/theme-reference/routes-and-pages): the full route table and dynamic pages
- [Templates](/commera/theme-reference/templates): the context variables and Jinja helpers
