> ## Documentation Index
> Fetch the complete documentation index at: https://cactal.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build pages with CMS data

> Use the generated @website SDK to define pages, bind route and query parameters into typed CMS queries, and render item or list results.

Every Cactal website gets a generated `@website` SDK based on its current CMS schema. Page code uses that SDK to define routes, declare server-side CMS queries, and render typed results.

## Page definition

A page directly default-exports `definePage({ ... })`:

```tsx pages/post theme={null}
import { definePage } from '@website'

export default definePage({
	route: '/blog/:slug',
	queries: ({ cms, param }) => ({
		post: cms.posts.get({
			slug: param.path('slug'),
			depth: 1
		})
	}),
	metadata: {
		title: '{{ post.fields.title }}'
	},
	render: ({ data }) => (
		<article>
			<h1>{data.post.fields.title}</h1>
		</article>
	)
})
```

| Property   | Required | Purpose                                            |
| ---------- | -------- | -------------------------------------------------- |
| `route`    | Yes      | Inline static or parameterized URL pattern         |
| `queries`  | No       | Object or function returning named CMS query plans |
| `metadata` | No       | Static or templated page metadata                  |
| `render`   | Yes      | Function returning the page's React content        |

The default export, `definePage` call, top-level object, and route literal must be direct and inline. Top-level object spreads, computed properties, indirect exports, variables used as routes, and route template literals fail static extraction.

See [Configure routes and redirects](/docs/guides/configure-routes-and-redirects) for the complete route grammar and matching behavior. See [Configure SEO metadata](/docs/guides/configure-seo-metadata) for metadata fields and templates.

## Generated CMS query builder

The `cms` object has one property per collection slug in the website's CMS schema. Each collection supports `get` and `list`:

```tsx theme={null}
queries: ({ cms }) => ({
	featuredPost: cms.posts.get({ slug: 'welcome' }),
	recentPosts: cms.posts.list({ limit: 10 })
})
```

Collection slugs, field keys, option choices, required fields, reference targets, and result types are inferred from the schema. A nonexistent collection, field, or option value is a type error and also fails the framework quality gate.

A page can declare at most 20 queries. Query names must be safe JavaScript identifiers with at most 64 characters, and cannot be `__proto__`, `constructor`, or `prototype`.

## Get one item

Use exactly one of `slug` or `id`:

```tsx theme={null}
queries: ({ cms, param }) => ({
	post: cms.posts.get({
		slug: param.path('slug'),
		depth: 1
	})
})
```

| Input   | Type                        | Behavior                                                 |
| ------- | --------------------------- | -------------------------------------------------------- |
| `slug`  | string or request parameter | Finds one published item by collection-local slug        |
| `id`    | string or request parameter | Finds one published item by stable item id               |
| `depth` | `0`, `1`, or `2`            | Hydrates references by that many levels; defaults to `0` |

If the parameter is absent or no published item matches, Cactal serves the site's custom `not_found` page with HTTP 404, exactly as it does for an unmatched route. Sites without a `not_found` file get the standard platform 404. Use a list query when an empty result is valid page content.

## List items

`list` returns published items in manual CMS order by default:

```tsx pages/blog theme={null}
import { definePage } from '@website'

export default definePage({
	route: '/blog',
	queries: ({ cms, param }) => ({
		posts: cms.posts.list({
			page: param.query('page'),
			limit: 12,
			search: param.query('q'),
			filter: {
				featured: { eq: true }
			},
			sort: {
				kind: 'field',
				field: 'published_date',
				direction: 'desc'
			}
		})
	}),
	render: ({ data }) => (
		<main>
			{data.posts.items.map(post => (
				<article key={post.id}>
					<h2>{post.fields.title}</h2>
				</article>
			))}
		</main>
	)
})
```

### List inputs

| Input         | Shape                                              | Behavior                                          |
| ------------- | -------------------------------------------------- | ------------------------------------------------- |
| `cursor`      | string or request parameter                        | Continues cursor pagination                       |
| `page`        | integer or numeric string, or request parameter    | Selects a 1-based offset page                     |
| `limit`       | integer `1` to `100`, or request parameter         | Items per page; defaults to `20`                  |
| `depth`       | `0`, `1`, or `2`, or request parameter             | Reference hydration depth                         |
| `filter`      | field-to-operator map                              | Filters using schema-aware field values           |
| `filterLogic` | `and` or `or`                                      | Combines field clauses; defaults to `and`         |
| `search`      | string, request parameter, or `{ query, fields? }` | Searches text-like fields                         |
| `sort`        | `{ kind: 'field', field, direction }`              | Sorts one supported field ascending or descending |

Do not use `cursor` and `page` together. The list result includes both `nextCursor` and `nextPage`; use the value matching your pagination style.

### Filters

```tsx theme={null}
filter: {
	category: { eq: param.query('category') },
	views: { gte: 100 },
	published_date: { lt: '2026-08-01T00:00:00.000Z' },
	title: { contains: param.query('q') }
}
```

| Field kind                                        | Supported operators                                                      |
| ------------------------------------------------- | ------------------------------------------------------------------------ |
| Text, markdown, link, email, color, single option | `eq`, `neq`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `exists` |
| Number and date                                   | `eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `exists`             |
| Boolean                                           | `eq`, `neq`, `in`, `nin`, `exists`                                       |
| Reference                                         | `eq`, `neq`, `in`, `nin`, `exists`, plus `slug` equality                 |
| Image, gallery, and file                          | `eq`, `neq`, `in`, `nin`, `exists`                                       |
| Multiple option                                   | `exists`                                                                 |

A filter can address at most 20 fields and contain at most 50 field/operator clauses. Unlike the REST item-list endpoint, the page query builder filters schema fields only, not item metadata such as `createdAt`, `publishedAt`, or `publishAt`.

Use `slug` to filter a reference from a dynamic route without first looking up
the referenced item id:

```tsx theme={null}
export default definePage({
	route: '/events/:city',
	queries: ({ cms, param }) => ({
		events: cms.events.list({
			filter: { city: { slug: param.path('city') } },
			limit: 100
		})
	}),
	render: ({ data }) => data.events.items.map(event => event.fields.name)
})
```

Reference slug traversal is limited to one level. It is applied by the CMS
before `limit`, `cursor`, or `page`, so pagination describes the filtered set.

### Search and sort

Search accepts a string or request parameter:

```tsx theme={null}
search: param.query('q')
```

Restrict search to selected text, markdown, link, email, or color fields with an object:

```tsx theme={null}
search: {
	query: param.query('q'),
	fields: ['title', 'summary']
}
```

Sort supports one schema field. Text, markdown, references, assets, galleries, files, and multiple-choice option fields are not sortable through page queries. Sort fields and directions must be static; they cannot be request-parameter placeholders.

## Bind request parameters

The `queries` function receives a placeholder builder named `param`:

```tsx theme={null}
queries: ({ cms, param }) => ({
	post: cms.posts.get({ slug: param.path('slug') }),
	results: cms.posts.list({
		page: param.query('page'),
		filter: {
			category: { in: param.query('category') },
			featured: { eq: param.query('featured') }
		}
	})
})
```

* `param.path(name)` is type-checked against `:name` segments in the page route.
* `param.query(name)` reads one query-string key.
* Values are coerced for their destination. Numeric pagination and number filters require numbers; boolean filters accept `true` or `false`.
* Repeated query-string values are accepted only where an `in` or `nin` filter expects multiple values.

Missing parameter behavior depends on its use:

| Location                                     | Missing value behavior                              |
| -------------------------------------------- | --------------------------------------------------- |
| `get` id or slug                             | The query reports not found and SSR fails           |
| List `cursor` or `page`                      | Omitted, so the list begins at its default position |
| List filter or search                        | Returns an empty list without calling the CMS       |
| Other list inputs such as `limit` or `depth` | SSR fails with a required-parameter diagnostic      |

Path and query parameters are source-specific. A missing path parameter never falls back to a query parameter with the same name.

## Render results

The `render` function receives:

| Property            | Type                                 | Meaning                                                 |
| ------------------- | ------------------------------------ | ------------------------------------------------------- |
| `data`              | object                               | One property per declared query, inferred from its plan |
| `path`              | string                               | Requested path                                          |
| `params`            | string map                           | Matched route parameters                                |
| `query`             | string or string-array map           | Parsed query parameters                                 |
| `param.path(name)`  | string                               | Type-checked route-parameter reader                     |
| `param.query(name)` | string, string array, or `undefined` | Query-parameter reader                                  |
| `metadata`          | object                               | Fully resolved metadata rendered for this request       |

### Item result

`get` returns:

```ts theme={null}
{
	id: string
	slug: string
	fields: Record<string, unknown>
	meta: {
		publishedAt: string | null
		publishAt: string | null
		createdAt: string
		updatedAt: string
	}
}
```

The actual `fields` type is generated from the collection. Optional values include `null`; gallery and multi-value fields use arrays. Image and file fields include their ready CDN URL and asset metadata.

At `depth: 0`, references are item ids. At greater depths, references become nested items with the same `{ id, slug, fields, meta }` shape. The page query maximum is `2`, even though direct CMS API reads support depth `3`.

### List result

`list` returns:

```ts theme={null}
{
	items: CMSItem[]
	nextCursor: string | null
	nextPage: number | null
}
```

## Published content and schema changes

Published sites and draft previews both query published CMS items. Draft, scheduled, unpublished, and deleted items do not appear in page results. A scheduled item begins appearing after Cactal promotes it and records `publishedAt`.

The published website guards its schema. A collection or field change that would break published page queries is rejected. Update page source, publish the compatible code, then change the schema. Collection-slug and field-key renames use dedicated CMS operations that migrate references safely.

## Sitemap discovery

A dynamic route can be enumerated in the generated sitemap when it has exactly one route parameter and exactly one `get` query whose `slug` or `id` is bound to that parameter:

```tsx theme={null}
route: '/blog/:slug',
queries: ({ cms, param }) => ({
	post: cms.posts.get({ slug: param.path('slug') })
})
```

See [Configure SEO metadata](/docs/guides/configure-seo-metadata#generated-crawler-files) for the complete crawler behavior.

## Troubleshooting

| Problem                                   | Cause                                                      | Fix                                                               |
| ----------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- |
| `cms.posts` does not exist                | The collection slug differs from the generated schema      | Read `GET /v1/cms/schema` and use the current slug                |
| A field is missing from `data`            | The field key changed or the build uses an older schema    | Re-check the schema and rebuild the draft                         |
| A reference is an id instead of an item   | The query uses `depth: 0`                                  | Set `depth: 1` or `2`                                             |
| SSR reports not found                     | A `get` query received no value or found no published item | Publish the item, correct the parameter, or use a list query      |
| A repeated query parameter fails          | Its destination expects one value                          | Use it in `in` or `nin`, or send a single value                   |
| A list is unexpectedly empty              | A filter or search request parameter is absent             | Supply the parameter or remove that conditional input             |
| A dynamic page is absent from the sitemap | Its route/query shape is not inferable                     | Use one path parameter and one matching `get` query by slug or id |

## Next steps

* [Manage CMS content](/docs/guides/manage-cms-content) to create collections, fields, and published items
* [Configure routes and redirects](/docs/guides/configure-routes-and-redirects) for route matching and canonical paths
* [Configure SEO metadata](/docs/guides/configure-seo-metadata) to resolve query data into titles and social tags
* [Style a website](/docs/guides/style-a-website) with global CSS and Tailwind CSS
