# Product lists (`product_list`)

Show 2-24 products as a grid or list; the person picks one or several; the page records the picks and buys nothing. The result is { items: [{ productId, quantity }] }.

**When to use it:** The person picks one product, or several with quantities, from 2-24 search results or recommendations shown as a grid or list. The page only records the picks: add them to a cart or order them yourself afterwards, and do not tell the person it is done until you have.

**When not to use it:** Do not use it for one product (use `product`) or for more than 24 products: narrow the list first.

**Gather first:**

- Products that match what the person asked for, with real prices and photos.
- Whether they pick one (`mode: "single"`) or several to fill a cart (`"multiple"`).

**Writing good content:**

- Include a `photo` for every product: a grid without photos is hard to scan.
- Use `categories` and each product's `categoryId` when the list mixes kinds, so the person can filter.
- Use `compareAtPrice` only for a real original price, higher than `price`.
- The answer is the picked products and quantities. Act on them yourself afterwards.

## Fields

Every page also has `version` (always `1`), `title`, and optionally `description`, `branding`, `cover` (a header photo) and `expiresAt`; see [Page fields](/docs/api#page-fields). These are the fields of `page` (the page type and its data):

| Field | Description |
| --- | --- |
| `products` | *object[], required.* The products in display order (2-24). For one product use the product page type. Limits: 2-24 items. |
| `products[].id` | *string, required.* Your product id (or SKU, when each card is one size or color). Returned as productId. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `products[].name` | *string, required.* Product name, e.g. "Canvas market tote". Keep it short: cards are narrow. Limits: 1-120 characters. |
| `products[].price` | *object, required.* Price for one item. |
| `products[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `products[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `products[].photo` | *object.* Optional main photo, shown at 4:5. Strongly recommended: a grid without photos is hard to scan. |
| `products[].photo.url` | *https URL, required.* https URL of a product photo (JPEG, PNG or WebP), e.g. "https://shop.example/linen-shirt-front.jpg". |
| `products[].photo.alt` | *string.* Optional. What the photo shows, for screen readers, e.g. "Sage linen shirt, front view". Omit to use the product name. Limits: 1-200 characters. |
| `products[].brand` | *string.* Optional brand, shown above the name. Limits: 1-60 characters. |
| `products[].description` | *string.* Optional one-line detail, e.g. "Sage · Size M" or "Serves 4". Shown in list layout and under the name. Limits: 1-200 characters. |
| `products[].compareAtPrice` | *object.* Optional original price when on sale; must be higher than price. |
| `products[].compareAtPrice.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `products[].compareAtPrice.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `products[].rating` | *number.* Optional average review score from 0 to 5, e.g. 4.6. Include only real reviews. Limits: 0-5. |
| `products[].reviewCount` | *integer.* Optional number of reviews behind rating, e.g. 128. Only with rating. Limits: 0-10000000. |
| `products[].badge` | *string.* Optional short label on the photo, e.g. "New", "Bestseller". Limits: 1-20 characters. |
| `products[].available` | *boolean.* Optional. false shows the product as sold out and it cannot be picked. Omit when it can be bought. |
| `products[].categoryId` | *string.* Optional id of one of categories, for the filter chips. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `mode` | *"single" \| "multiple".* Optional. "single" (default): the person picks one product. "multiple": they pick several, e.g. to fill a cart. |
| `maxSelections` | *integer.* Optional, mode "multiple" only: the most products the person may pick. Limits: 2-24. |
| `maxQuantity` | *integer.* Optional, mode "multiple" only: show a quantity stepper on picked products, up to this many each. Omit for one of each. Limits: 2-99. |
| `layout` | *"grid" \| "list".* Optional. "grid" (default, 2 columns on phones) for photo-led products; "list" for rows with more text. |
| `categories` | *object[].* Optional filter chips (2-8) above the products, with an "All" chip first. Give each product a categoryId. Limits: 2-8 items. |
| `categories[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `categories[].label` | *string, required.* Chip text, e.g. "Tops". Limits: 1-24 characters. |
| `submitLabel` | *string.* Optional button text for the choice. Default "Choose". Not "Buy" or "Add to bag": the page buys nothing. Limits: 1-24 characters, matches `^[^<>]*$`. |

**Why these are required:** Each card needs an id, a name and a price; 2-24 products.

**When optional fields are left out:** Single choice in a 2-column grid; no filter chips; every product can be picked once; a grey box instead of a photo. `maxSelections` and `maxQuantity` need mode `multiple`. Tapping a card selects it; there is no detail sheet and no per-product options (send a `product` page for that, or list each size or color as its own card).

## Minimal example

Only the required fields:

```json
{
  "version": 1,
  "title": "Which one do you want?",
  "page": {
    "type": "product_list",
    "products": [
      {
        "id": "mug",
        "name": "Enamel camp mug",
        "price": {
          "amount": 18,
          "currency": "USD"
        }
      },
      {
        "id": "bottle",
        "name": "Steel water bottle",
        "price": {
          "amount": 29,
          "currency": "USD"
        }
      },
      {
        "id": "flask",
        "name": "Insulated coffee flask",
        "price": {
          "amount": 36,
          "currency": "USD"
        }
      }
    ]
  }
}
```

## Full example

```json
{
  "version": 1,
  "title": "Gift ideas under $100",
  "description": "Six picks for your sister. Tap the one you like.",
  "branding": {
    "name": "Juniper Market",
    "accentColor": "#2f5d50"
  },
  "page": {
    "type": "product_list",
    "products": [
      {
        "id": "linen-shirt-sage-m",
        "name": "Linen camp shirt",
        "description": "Sage · M",
        "price": {
          "amount": 68,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/linen-shirt-sage-1.jpg",
          "alt": "Sage linen camp shirt"
        },
        "rating": 4.7,
        "reviewCount": 212,
        "badge": "New",
        "categoryId": "tops"
      },
      {
        "id": "canvas-tote",
        "name": "Canvas market tote",
        "price": {
          "amount": 32,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/canvas-tote-1.jpg",
          "alt": "Natural canvas tote bag with leather handles"
        },
        "rating": 4.9,
        "reviewCount": 540,
        "badge": "Bestseller",
        "categoryId": "bags"
      },
      {
        "id": "wool-throw",
        "name": "Waffle wool throw",
        "price": {
          "amount": 96,
          "currency": "USD"
        },
        "compareAtPrice": {
          "amount": 128,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/wool-throw-1.jpg",
          "alt": "Oatmeal waffle-knit wool throw"
        },
        "categoryId": "home"
      },
      {
        "id": "stoneware-mug",
        "name": "Speckled stoneware mug",
        "price": {
          "amount": 24,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/stoneware-mug-1.jpg",
          "alt": "Speckled cream stoneware mug"
        },
        "rating": 4.6,
        "reviewCount": 88,
        "categoryId": "home"
      },
      {
        "id": "cord-cap",
        "name": "Corduroy cap",
        "price": {
          "amount": 38,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/cord-cap-1.jpg",
          "alt": "Rust corduroy six-panel cap"
        },
        "available": false,
        "categoryId": "bags"
      },
      {
        "id": "merino-tee",
        "name": "Merino crew tee",
        "description": "Charcoal · L",
        "price": {
          "amount": 54,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/merino-tee-1.jpg",
          "alt": "Charcoal merino crew-neck tee, folded"
        },
        "categoryId": "tops"
      }
    ],
    "categories": [
      {
        "id": "tops",
        "label": "Tops"
      },
      {
        "id": "bags",
        "label": "Bags & hats"
      },
      {
        "id": "home",
        "label": "Home"
      }
    ]
  }
}
```

## Result

When the person submits, the result has these fields:

| Field | Description |
| --- | --- |
| `items` | *object[], required.* The picked products, in display order. Exactly one in mode "single". Limits: 1-24 items. |
| `items[].productId` | *string, required.* id of a picked product. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `items[].quantity` | *integer, required.* How many of it. 1 unless the spec set maxQuantity. Limits: 1-99. |

For the full example above, a result could be:

```json
{
  "items": [
    {
      "productId": "canvas-tote",
      "quantity": 1
    }
  ]
}
```

## Send it

### eve, AI SDK and MCP

With [`@pagesystems/eve`](/docs#quickstart-eve), [`@pagesystems/ai-sdk`](/docs#quickstart-ai-sdk) or the [MCP server](/docs#quickstart-mcp), the model calls `get_page_type_schema` with `"product_list"` (the same JSON as [`GET /v1/page-types/product_list`](/docs/api#endpoints)), then `create_page` with this input (no `version` or `branding`; branding comes from your dashboard):

```json
{
  "title": "Gift ideas under $100",
  "description": "Six picks for your sister. Tap the one you like.",
  "page": {
    "type": "product_list",
    "products": [
      {
        "id": "linen-shirt-sage-m",
        "name": "Linen camp shirt",
        "description": "Sage · M",
        "price": {
          "amount": 68,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/linen-shirt-sage-1.jpg",
          "alt": "Sage linen camp shirt"
        },
        "rating": 4.7,
        "reviewCount": 212,
        "badge": "New",
        "categoryId": "tops"
      },
      {
        "id": "canvas-tote",
        "name": "Canvas market tote",
        "price": {
          "amount": 32,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/canvas-tote-1.jpg",
          "alt": "Natural canvas tote bag with leather handles"
        },
        "rating": 4.9,
        "reviewCount": 540,
        "badge": "Bestseller",
        "categoryId": "bags"
      },
      {
        "id": "wool-throw",
        "name": "Waffle wool throw",
        "price": {
          "amount": 96,
          "currency": "USD"
        },
        "compareAtPrice": {
          "amount": 128,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/wool-throw-1.jpg",
          "alt": "Oatmeal waffle-knit wool throw"
        },
        "categoryId": "home"
      },
      {
        "id": "stoneware-mug",
        "name": "Speckled stoneware mug",
        "price": {
          "amount": 24,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/stoneware-mug-1.jpg",
          "alt": "Speckled cream stoneware mug"
        },
        "rating": 4.6,
        "reviewCount": 88,
        "categoryId": "home"
      },
      {
        "id": "cord-cap",
        "name": "Corduroy cap",
        "price": {
          "amount": 38,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/cord-cap-1.jpg",
          "alt": "Rust corduroy six-panel cap"
        },
        "available": false,
        "categoryId": "bags"
      },
      {
        "id": "merino-tee",
        "name": "Merino crew tee",
        "description": "Charcoal · L",
        "price": {
          "amount": 54,
          "currency": "USD"
        },
        "photo": {
          "url": "https://images.example.test/shop/merino-tee-1.jpg",
          "alt": "Charcoal merino crew-neck tee, folded"
        },
        "categoryId": "tops"
      }
    ],
    "categories": [
      {
        "id": "tops",
        "label": "Tops"
      },
      {
        "id": "bags",
        "label": "Bags & hats"
      },
      {
        "id": "home",
        "label": "Home"
      }
    ]
  }
}
```

### REST

```bash
curl https://pagesystems.ai/v1/pages \
  -H "Authorization: Bearer $PAGE_SYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"spec":{"version":1,"title":"Gift ideas under $100","description":"Six picks for your sister. Tap the one you like.","branding":{"name":"Juniper Market","accentColor":"#2f5d50"},"page":{"type":"product_list","products":[{"id":"linen-shirt-sage-m","name":"Linen camp shirt","description":"Sage · M","price":{"amount":68,"currency":"USD"},"photo":{"url":"https://images.example.test/shop/linen-shirt-sage-1.jpg","alt":"Sage linen camp shirt"},"rating":4.7,"reviewCount":212,"badge":"New","categoryId":"tops"},{"id":"canvas-tote","name":"Canvas market tote","price":{"amount":32,"currency":"USD"},"photo":{"url":"https://images.example.test/shop/canvas-tote-1.jpg","alt":"Natural canvas tote bag with leather handles"},"rating":4.9,"reviewCount":540,"badge":"Bestseller","categoryId":"bags"},{"id":"wool-throw","name":"Waffle wool throw","price":{"amount":96,"currency":"USD"},"compareAtPrice":{"amount":128,"currency":"USD"},"photo":{"url":"https://images.example.test/shop/wool-throw-1.jpg","alt":"Oatmeal waffle-knit wool throw"},"categoryId":"home"},{"id":"stoneware-mug","name":"Speckled stoneware mug","price":{"amount":24,"currency":"USD"},"photo":{"url":"https://images.example.test/shop/stoneware-mug-1.jpg","alt":"Speckled cream stoneware mug"},"rating":4.6,"reviewCount":88,"categoryId":"home"},{"id":"cord-cap","name":"Corduroy cap","price":{"amount":38,"currency":"USD"},"photo":{"url":"https://images.example.test/shop/cord-cap-1.jpg","alt":"Rust corduroy six-panel cap"},"available":false,"categoryId":"bags"},{"id":"merino-tee","name":"Merino crew tee","description":"Charcoal · L","price":{"amount":54,"currency":"USD"},"photo":{"url":"https://images.example.test/shop/merino-tee-1.jpg","alt":"Charcoal merino crew-neck tee, folded"},"categoryId":"tops"}],"categories":[{"id":"tops","label":"Tops"},{"id":"bags","label":"Bags & hats"},{"id":"home","label":"Home"}]}}}'
```

## Validation errors

A spec that does not validate is refused with `400 invalid_spec`. The message names each problem and its path, so a model can fix the spec and try again. For example:

**Leaving out the required field `products`:**

```text
✖ Invalid input: expected array, received undefined
  → at page.products
```

**Misspelling a field name (unknown keys are rejected):**

```text
✖ Unrecognized key: "colour"
  → at page
```
