---
title: Composable pages
status: current
order: 10
---

# Composable pages

With this, a new page on the site ("Tuition", "Our mission") gets built **by chat, without touching code**: your agent creates the page in Pluma with sections the site already knows how to render, publishes it, and it shows up at `/<slug>`.

Pluma has no built-in "pages", on purpose: the design belongs to your site. This is a **model pattern**: two content types and one component per section. Everything is custom: each site defines its own sections.

## When it helps and when it doesn't

| You want… | How |
| --- | --- |
| To change a text, a price, an event | Already works by chat, without this: you edit the entry |
| A new page with sections that already exist | **By chat**, with this pattern |
| A new kind of section ("Video testimonials") | Model by chat, plus **one component** on the site, once |
| To change a page that is written in the site's code | Code: Pluma doesn't build those pages |

## The model

**One type per section.** Each one with the fields it needs and its [agent guidance](content-model.md#agent-guidance): when to use it, when not to, and previews of how it looks. That's what lets the agent choose well.

| Type | Fields | Guidance (summary) |
| --- | --- | --- |
| `section_hero` | `eyebrow`, `title`, `text`, `image`, `buttonLabel`, `buttonUrl` | Once, at the very top |
| `section_text` | `title`, `body` (rich text) | Explanations longer than two sentences |
| `section_statement` | `eyebrow`, `statement`, `attribution` | A single big idea: mission, motto |
| `section_pricing` | `title`, `intro`, `items` (→ `price_item`), `note` | Tuition, fees, discounts |
| `section_cta` | `title`, `text`, `buttonLabel`, `buttonUrl` | Once, at the end: the next action |

**A `page` type** that puts them in order:

| Field | Type | What for |
| --- | --- | --- |
| `title` | `symbol` | Title for the browser tab and Google |
| `slug` | `slug` | The URL: `tuition` → `/tuition` |
| `seoDescription` | `symbol` | One sentence for Google |
| `sections` | `references` to the `section_*` types | The sections, in reading order |

**Name the field `sections`** (or `blocks`, `modules`, `components`). That's how Pluma knows `page` is a **page** and the types it accepts are its **sections**: see [How content is organized](content-model.md#how-content-is-organized). If your model uses another name, set `kind` on each type by [API](../api/management.md#edit-a-content-type), MCP, or on the Model page under **How it shows in Content**.

In the `page` type's guidance, tell the agent three things it can't guess:

- the slugs the code's pages already use (`about`, `apply`, `blog`…), so it doesn't reuse them;
- that a new page **doesn't get added to the menu on its own**: if the menu lives in Pluma, where it is;
- whether the layout already ends every page with a call to action, so it doesn't duplicate it.

## The site

Three pieces, and none of them change when a new page is built:

1. **One component per section**, with the same name as its type in Pluma, drawn with the components the site already has. That way the new page looks native.
2. **A dispatcher** that picks the component by type (`sys.content_type`) and skips, with a warning in the build, the types it doesn't know. It never guesses.
3. **A route** `/[slug]` that requests the `page` entries with `include=2` and `rich_text=html`: they come with their sections, the `price_item`s and the images in the same response. If a page in the code already uses the slug, the code's page wins.

In Astro:

```astro
---
// src/pages/[slug].astro
export async function getStaticPaths() {
  const taken = new Set(Object.keys(import.meta.glob('./*.astro')).map((f) => f.slice(2, -6)))
  const pages = await getPages() // GET /entries?content_type=page&include=2&rich_text=html
  return pages.filter((p) => !taken.has(p.slug)).map((page) => ({ params: { slug: page.slug }, props: { page } }))
}
const { page } = Astro.props
---
<Layout title={page.title} description={page.seoDescription}>
  {page.sections.map((section) => <Section section={section} />)}
</Layout>
```

On the test site it's three files: `src/pages/[slug].astro`, one component per section in `src/components/sections/`, and `getPages()` in `src/lib/pluma.mjs`. See also the [Astro recipe](../frameworks/astro.md).

## In the dashboard

People edit these pages without thinking about entries and references:

- **Content** lists the pages under **Pages**. The section types don't clutter the menu.
- **The page builder:** a page opens with its sections inside it, in order, as cards that open and close in place. Drag them to reorder, **Add section** to create a new one (only of the types the field accepts) or reuse one that exists, **✕** to take one off this page. Saving the page saves its sections; publishing it from the dashboard publishes the sections that aren't live yet.
- **Page sections** lists every section with **Used in** and the pages that show it. A section shared by several pages warns that a change shows on all of them.

See [Pages and their sections](entries.md#pages-and-their-sections).

## How the owner asks for it

> "Add a Tuition page with the prices for the 2026-2027 school year and, below, our mission. At the end, invite people to schedule a visit."

The agent reads the guidance with `describe_space`, creates the `price_item`s, the sections and the page in one [batch](../api/management.md#batch) (with `"$ref"` to link them without knowing the ids), publishes it, and the [deploy hook](deploy-netlify.md) rebuilds the site.

## What we tested

On the pilot site, an agent with no context, only the MCP, got the request above in one line. It read the site, built the full page (4 prices, hero, pricing, mission and closing) and tested it with `dry_run`. Then it published it in **a single batch of 18 operations**: 13 MCP calls in total. The site rendered it at `/tuition` with its own components, without touching code, and the 97 pages that were already there stayed identical.

What the agent **couldn't** do, as expected:

- **Edit a page in the code.** They also asked for the mission on About, which is code. The agent put it in a text block used by two pages and published it, telling them afterwards. We fixed it like this: the MCP now tells it to **ask before** publishing a change the owner didn't name or that shows up in more than one place, and the site's blocks have a "shown on" field listing their pages, which the type's guidance tells it to check.
- **Add the page to the menu.** Nobody asked, and the menu was a JSON field.
- **See the result.** No tool reports the published URL or whether the deploy finished.

## Previews that stay current

The previews in each type's [guidance](content-model.md#agent-guidance) come from the real site, and a script keeps them current: run it after each staging deploy and the agent always sees what a section looks like today.

1. Each section component marks its root with the content type it draws: `<section data-pluma-type="section_pricing">`.
2. The script opens the pages you give it, screenshots the first element of each type, and uploads it as the file **Preview: &lt;type&gt;**. On later runs it [replaces that same file](../api/management.md#edit-or-replace-a-file), so nothing piles up and nothing published is rebuilt. It also makes sure the type lists it in its previews.

```js
// scripts/pluma-previews.mjs — PLUMA_SPACE=… PLUMA_MANAGEMENT_KEY=… node scripts/pluma-previews.mjs <page URL> [more]
import { chromium } from "playwright"

const { PLUMA_SPACE, PLUMA_MANAGEMENT_KEY, PLUMA_API_URL = "https://pluma.so" } = process.env
const api = `${PLUMA_API_URL}/api/v1/spaces/${PLUMA_SPACE}`
const auth = { Authorization: `Bearer ${PLUMA_MANAGEMENT_KEY}` }
async function pluma(method, path, body) {
  const json = body && !(body instanceof FormData)
  const res = await fetch(api + path, { method, headers: { ...auth, ...(json ? { "Content-Type": "application/json" } : {}) }, body: json ? JSON.stringify(body) : body })
  const data = await res.json()
  if (!res.ok) throw new Error(`${method} ${path}: ${data.error.message} ${data.error.fix}`)
  return data
}

const shots = new Map()
const browser = await chromium.launch()
try {
  const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
  for (const url of process.argv.slice(2)) {
    await page.goto(url, { waitUntil: "networkidle" })
    for (const el of await page.$$("[data-pluma-type]")) {
      const type = await el.getAttribute("data-pluma-type")
      if (!shots.has(type)) shots.set(type, await el.screenshot())
    }
  }
} finally {
  await browser.close()
}

const { items: files } = await pluma("GET", "/assets?limit=1000")
for (const [type, png] of shots) {
  const title = `Preview: ${type}`
  const form = new FormData()
  form.append("file", new Blob([png], { type: "image/png" }), `preview-${type}.png`)
  form.append("title", title)
  form.append("alt", `How a ${type.replace(/_/g, " ")} looks on the site`)
  const existing = files.find((f) => f.fields.title === title)
  const file = existing ? await pluma("PATCH", `/assets/${existing.sys.id}`, form) : await pluma("POST", "/assets", form)
  const ids = ((await pluma("GET", `/content_types/${type}`)).previews ?? []).map((p) => p.id)
  if (!ids.includes(file.sys.id)) await pluma("PATCH", `/content_types/${type}`, { previews: [file.sys.id, ...ids].slice(0, 4) })
}
```

- It needs a **management key** (it writes files and the model) and Playwright (`npm i -D playwright`, then `npx playwright install chromium`).
- Point it at a page that uses every section type, on staging or on a local build. In the pilot it captured the 4 sections of `/tuition` and replaced the same 4 files on the second run.
- Run it wherever you run things after a deploy: a CI step, a Netlify post-deploy plugin, or by hand after changing a section's design.
