Astro Starter
Using Adapto CMS with Astro
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.
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-astro-client.git
cd adapto-astro-client
npm install
cp .env.example .env Edit .env and set your keys, then 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 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 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 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 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 };
});
}