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.
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:
ATLAS_BASE_URL=https://api.atlas.latellu.com
ATLAS_API_KEY=ak_live_your_key_here2. List Page
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
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{
"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{
"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{
"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{
"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:
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:
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.