Skip to main content
On this page
Updated 6 min read

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 }));
}