Atlas CMS

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 servercreate_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

MethodPathDescription
GET/api/v1/public/pagesList 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
curl "https://api.atlas.latellu.com/api/v1/public/pages" \
  -H "X-API-Key: atlas_live_abc123xyz"
Response — GET /public/pages
{
  "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
curl "https://api.atlas.latellu.com/api/v1/public/pages/home" \
  -H "X-API-Key: atlas_live_abc123xyz"
Response — GET /public/pages/home
{
  "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

FieldTypeDescription
slugstringURL-safe identifier. Use this as the route in your frontend.
positionintegerNavigation order (0-indexed).
seoobjectDefault-locale SEO metadata (title, description, keywords, og_image, canonical).
published_atstringISO 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.

FieldTypeDescription
typestringBlock type slug — identifies which component to render (e.g. hero, feature-grid).
positionintegerOrder within the parent (or root if no parent).
datastringBlock content as a JSON string — call JSON.parse(block.data) to read it.
childrenarrayNested 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:

ArrayShapePurpose
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
curl "https://api.atlas.latellu.com/api/v1/public/pages/home?locale=id" \
  -H "X-API-Key: atlas_live_abc123xyz"

Response

Response — GET /public/pages/home?locale=id
{
  "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

  • testimonials block has no translation. block_translations contains no entry for block-testimonials-01. Your frontend should fall back to the default English block.data.
  • Only localizable fields are translated. The hero block translates heading, subtext, and cta_label — but not cta_url or bg_image (those are non-localizable). Your merge must shallow-merge so non-translated fields are preserved from the default.
  • seo_translations is locale-keyed. Find the id entry and use its seo object as the page's <head> metadata. If no entry exists for your locale, fall back to page.seo.
  • columns: 3 on feature-grid is not translated. It's layout configuration, not text — not marked localizable by the workspace admin.

Merge implementation

lib/page.ts
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 ComponentATLAS_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>.

lib/atlas.ts
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
}
app/[locale]/[slug]/page.tsx
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.

On this page