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
npx create-adapto-app --template next The installer will prompt you for the remaining details — including your Adapto API key — then scaffold the project, install dependencies, and set up your .env file. When it finishes, cd into your new project and 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 }));
}