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. 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": "Kelola dan distribusikan konten dalam skala besar."
}
}
],
"block_translations": [
{
"block_id": "0190d1a1-0000-7000-8000-000000000010",
"locale": "id",
"data": "{\"heading\":\"Bangun lebih cepat dengan Atlas\",\"subtext\":\"CMS headless yang dibuat untuk developer.\"}"
}
]
}
}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 — Platform Konten Headless",
"description": "Kelola dan distribusikan konten dalam skala besar.",
"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\":\"Bangun lebih cepat dengan Atlas\",\"subtext\":\"CMS headless yang dibuat untuk developer.\",\"cta_label\":\"Mulai sekarang\"}"
},
{
"block_id": "block-features-01",
"locale": "id",
"data": "{\"title\":\"Mengapa Atlas?\"}"
},
{
"block_id": "block-feat-item-01",
"locale": "id",
"data": "{\"title\":\"Pengiriman cepat\",\"body\":\"Konten disajikan via REST API dengan latensi p99 di bawah 100ms.\"}"
},
{
"block_id": "block-feat-item-02",
"locale": "id",
"data": "{\"title\":\"Multi-bahasa\",\"body\":\"Lokalisasi bawaan: tentukan locale, terjemahkan field, dan sajikan bahasa yang tepat 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.