Pages
Fetch published pages and their block tree from the Atlas CMS Public API.
Pages
Pages are structured documents built from reusable content blocks. Where an Entry holds a single set of fields (e.g. an article), a page holds a hierarchical block tree — hero, feature row, CTA, testimonials — each block rendered from its own Content Type schema.
Creating and managing pages
Pages and their blocks are managed through the Atlas dashboard, via the
Management API (@latellu/atlas-sdk), or via the
MCP server — create_page/update_page
accept SEO fields, seo_translations, and a full blocks tree (each block's
block_type_id comes from get_workspace_schema, filtered to is_block: true).
This guide covers reading published pages through the Public API.
Endpoints
| Method | Path | Description |
|---|---|---|
GET | /api/v1/public/pages | List published pages. |
GET | /api/v1/public/pages/{slug} | Fetch a single page with its full block tree. |
Both endpoints require an X-API-Key header. See Authentication.
List published pages
Returns a paginated list of pages. Each item includes the page slug, position, and SEO metadata. Blocks are not included in the list — fetch a single page to get the block tree.
curl "https://api.atlas.latellu.com/api/v1/public/pages" \
-H "X-API-Key: atlas_live_abc123xyz"{
"success": true,
"message": "Success",
"data": [
{
"id": "0190d1a1-0000-7000-8000-000000000001",
"slug": "home",
"status": "published",
"position": 0,
"seo": {
"title": "Atlas CMS — Headless Content Platform",
"description": "Manage and deliver content at scale.",
"keywords": ["headless", "cms", "api"],
"og_image": "https://cdn.atlas.latellu.com/pages/home/og.jpg",
"canonical": "https://acme.com/"
},
"published_at": "2026-04-01T08:00:00Z",
"created_at": "2026-03-15T12:00:00Z",
"updated_at": "2026-04-01T07:55:00Z"
},
{
"id": "0190d1a1-0000-7000-8000-000000000002",
"slug": "about-us",
"status": "published",
"position": 1,
"seo": {
"title": "About Us",
"description": "Learn about our team and mission.",
"keywords": ["about", "team"],
"og_image": "https://cdn.atlas.latellu.com/pages/about/og.jpg",
"canonical": "https://acme.com/about-us"
},
"published_at": "2026-04-05T10:00:00Z",
"created_at": "2026-03-20T09:00:00Z",
"updated_at": "2026-04-05T09:50:00Z"
}
],
"meta": {
"total": 5,
"page": 1,
"limit": 10,
"next_cursor": null
}
}position reflects the drag-and-drop order set in the dashboard. Pages are returned
sorted by position:asc by default — the natural navigation order.
Get a single page
Returns the full page including its block tree with nested children, SEO metadata, and per-locale translations.
curl "https://api.atlas.latellu.com/api/v1/public/pages/home" \
-H "X-API-Key: atlas_live_abc123xyz"{
"success": true,
"data": {
"page": {
"id": "0190d1a1-0000-7000-8000-000000000001",
"slug": "home",
"status": "published",
"position": 0,
"seo": {
"title": "Atlas CMS — Headless Content Platform",
"description": "Manage and deliver content at scale.",
"keywords": ["headless", "cms", "api"],
"og_image": "https://cdn.atlas.latellu.com/pages/home/og.jpg",
"canonical": "https://acme.com/"
},
"published_at": "2026-04-01T08:00:00Z",
"created_at": "2026-03-15T12:00:00Z",
"updated_at": "2026-04-01T07:55:00Z"
},
"blocks": [
{
"id": "0190d1a1-0000-7000-8000-000000000010",
"page_id": "0190d1a1-0000-7000-8000-000000000001",
"block_type_id": "0190d1a1-0000-7000-8000-000000000020",
"type": "hero",
"position": 0,
"data": "{\"heading\":\"Build faster with Atlas\",\"subtext\":\"The headless CMS built for developers.\",\"cta_label\":\"Get started\",\"cta_url\":\"/docs\"}",
"children": []
},
{
"id": "0190d1a1-0000-7000-8000-000000000011",
"page_id": "0190d1a1-0000-7000-8000-000000000001",
"block_type_id": "0190d1a1-0000-7000-8000-000000000021",
"type": "feature-grid",
"position": 1,
"data": "{\"title\":\"Why Atlas?\"}",
"children": [
{
"id": "0190d1a1-0000-7000-8000-000000000012",
"page_id": "0190d1a1-0000-7000-8000-000000000001",
"block_type_id": "0190d1a1-0000-7000-8000-000000000022",
"parent_id": "0190d1a1-0000-7000-8000-000000000011",
"type": "feature-item",
"position": 0,
"data": "{\"icon\":\"zap\",\"title\":\"Fast delivery\",\"body\":\"Content via CDN-backed REST API.\"}",
"children": []
}
]
}
],
"seo_translations": [
{
"locale": "id",
"seo": {
"title": "Atlas CMS — Platform Konten Headless",
"description": "Manage and deliver content at scale."
}
}
],
"block_translations": [
{
"block_id": "0190d1a1-0000-7000-8000-000000000010",
"locale": "id",
"data": "{\"heading\":\"Build faster with Atlas\",\"subtext\":\"The headless CMS built for developers.\"}"
}
]
}
}Understanding the response
The page object
| Field | Type | Description |
|---|---|---|
slug | string | URL-safe identifier. Use this as the route in your frontend. |
position | integer | Navigation order (0-indexed). |
seo | object | Default-locale SEO metadata (title, description, keywords, og_image, canonical). |
published_at | string | ISO 8601 timestamp of when the page was published. |
The blocks array
Blocks are returned as a nested tree: top-level blocks sit in the root array,
and their children appear in block.children. Process them recursively to render
the page layout.
| Field | Type | Description |
|---|---|---|
type | string | Block type slug — identifies which component to render (e.g. hero, feature-grid). |
position | integer | Order within the parent (or root if no parent). |
data | string | Block content as a JSON string — call JSON.parse(block.data) to read it. |
children | array | Nested child blocks, themselves following the same shape. |
data is a JSON string
block.data is returned as a serialized JSON string, not an object. You must
call JSON.parse(block.data) before accessing its fields. The keys inside match
the fields defined on the block's content type.
Localization
Per-locale content is returned in two separate arrays alongside the block tree:
| Array | Shape | Purpose |
|---|---|---|
seo_translations | { locale, seo }[] | Localized SEO metadata per locale. |
block_translations | { block_id, locale, data }[] | Localized field values per block per locale. |
Pass ?locale=id to tell the API which locale you want. The returned arrays will contain only translations for that locale, not all available locales.
The merge rule is: translation fields override default fields, field-by-field. If a block has no translation, its default data is used as-is (fallback).
See Localization for how fallback works and how locale codes are configured.
Multi-language example
A realistic page — home — with three locales: English (default), Indonesian (id), and French (fr). The page has three blocks: hero, feature-grid (with two child feature-item blocks), and testimonials. Not every block has a full translation: the testimonials block has no Indonesian translation and will fall back to English.
Request
curl "https://api.atlas.latellu.com/api/v1/public/pages/home?locale=id" \
-H "X-API-Key: atlas_live_abc123xyz"Response
{
"success": true,
"data": {
"page": {
"id": "0190d1a1-0000-7000-8000-000000000001",
"slug": "home",
"status": "published",
"position": 0,
"seo": {
"title": "Atlas CMS — Headless Content Platform",
"description": "Manage and deliver content at scale.",
"keywords": ["headless", "cms", "api"],
"og_image": "https://cdn.atlas.latellu.com/pages/home/og.jpg",
"canonical": "https://acme.com/"
},
"published_at": "2026-04-01T08:00:00Z",
"updated_at": "2026-04-01T07:55:00Z"
},
"blocks": [
{
"id": "block-hero-01",
"type": "hero",
"position": 0,
"data": "{\"heading\":\"Build faster with Atlas\",\"subtext\":\"The headless CMS built for developers.\",\"cta_label\":\"Get started\",\"cta_url\":\"/docs\",\"bg_image\":\"https://cdn.atlas.latellu.com/pages/home/hero-bg.webp\"}",
"children": []
},
{
"id": "block-features-01",
"type": "feature-grid",
"position": 1,
"data": "{\"title\":\"Why Atlas?\",\"columns\":3}",
"children": [
{
"id": "block-feat-item-01",
"type": "feature-item",
"parent_id": "block-features-01",
"position": 0,
"data": "{\"icon\":\"zap\",\"title\":\"Fast delivery\",\"body\":\"Content served via a CDN-backed REST API with sub-100ms p99 latency.\"}",
"children": []
},
{
"id": "block-feat-item-02",
"type": "feature-item",
"parent_id": "block-features-01",
"position": 1,
"data": "{\"icon\":\"globe\",\"title\":\"Multi-locale\",\"body\":\"Built-in localization: define locales, translate fields, and serve the right language per request.\"}",
"children": []
}
]
},
{
"id": "block-testimonials-01",
"type": "testimonials",
"position": 2,
"data": "{\"heading\":\"Loved by teams worldwide\",\"items\":[{\"quote\":\"Atlas cut our time-to-publish by half.\",\"author\":\"Sarah Kim\",\"role\":\"Head of Content, Acme Inc\"},{\"quote\":\"The API is clean and the docs are excellent.\",\"author\":\"Arya Santoso\",\"role\":\"Frontend Lead, Tokobaru\"}]}",
"children": []
}
],
"seo_translations": [
{
"locale": "id",
"seo": {
"title": "Atlas CMS — Headless Content Platform",
"description": "Manage and deliver content at scale.",
"keywords": ["headless", "cms", "api"],
"og_image": "https://cdn.atlas.latellu.com/pages/home/og.jpg",
"canonical": "https://acme.com/id"
}
}
],
"block_translations": [
{
"block_id": "block-hero-01",
"locale": "id",
"data": "{\"heading\":\"Build faster with Atlas\",\"subtext\":\"The headless CMS built for developers.\",\"cta_label\":\"Get started\"}"
},
{
"block_id": "block-features-01",
"locale": "id",
"data": "{\"title\":\"Why Atlas?\"}"
},
{
"block_id": "block-feat-item-01",
"locale": "id",
"data": "{\"title\":\"Fast delivery\",\"body\":\"Content served via a CDN-backed REST API with sub-100ms p99 latency.\"}"
},
{
"block_id": "block-feat-item-02",
"locale": "id",
"data": "{\"title\":\"Multi-language\",\"body\":\"Built-in localization: define locales, translate fields, and serve the right language per request.\"}"
}
]
}
}What to notice
testimonialsblock has no translation.block_translationscontains no entry forblock-testimonials-01. Your frontend should fall back to the default Englishblock.data.- Only localizable fields are translated. The
heroblock translatesheading,subtext, andcta_label— but notcta_urlorbg_image(those are non-localizable). Your merge must shallow-merge so non-translated fields are preserved from the default. seo_translationsis locale-keyed. Find theidentry and use itsseoobject as the page's<head>metadata. If no entry exists for your locale, fall back topage.seo.columns: 3onfeature-gridis not translated. It's layout configuration, not text — not marked localizable by the workspace admin.
Merge implementation
type Block = {
id: string
type: string
position: number
data: string // JSON string — must be parsed
children: Block[]
}
type BlockTranslation = {
block_id: string
locale: string
data: string // JSON string — must be parsed
}
type SeoTranslation = {
locale: string
seo: Record<string, unknown>
}
/**
* Resolves the fields for a single block in a given locale.
* Translated fields override defaults; untranslated fields fall back to default.
*/
export function resolveBlockFields(
block: Block,
translationMap: Map<string, Record<string, unknown>>
): Record<string, unknown> {
const defaults = JSON.parse(block.data) as Record<string, unknown>
const translation = translationMap.get(block.id) ?? {}
return { ...defaults, ...translation }
}
/**
* Builds a lookup map from block_translations for a single locale.
*/
export function buildTranslationMap(
blockTranslations: BlockTranslation[],
locale: string
): Map<string, Record<string, unknown>> {
const map = new Map<string, Record<string, unknown>>()
for (const t of blockTranslations) {
if (t.locale === locale) {
map.set(t.block_id, JSON.parse(t.data))
}
}
return map
}
/**
* Picks the SEO object for the given locale, falling back to the page default.
*/
export function resolveSeo(
defaultSeo: Record<string, unknown>,
seoTranslations: SeoTranslation[],
locale: string
): Record<string, unknown> {
const translation = seoTranslations.find((t) => t.locale === locale)
return translation ? { ...defaultSeo, ...translation.seo } : defaultSeo
}Usage in a Next.js page
The fetch runs in a Server Component — ATLAS_API_KEY never reaches the browser.
SEO metadata is exported via generateMetadata, which is how Next.js App Router
injects <title> and <meta> tags into <head>.
const BASE = 'https://api.atlas.latellu.com/api/v1/public'
export async function fetchPage(slug: string, locale: string) {
const res = await fetch(`${BASE}/pages/${slug}?locale=${locale}`, {
headers: { 'X-API-Key': process.env.ATLAS_API_KEY! },
next: { revalidate: 60 },
})
if (!res.ok) return null
const { data } = await res.json()
return data
}import type { Metadata } from 'next'
import { fetchPage } from '@/lib/atlas'
import { buildTranslationMap, resolveBlockFields, resolveSeo } from '@/lib/page'
type Props = { params: Promise<{ locale: string; slug: string }> }
// Next.js calls this to build <head> — runs server-side only
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params
const data = await fetchPage(slug, locale)
if (!data) return {}
const seo = resolveSeo(data.page.seo, data.seo_translations, locale)
return {
title: seo.title,
description: seo.description,
keywords: seo.keywords,
openGraph: { images: seo.og_image ? [seo.og_image] : [] },
alternates: { canonical: seo.canonical },
}
}
export default async function CmsPage({ params }: Props) {
const { locale, slug } = await params
const data = await fetchPage(slug, locale)
if (!data) return <p>Page not found.</p>
const { blocks, block_translations } = data
const translationMap = buildTranslationMap(block_translations, locale)
return (
<main>
{[...blocks]
.sort((a: Block, b: Block) => a.position - b.position)
.map((block: Block) => (
<BlockRenderer
key={block.id}
block={block}
fields={resolveBlockFields(block, translationMap)}
translationMap={translationMap}
/>
))}
</main>
)
}Why fetchPage is called twice
generateMetadata and the page component both call fetchPage. Next.js
deduplicates fetch calls with the same URL and cache options within a
single render pass, so no extra network request is made.
Next Steps
- Localization — how
?locale=works and the fallback behaviour. - Content Types — understand the field schema behind each block type.
- API Reference — full parameter list for
GET /public/pages.