Next.js Starter
Using Adapto CMS with Next.js
This guide covers integrating every Adapto CMS content type into a Next.js 16 App Router project. For SDK method signatures and type definitions, see the SDK Reference.
Prerequisites
- An Adapto CMS account with an API key (found in the backoffice under Developer Tools → API Keys)
- Node.js 18+
Quick Start
git clone https://github.com/adaptocms/adapto-next-client.git
cd adapto-next-client
npm install
cp .env.example .env Edit .env and set your keys, then run npm run dev.
Configuration
All environment variables are centralised in src/config.ts.
// src/config.ts
export const API_URL = process.env.ADAPTO_API_URL ?? '';
export const API_KEY = process.env.ADAPTO_API_KEY ?? '';
export const IS_DEV = process.env.DEV === 'true';
export const DEFAULT_LANGUAGE = 'en-US';
export const PAGE_SIZE = 10; Articles
Listing articles
The summary field is auto-generated from the article's first paragraph — see the SDK Reference for the full IArticle type.
// src/app/[lang]/articles/page.tsx
import { adapto } from '@/lib/adapto-sdk';
import { PAGE_SIZE } from '@/config';
export default async function ArticlesPage({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const { items: articles, total, pages: totalPages } = await adapto.articles.list({
language: lang,
status: 'published',
page: 1,
limit: PAGE_SIZE,
});
return (
<main>
<p>{total} articles</p>
{articles.map((article) => (
<article key={article.id}>
<a href={`/${lang}/articles/${article.slug}`}>{article.title}</a>
{article.summary && <p>{article.summary}</p>}
{article.published_at && (
<time>{new Date(article.published_at).toLocaleDateString()}</time>
)}
</article>
))}
</main>
);
} Article detail
article.content is an HTML string. For articles with embedded media, pass it through hydrateMediaPlacements before rendering — see the SDK Reference.
// src/app/[lang]/articles/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { adapto } from '@/lib/adapto-sdk';
export async function generateStaticParams({
params: { lang },
}: {
params: { lang: string };
}) {
const articles = await adapto.articles.listAll({ language: lang, status: 'published' });
return articles.map((a) => ({ slug: a.slug }));
}
export default async function ArticlePage({
params,
}: {
params: Promise<{ lang: string; slug: string }>;
}) {
const { lang, slug } = await params;
const article = await adapto.articles.getBySlug(slug).catch(() => null);
if (!article) notFound();
return (
<article>
<h1>{article.title}</h1>
<p>By {article.author}</p>
<div dangerouslySetInnerHTML={{ __html: article.content }} />
</article>
);
} Pagination
Page 1 lives at /[lang]/articles. Pages 2+ live at /[lang]/articles/page/[pageNum].
// src/app/[lang]/articles/page/[pageNum]/page.tsx
import { notFound } from 'next/navigation';
import { adapto } from '@/lib/adapto-sdk';
import { PAGE_SIZE } from '@/config';
export async function generateStaticParams({
params: { lang },
}: {
params: { lang: string };
}) {
const { pages: totalPages } = await adapto.articles.list({
language: lang, status: 'published', page: 1, limit: PAGE_SIZE,
});
return Array.from({ length: Math.max(0, totalPages - 1) }, (_, i) => ({
pageNum: String(i + 2),
}));
}
export default async function ArticlesPageNum({
params,
}: {
params: Promise<{ lang: string; pageNum: string }>;
}) {
const { lang, pageNum } = await params;
const currentPage = parseInt(pageNum, 10);
const { items: articles, pages: totalPages } = await adapto.articles.list({
language: lang, status: 'published', page: currentPage, limit: PAGE_SIZE,
});
if (currentPage > totalPages) notFound();
return (
<main>
{articles.map((article) => (
<a key={article.id} href={`/${lang}/articles/${article.slug}`}>{article.title}</a>
))}
</main>
);
} Categories
See the SDK Reference for the full ICategory type, including parent_id and description.
Listing categories
const { items: categories } = await adapto.categories.list({ language: lang });
const topLevel = categories.filter((c) => c.parent_id === null); Category detail with filtered articles
Articles store category membership as an array of category IDs in their categories field. Pass the category's id to the category filter when listing articles.
// src/app/[lang]/articles/categories/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { adapto } from '@/lib/adapto-sdk';
import { PAGE_SIZE } from '@/config';
export async function generateStaticParams({
params: { lang },
}: {
params: { lang: string };
}) {
const categories = await adapto.categories.listAll({ language: lang });
return categories.map((c) => ({ slug: c.slug }));
}
export default async function CategoryPage({
params,
}: {
params: Promise<{ lang: string; slug: string }>;
}) {
const { lang, slug } = await params;
const category = await adapto.categories.getBySlug(slug).catch(() => null);
if (!category) notFound();
const { items: articles } = await adapto.articles.list({
language: lang,
status: 'published',
category: category.id,
page: 1,
limit: PAGE_SIZE,
});
return (
<main>
<h1>{category.name}</h1>
{category.description && (
<div dangerouslySetInnerHTML={{ __html: category.description }} />
)}
{articles.map((article) => (
<a key={article.id} href={`/${lang}/articles/${article.slug}`}>{article.title}</a>
))}
</main>
);
} Subcategories
const subcategories = await adapto.categories.getSubcategories(category.id); Pages
CMS-managed pages (Home, About, Contact, etc.) are fetched by slug. See the SDK Reference for the full IPage type including menu_label.
// src/app/[lang]/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { adapto } from '@/lib/adapto-sdk';
export async function generateStaticParams({
params: { lang },
}: {
params: { lang: string };
}) {
const pages = await adapto.pages.listAll({ language: lang, status: 'published' });
return pages.map((p) => ({ slug: p.slug }));
}
export default async function CmsPage({
params,
}: {
params: Promise<{ lang: string; slug: string }>;
}) {
const { lang, slug } = await params;
const page = await adapto.pages.getBySlug(slug).catch(() => null);
if (!page) notFound();
return (
<main>
<h1>{page.title}</h1>
<div dangerouslySetInnerHTML={{ __html: page.content }} />
</main>
);
} Custom Collections
A collection defines a schema via its fields array. Items store their values in a plain data object keyed by field name. See the SDK Reference for all ICollectionField types.
Collection list
// src/app/[lang]/[collection_slug]/page.tsx
import { notFound } from 'next/navigation';
import { adapto } from '@/lib/adapto-sdk';
import { PAGE_SIZE } from '@/config';
export default async function CollectionPage({
params,
}: {
params: Promise<{ lang: string; collection_slug: string }>;
}) {
const { lang, collection_slug } = await params;
const collection = await adapto.collections.getBySlug(collection_slug).catch(() => null);
if (!collection) notFound();
const { items } = await adapto.collections.listItems(
collection.id,
{ language: lang, status: 'published', page: 1, limit: PAGE_SIZE },
);
return (
<main>
<h1>{collection.name}</h1>
{items.map((item) => (
<a key={item.id} href={`/${lang}/${collection_slug}/${item.slug}`}>{item.title}</a>
))}
</main>
);
} Collection item detail
// src/app/[lang]/[collection_slug]/[item_slug]/page.tsx
import { notFound } from 'next/navigation';
import { adapto } from '@/lib/adapto-sdk';
export async function generateStaticParams({
params: { lang },
}: {
params: { lang: string };
}) {
const collections = await adapto.collections.listAll({ language: lang });
const paths = await Promise.all(
collections.map(async (collection) => {
const items = await adapto.collections.listAllItems(collection.id);
return items.map((item) => ({
collection_slug: collection.slug,
item_slug: item.slug,
}));
}),
);
return paths.flat();
}
export default async function CollectionItemPage({
params,
}: {
params: Promise<{ lang: string; collection_slug: string; item_slug: string }>;
}) {
const { lang, collection_slug, item_slug } = await params;
const collection = await adapto.collections.getBySlug(collection_slug).catch(() => null);
if (!collection) notFound();
const item = await adapto.collections.getItemBySlug(collection.id, item_slug).catch(() => null);
if (!item) notFound();
return (
<article>
<h1>{item.title}</h1>
{Object.entries(item.data).map(([fieldName, value]) => {
const field = collection.fields.find((f) => f.name === fieldName);
if (!field || value === null || value === '') return null;
switch (field.type) {
case 'rich_text':
return <div key={fieldName} dangerouslySetInnerHTML={{ __html: String(value) }} />;
case 'image':
return <img key={fieldName} src={String(value)} alt={field.label} />;
case 'url':
case 'email':
return <a key={fieldName} href={String(value)}>{String(value)}</a>;
case 'boolean':
return <span key={fieldName}>{value ? 'Yes' : 'No'}</span>;
case 'multi_select':
return <ul key={fieldName}>{(value as string[]).map((v) => <li key={v}>{v}</li>)}</ul>;
default:
return <p key={fieldName}>{String(value)}</p>;
}
})}
</article>
);
} Micro Copy
Micro copy entries are short, language-scoped strings managed in the CMS — button labels, navigation items, error messages. getDictionary loads all strings for a language at once as Record<string, string>.
Loading all strings for a language
const copy = await adapto.microCopy.getDictionary(lang);
// { 'nav.home': 'Home', 'btn.submit': 'Submit', ... }
return <button>{copy['btn.submit']}</button>; Fetching a single string
const entry = await adapto.microCopy.getByKey('nav.home');
return <span>{entry.value}</span>; Multi-language
Every content type has a language field storing the full locale code (e.g. "en-US"). The [lang] URL segment uses the same value. Pass it to every list or listAll call to scope results to a single locale.
The [lang] layout drives static generation for the entire route tree — every page under it inherits the language segment.
// src/app/[lang]/layout.tsx
import { adapto } from '@/lib/adapto-sdk';
export async function generateStaticParams() {
const languages = await adapto.languages.list();
return languages.map((lang) => ({ lang }));
}