Atlas CMS
Use Cases

Article List

Build a blog-style article list and detail page in Next.js using the Atlas Public API.

Article List & Detail

This guide walks through a complete blog-style implementation: a page that lists published articles and a detail page that opens when the user clicks one. Both pages are Server Components — the API key stays on the server and is never exposed to the browser.

Content type slug

The examples below use article as the content type slug. Replace it with whatever slug you defined in the Atlas dashboard (e.g. post, news, blog-post).

What We're Building

app/
  articles/
    page.tsx          ← grid of article cards
    [slug]/
      page.tsx        ← full article detail
lib/
  atlas.ts            ← API helpers (API key lives here, server-only)

1. API Helpers

Keep both functions in one file so the API key is never imported by client components.

lib/atlas.ts
const BASE = process.env.ATLAS_BASE_URL + '/api/v1/public'

const headers = {
  'X-API-Key': process.env.ATLAS_API_KEY!,
}

export interface Article {
  slug: string
  status: string
  data: {
    title: string
    summary?: string
    cover_image?: { url: string; width: number; height: number; alt?: string }
    author?: { slug: string; data: { name: string } }
    published_at?: string
    body_html?: string
  }
}

export async function listArticles(page = 1): Promise<{
  data: Article[]
  meta: { total: number; page: number; limit: number; next_cursor?: string }
}> {
  const res = await fetch(
    `${BASE}/entries?type=article&page=${page}&limit=12`,
    { headers, next: { revalidate: 60 } },
  )
  if (!res.ok) return { data: [], meta: { total: 0, page: 1, limit: 12 } }
  return res.json()
}

export async function getArticle(slug: string): Promise<Article | null> {
  // Request body_html so richtext is pre-rendered to HTML by Atlas
  const res = await fetch(
    `${BASE}/entries/${slug}?type=article&fields=body_html`,
    { headers, next: { revalidate: 60 } },
  )
  if (!res.ok) return null
  const { data } = await res.json()
  return data
}

Add the required variables to .env.local:

.env.local
ATLAS_BASE_URL=https://api.atlas.latellu.com
ATLAS_API_KEY=ak_live_your_key_here

2. List Page

app/articles/page.tsx
import Link from 'next/link'
import Image from 'next/image'
import { listArticles } from '@/lib/atlas'

export const metadata = {
  title: 'Articles',
  description: 'Read the latest articles published via Atlas CMS.',
}

export default async function ArticlesPage() {
  const { data: articles, meta } = await listArticles()

  return (
    <main className="container py-12">
      <h1 className="text-3xl font-bold mb-8">Articles</h1>

      <p className="text-sm text-muted mb-6">{meta.total} articles</p>

      <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
        {articles.map((article) => (
          <li key={article.slug}>
            <Link href={`/articles/${article.slug}`} className="group block">
              {article.data.cover_image && (
                <Image
                  src={article.data.cover_image.url}
                  alt={article.data.cover_image.alt ?? article.data.title}
                  width={article.data.cover_image.width}
                  height={article.data.cover_image.height}
                  className="rounded-lg mb-4 w-full object-cover aspect-video"
                />
              )}
              <h2 className="text-lg font-semibold group-hover:underline">
                {article.data.title}
              </h2>
              {article.data.summary && (
                <p className="text-sm text-muted mt-1 line-clamp-2">
                  {article.data.summary}
                </p>
              )}
              {article.data.published_at && (
                <time
                  dateTime={article.data.published_at}
                  className="text-xs text-muted mt-2 block"
                >
                  {new Date(article.data.published_at).toLocaleDateString(
                    'en-US',
                    { year: 'numeric', month: 'long', day: 'numeric' },
                  )}
                </time>
              )}
            </Link>
          </li>
        ))}
      </ul>
    </main>
  )
}

3. Detail Page

app/articles/[slug]/page.tsx
import Image from 'next/image'
import { notFound } from 'next/navigation'
import { getArticle, listArticles } from '@/lib/atlas'
import type { Metadata } from 'next'

type Props = { params: Promise<{ slug: string }> }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const article = await getArticle(slug)
  if (!article) return {}
  return {
    title: article.data.title,
    description: article.data.summary,
    openGraph: article.data.cover_image
      ? { images: [article.data.cover_image.url] }
      : undefined,
  }
}

// Pre-render known slugs at build time (optional — remove for pure SSR)
export async function generateStaticParams() {
  const { data } = await listArticles()
  return data.map((a) => ({ slug: a.slug }))
}

export default async function ArticlePage({ params }: Props) {
  const { slug } = await params
  const article = await getArticle(slug)
  if (!article) notFound()

  const { title, summary, cover_image, author, published_at, body_html } =
    article.data

  return (
    <main className="container py-12 max-w-3xl">
      <h1 className="text-4xl font-bold mb-4">{title}</h1>

      <div className="flex items-center gap-4 text-sm text-muted mb-8">
        {author && <span>By {author.data.name}</span>}
        {published_at && (
          <time dateTime={published_at}>
            {new Date(published_at).toLocaleDateString('en-US', {
              year: 'numeric',
              month: 'long',
              day: 'numeric',
            })}
          </time>
        )}
      </div>

      {cover_image && (
        <Image
          src={cover_image.url}
          alt={cover_image.alt ?? title}
          width={cover_image.width}
          height={cover_image.height}
          priority
          className="rounded-xl w-full mb-10"
        />
      )}

      {summary && (
        <p className="text-xl text-muted mb-8 border-l-4 pl-4">{summary}</p>
      )}

      {body_html && (
        <div
          className="prose prose-neutral max-w-none"
          dangerouslySetInnerHTML={{ __html: body_html }}
        />
      )}
    </main>
  )
}

Why body_html?

Atlas stores richtext as Editor.js JSON internally. Adding body_html to ?fields= tells Atlas to convert it to HTML before returning — no client-side parser needed. You can also request body_text for plain text (useful for search excerpts).

Note: body_html and body_text only work when the richtext field is named body in the content type schema.


Richtext Variants

Atlas stores richtext as Editor.js JSON internally. By default the body field is returned as a JSON object. You can request a converted variant via ?fields=.

Field name requirement

The body_html and body_text conversions only apply to a richtext field named body. If your content type uses a different name (e.g. content, description), the default Editor.js JSON is returned as-is.

Default — Editor.js JSON

GET /entries/getting-started-with-headless-cms?type=article
Response — body (default, Editor.js JSON)
{
  "success": true,
  "data": {
    "slug": "getting-started-with-headless-cms",
    "data": {
      "title": "Getting Started with Headless CMS",
      "body": {
        "blocks": [
          {
            "type": "header",
            "data": { "text": "What is a Headless CMS?", "level": 2 }
          },
          {
            "type": "paragraph",
            "data": { "text": "A headless CMS separates the <b>content layer</b> from the presentation layer." }
          },
          {
            "type": "list",
            "data": {
              "style": "unordered",
              "items": ["Decoupled architecture", "API-first delivery", "Any frontend"]
            }
          }
        ]
      }
    }
  }
}

Use this if you want to render with full fidelity using an Editor.js renderer on your frontend.

body_html — Pre-rendered HTML

GET /entries/getting-started-with-headless-cms?type=article&fields=body_html
Response — body_html
{
  "success": true,
  "data": {
    "slug": "getting-started-with-headless-cms",
    "data": {
      "title": "Getting Started with Headless CMS",
      "body_html": "<h2>What is a Headless CMS?</h2>\n<p>A headless CMS separates the <b>content layer</b> from the presentation layer.</p>\n<ul><li>Decoupled architecture</li><li>API-first delivery</li><li>Any frontend</li></ul>"
    }
  }
}

Render directly with dangerouslySetInnerHTML={{ __html: body_html }}. Pair with Tailwind's prose class for typography.

body_text — Plain text

GET /entries/getting-started-with-headless-cms?type=article&fields=body_text
Response — body_text
{
  "success": true,
  "data": {
    "slug": "getting-started-with-headless-cms",
    "data": {
      "title": "Getting Started with Headless CMS",
      "body_text": "What is a Headless CMS? A headless CMS separates the content layer from the presentation layer. Decoupled architecture API-first delivery Any frontend"
    }
  }
}

Useful for search indexing, AI summarisation, or generating excerpts without stripping HTML tags manually.

Requesting multiple variants

You can combine body_html and body_text in one request — Atlas processes them in a single pass:

GET /entries/{slug}?type=article&fields=title,body_html,body_text
Response — title + body_html + body_text
{
  "success": true,
  "data": {
    "slug": "getting-started-with-headless-cms",
    "data": {
      "title": "Getting Started with Headless CMS",
      "body_html": "<h2>What is a Headless CMS?</h2>\n<p>A headless CMS separates the <b>content layer</b> from the presentation layer.</p>",
      "body_text": "What is a Headless CMS? A headless CMS separates the content layer from the presentation layer."
    }
  }
}

Localization

Pass ?locale= to get translated field values merged over the default locale:

lib/atlas.ts
export async function getArticle(slug: string, locale = 'en') {
  const res = await fetch(
    `${BASE}/entries/${slug}?type=article&fields=body_html&locale=${locale}`,
    { headers, next: { revalidate: 60 } },
  )
  // ...
}

See Localization for how locale merging works and how to fall back gracefully.


Pagination

listArticles accepts a page number. Pass it from the URL search params to build a paginated list:

app/articles/page.tsx
type Props = { searchParams: Promise<{ page?: string }> }

export default async function ArticlesPage({ searchParams }: Props) {
  const { page } = await searchParams
  const currentPage = Number(page ?? 1)
  const { data: articles, meta } = await listArticles(currentPage)

  // ... render articles ...

  return (
    <>
      {/* article grid */}

      <nav className="flex gap-4 mt-8">
        {currentPage > 1 && (
          <a href={`/articles?page=${currentPage - 1}`}>← Previous</a>
        )}
        {currentPage * meta.limit < meta.total && (
          <a href={`/articles?page=${currentPage + 1}`}>Next →</a>
        )}
      </nav>
    </>
  )
}

See Pagination & Filtering for cursor-based pagination and filtering options.


Next Steps

  • Localization — serve articles in multiple languages.
  • Pages — build full pages with nested blocks (hero, CTA, etc.).
  • Entry API Reference — full parameter reference for GET /public/entries.

On this page