---
title: "Astro Starter"
url: "https://adaptocms.com/docs/astro-starter/"
source: adaptocms.com
---

# Astro Starter

This guide covers integrating every Adapto CMS content type into a fully static Astro project. Adapto data enters the project through Content Collection loaders that run at build time. 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 astro
```

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

Environment variables are read via `import.meta.env` and exported from `settings.ts` at the project root.

```
// settings.ts
export const API_URL = import.meta.env.ADAPTO_API_URL || '';
export const API_KEY = import.meta.env.ADAPTO_API_KEY || '';
export const DEFAULT_LANGUAGE = 'en';
export const ARTICLES_PER_PAGE = 10;
```

* * *

## Content Collections Architecture

All Adapto data is fetched at build time through custom loaders registered in `src/content.config.ts`. Loaders call the SDK and make data available via `getCollection()` throughout the project.

```
// src/content.config.ts
import { defineCollection } from 'astro:content';
import { articlesLoader } from './content/loaders/articles';
import { categoriesLoader } from './content/loaders/categories';
import { pagesLoader } from './content/loaders/pages';
import { customCollectionsLoader } from './content/loaders/customCollections';
import { customCollectionItemsLoader } from './content/loaders/customCollectionItems';
import { microCopyLoader } from './content/loaders/microCopies';
import { languagesLoader } from './content/loaders/languages';

export const collections = {
  articles: defineCollection({ loader: articlesLoader }),
  categories: defineCollection({ loader: categoriesLoader }),
  pages: defineCollection({ loader: pagesLoader }),
  customCollections: defineCollection({ loader: customCollectionsLoader }),
  customCollectionItems: defineCollection({ loader: customCollectionItemsLoader }),
  microCopies: defineCollection({ loader: microCopyLoader }),
  languages: defineCollection({ loader: languagesLoader }),
};
```

* * *

## Articles

### Articles loader

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/content/loaders/articles.ts
import { adapto } from '../../lib/adapto-sdk';

export async function articlesLoader() {
  const articles = await adapto.articles.listAll({ status: 'published' });
  return articles.map((article) => ({ ...article, id: article.id }));
}
```

### Article list and detail pages

The default language also gets an unprefixed URL (e.g. `/articles/slug` alongside `/en/articles/slug`) by passing `lang: undefined` in `getStaticPaths`.

```
// src/pages/[...lang]/articles/[slug].astro
---
import { getCollection } from 'astro:content';
import { DEFAULT_LANGUAGE } from '../../../../settings';

export async function getStaticPaths() {
  const articles = (await getCollection('articles')).map((a) => a.data);
  const languages = (await getCollection('languages')).map((l) => l.data);
  const defaultLang = languages.find((l) => l.is_default)?.short || DEFAULT_LANGUAGE;

  return articles.flatMap((article) => {
    const langCode = article.language.split('-')[0];
    const isDefault = langCode === defaultLang;
    const path = { params: { lang: langCode, slug: article.slug }, props: { article } };
    return isDefault
      ? [path, { params: { lang: undefined, slug: article.slug }, props: { article } }]
      : [path];
  });
}

const { article } = Astro.props;
---

<article>
  <h1>{article.title}</h1>
  <p>By {article.author}</p>
  <div set:html={article.content} />
</article>
```

### Paginated article list

```
// src/pages/[...lang]/articles/[...page].astro
---
import { getCollection } from 'astro:content';
import type { GetStaticPathsOptions } from 'astro';
import { DEFAULT_LANGUAGE, ARTICLES_PER_PAGE } from '../../../../settings';

export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  const articles = await getCollection('articles');
  const languages = (await getCollection('languages')).map((l) => l.data);
  const defaultLang = languages.find((l) => l.is_default)?.short || DEFAULT_LANGUAGE;

  return languages.flatMap((lang) => {
    const langCode = lang.short;
    const langArticles = articles.filter((a) => a.data.language.split('-')[0] === langCode);

    return paginate(langArticles, { pageSize: ARTICLES_PER_PAGE }).flatMap((res) => {
      const pageParam = res.props.page.currentPage === 1 ? undefined : String(res.props.page.currentPage);
      const path = { params: { lang: langCode, page: pageParam }, props: res.props };
      return langCode === defaultLang
        ? [path, { params: { lang: undefined, page: pageParam }, props: res.props }]
        : [path];
    });
  });
}

const { page } = Astro.props;
---

{page.data.map((entry) => (
  <a href={`/articles/${entry.data.slug}`}>{entry.data.title}</a>
  <p>{entry.data.summary}</p>
))}
<a href={page.url.prev}>Previous</a>
<a href={page.url.next}>Next</a>
```

* * *

## Categories

See the [SDK Reference](/docs/sdk-reference) for the full `ICategory` type, including `parent_id` and `description`.

### Categories loader

```
// src/content/loaders/categories.ts
import { adapto } from '../../lib/adapto-sdk';

export async function categoriesLoader() {
  const categories = await adapto.categories.listAll();
  return categories.map((c) => ({ ...c, id: c.id }));
}
```

### Category pages with filtered articles

Articles belong to a category when the category's `id` appears in the article's `categories` array. Filter in-memory after `getCollection()`, matching both language and category ID. The path generation follows the same prefixed/unprefixed pattern as Articles.

```
// src/pages/[...lang]/articles/category/[slug]/[...page].astro
---
import { getCollection } from 'astro:content';
import type { GetStaticPathsOptions } from 'astro';
import { DEFAULT_LANGUAGE, ARTICLES_PER_PAGE } from '../../../../../../settings';

export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  const allArticles = await getCollection('articles');
  const categories = (await getCollection('categories')).map((c) => c.data);
  const languages = (await getCollection('languages')).map((l) => l.data);
  const defaultLang = languages.find((l) => l.is_default)?.short || DEFAULT_LANGUAGE;

  return categories.flatMap((category) => {
    const langCode = category.language.split('-')[0];
    const filtered = allArticles.filter(
      (a) => a.data.categories.includes(category.id) &&
              a.data.language.split('-')[0] === langCode
    );

    return paginate(filtered, {
      pageSize: ARTICLES_PER_PAGE,
      params: { slug: category.slug },
      props: { category },
    }).flatMap((res) => {
      const pageParam = res.props.page.currentPage === 1 ? undefined : String(res.props.page.currentPage);
      const path = { params: { lang: langCode, slug: category.slug, page: pageParam }, props: res.props };
      return langCode === defaultLang
        ? [path, { params: { lang: undefined, slug: category.slug, page: pageParam }, props: res.props }]
        : [path];
    });
  });
}

const { page, category } = Astro.props;
---

<h1>{category.name}</h1>
{category.description && <div set:html={category.description} />}
{page.data.map((entry) => (
  <a href={`/articles/${entry.data.slug}`}>{entry.data.title}</a>
))}
```

* * *

## 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`.

### Pages loader

```
// src/content/loaders/pages.ts
import { adapto } from '../../lib/adapto-sdk';

export async function pagesLoader() {
  const pages = await adapto.pages.listAll({ status: 'published' });
  return pages.map((p) => ({ ...p, id: p.id }));
}
```

### CMS page routes

```
// src/pages/[...lang]/[slug].astro
---
import { getCollection } from 'astro:content';
import { DEFAULT_LANGUAGE } from '../../../settings';

export async function getStaticPaths() {
  const pages = (await getCollection('pages')).map((p) => p.data);
  const languages = (await getCollection('languages')).map((l) => l.data);
  const defaultLang = languages.find((l) => l.is_default)?.short || DEFAULT_LANGUAGE;

  return pages.flatMap((page) => {
    const langCode = page.language.split('-')[0];
    const path = { params: { lang: langCode, slug: page.slug }, props: { page } };
    return langCode === defaultLang
      ? [path, { params: { lang: undefined, slug: page.slug }, props: { page } }]
      : [path];
  });
}

const { page } = Astro.props;
---

<h1>{page.title}</h1>
<div set:html={page.content} />
```

* * *

## 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.

### Collections and items loaders

The items loader fetches all collections first, then fetches items for every collection in parallel. Each item gets a composite `id` (`collection-slug/item-slug`) to ensure uniqueness across collections.

```
// src/content/loaders/customCollections.ts
export async function customCollectionsLoader() {
  const collections = await adapto.collections.listAll();
  return collections.map((c) => ({ ...c, id: c.id }));
}

// src/content/loaders/customCollectionItems.ts
export async function customCollectionItemsLoader() {
  const collections = await adapto.collections.listAll();
  const nested = await Promise.all(
    collections.map(async (collection) => {
      const items = await adapto.collections.listAllItems(collection.id);
      return items.map((item) => ({
        ...item,
        id: `${collection.slug}/${item.slug}`,
        parentCollectionSlug: collection.slug,
      }));
    })
  );
  return nested.flat();
}
```

### Collection list with pagination

```
// src/pages/[...lang]/[collection_slug]/[...page].astro
---
import { getCollection } from 'astro:content';
import type { GetStaticPathsOptions } from 'astro';
import { DEFAULT_LANGUAGE, ARTICLES_PER_PAGE } from '../../../../settings';

export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  const allItems = await getCollection('customCollectionItems');
  const collections = (await getCollection('customCollections')).map((c) => c.data);
  const languages = (await getCollection('languages')).map((l) => l.data);
  const defaultLang = languages.find((l) => l.is_default)?.short || DEFAULT_LANGUAGE;

  return collections.flatMap((collection) =>
    languages.flatMap((lang) => {
      const langCode = lang.short;
      const items = allItems.filter(
        (item) => item.data.collection_id === collection.id &&
                  item.data.language.split('-')[0] === langCode
      );
      return paginate(items, {
        pageSize: ARTICLES_PER_PAGE,
        params: { collection_slug: collection.slug },
        props: { collection },
      }).flatMap((res) => {
        const pageParam = res.props.page.currentPage === 1 ? undefined : String(res.props.page.currentPage);
        const path = { params: { lang: langCode, collection_slug: collection.slug, page: pageParam }, props: res.props };
        return langCode === defaultLang
          ? [path, { params: { lang: undefined, collection_slug: collection.slug, page: pageParam }, props: res.props }]
          : [path];
      });
    })
  );
}

const { page, collection } = Astro.props;
---

<h1>{collection.name}</h1>
{page.data.map((entry) => (
  <a href={`/${collection.slug}/${entry.data.slug}`}>{entry.data.title}</a>
))}
```

### Collection item detail

```
// src/pages/[...lang]/[collection_slug]/[item_slug].astro
---
import { getCollection } from 'astro:content';
import { DEFAULT_LANGUAGE } from '../../../../settings';

export async function getStaticPaths() {
  const allItems = await getCollection('customCollectionItems');
  const allCollections = await getCollection('customCollections');
  const languages = (await getCollection('languages')).map((l) => l.data);
  const defaultLang = languages.find((l) => l.is_default)?.short || DEFAULT_LANGUAGE;

  return allItems.flatMap((entry) => {
    const item = entry.data;
    const langCode = item.language.split('-')[0];
    const collection = allCollections.find((c) => c.data.id === item.collection_id);
    if (!collection) return [];
    const path = {
      params: { lang: langCode, collection_slug: collection.data.slug, item_slug: item.slug },
      props: { item, collection: collection.data },
    };
    return langCode === defaultLang
      ? [path, { params: { lang: undefined, collection_slug: collection.data.slug, item_slug: item.slug }, props: { item, collection: collection.data } }]
      : [path];
  });
}

const { item, collection } = Astro.props;
const getFieldType = (name: string) => collection.fields.find((f) => f.name === name)?.type ?? 'text';
---

<h1>{item.title}</h1>

{Object.entries(item.data || {}).map(([key, value]) => {
  const type = getFieldType(key);
  return (
    <section>
      <h3>{key}</h3>
      {type === 'rich_text' && <div set:html={value} />}
      {type === 'image' && <img src={String(value)} alt={key} />}
      {(type === 'url' || type === 'email') && <a href={String(value)}>{String(value)}</a>}
      {type === 'boolean' && <span>{value ? 'Yes' : 'No'}</span>}
      {!['rich_text','image','url','email','boolean'].includes(type) && <p>{String(value)}</p>}
    </section>
  );
})}
```

* * *

## Micro Copy

Micro copy entries are short, language-scoped strings managed in the CMS. The loader maps each entry's `key` as its Astro ID for direct lookup via `getEntry('microCopies', 'your-key')`.

### Micro copy loader

```
// src/content/loaders/microCopies.ts
import { adapto } from '../../lib/adapto-sdk';

export async function microCopyLoader() {
  const entries = await adapto.microCopy.list();
  return entries.map((entry) => ({ ...entry, id: entry.key }));
}
```

### Microcopy component

```
// src/components/Microcopy.astro
---
import { getCollection } from 'astro:content';

type Props = { key: string; lang?: string };
const { key, lang } = Astro.props;

const entries = await getCollection('microCopies');
const match = entries.find(
  (e) => e.data.key === key && (!lang || e.data.language.split('-')[0] === lang)
);
---
{match?.data.value ?? 'Microcopy not found'}
```

```
<Microcopy key="nav.home" lang={currentLang} />
```

* * *

## Multi-language

Every content type has a `language` field storing the full locale code (e.g. `"en-US"`). URL segments use the short code (e.g. `"en"`). The `[...lang]` spread segment makes the default language's URLs prefix-free: `/articles/my-post` is generated alongside `/en/articles/my-post`.

### Languages loader

The loader enriches each language with a `short` code, a human-readable `label`, and an `is_default` flag. The first language returned by the API is treated as the default.

```
// src/content/loaders/languages.ts
import { adapto } from '../../lib/adapto-sdk';

export async function languagesLoader() {
  const codes = await adapto.languages.list();
  return codes.map((code, i) => {
    const short = code.split('-')[0];
    const label = new Intl.DisplayNames([short], { type: 'language' }).of(short) || short;
    return { id: code, code, short, label, is_default: i === 0 };
  });
}
```
