Astro
Build an Astro site from Capa entries with plain fetch, at build time or on every request.
Astro needs no SDK to read Capa. This guide uses fetch, a small helper, and Astro's own pages.
It assumes an article model with title, slug and body fields, and a production key. The Quickstart shows how to make both.
Add your key
Put the key in .env at the project root:
CAPA_API_URL=https://cdn.capacms.com
CAPA_KEY=cap_live_...Astro exposes variables without the PUBLIC_ prefix to server code only, so the key never reaches the browser. Declare them for TypeScript:
// src/env.d.ts
interface ImportMetaEnv {
readonly CAPA_API_URL: string;
readonly CAPA_KEY: string;
}Write the helper
// src/lib/capa.ts
export interface Entry<F> {
id: string;
model: string;
status: string;
fields: F;
}
interface ListPage<F> {
data: Entry<F>[];
page: { hasNext: boolean; next: string | null };
}
/** One GET against Capa's read API. Throws with the API's code and hint. */
export async function capa<T>(path: string, params: Record<string, string> = {}): Promise<T> {
const url = new URL(path, import.meta.env.CAPA_API_URL);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
const res = await fetch(url, {
headers: { "x-api-key": import.meta.env.CAPA_KEY, "Capa-Version": "2026-10-01" },
});
const body = await res.json();
if (!res.ok) {
const error = body?.error ?? {};
throw new Error(`${res.status} ${error.code}: ${error.message} ${error.hint ?? ""}`.trim());
}
return body as T;
}
/** Every entry of a model, following the cursor from page to page. */
export async function allEntries<F>(namespace: string, params: Record<string, string> = {}) {
const entries: Entry<F>[] = [];
let after: string | null = null;
do {
const query: Record<string, string> = after ? { ...params, after } : params;
const page: ListPage<F> = await capa<ListPage<F>>(`/api/entries/${namespace}`, query);
entries.push(...page.data);
after = page.page.next;
} while (after);
return entries;
}capa sends the two headers every read needs. allEntries follows page.next until the list ends, so a model of any size comes back whole. Pass limit: "200" to fetch it in as few requests as possible.
List the articles
---
// src/pages/index.astro
import { allEntries } from "../lib/capa";
type Article = { title: string; slug: string };
const articles = await allEntries<Article>("article", {
select: "title,slug",
sort: "-publishedAt",
});
---
<h1>Articles</h1>
<ul>
{articles.map((article) => (
<li><a href={`/articles/${article.fields.slug}`}>{article.fields.title}</a></li>
))}
</ul>select asks for only the fields the page shows. The response is the same JSON curl returns: see Any language with fetch.
A page per article
---
// src/pages/articles/[slug].astro
import { allEntries, type Entry } from "../../lib/capa";
type Article = { title: string; slug: string; body: string };
export async function getStaticPaths() {
const articles = await allEntries<Article>("article", { select: "title,slug,body" });
return articles.map((article) => ({
params: { slug: article.fields.slug },
props: { article },
}));
}
interface Props {
article: Entry<Article>;
}
const { article } = Astro.props;
---
<article>
<h1>{article.fields.title}</h1>
<div>{article.fields.body}</div>
</article>getStaticPaths reads every article once, at build time, and Astro writes one page for each.
A Rich Text field arrives as the string it was saved as. The admin's editor saves HTML, so render it with set:html. A value written through the API in Markdown stays Markdown.
When content changes
By default Astro builds static pages, so a published change appears on the next build. Pick one:
- Rebuild on a schedule. Most hosts can run a build every hour or every night.
- Rebuild on publish. Point a webhook at your host's deploy hook. See Webhooks.
- Render on request. Add a server adapter, then put
export const prerender = false;in a page's frontmatter. The page reads Capa on every request. Those reads are served from Capa's CDN, and a publish purges them, so the page is current within seconds.
Read one entry on request
On a server-rendered page, read the one entry you need instead of the whole model:
---
// src/pages/articles/[slug].astro, rendered on request
import { capa, type Entry } from "../../lib/capa";
export const prerender = false;
type Article = { title: string; body: string };
const { data } = await capa<{ data: Entry<Article>[] }>("/api/entries/article", {
select: "title,body",
"filter[slug]": Astro.params.slug ?? "",
limit: "1",
});
const article = data[0];
if (!article) return Astro.redirect("/404");
---
<article>
<h1>{article.fields.title}</h1>
<div>{article.fields.body}</div>
</article>filter[slug] matches the slug exactly. limit: "1" stops after the first match.
Errors
The helper throws with the API's code and hint, such as 404 model_not_found for a namespace the key cannot read. A failed read at build time fails the build, which is what you want: no page ships with missing content.
Every code has a page under Errors.
Related
- Entries reference for
select, filters, sorting and paging. - Images for resizing on the CDN.
- Keys for production and draft keys.