---
title: "Next.js Starter"
url: "https://adaptocms.com/docs/nextjs-starter/"
source: adaptocms.com
---

# Next.js Starter

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](/docs/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](/docs/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](/docs/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](/docs/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](/docs/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](/docs/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 }));
}
```
