Docs
Developer guides

Astro

Build an Astro site from Capa entries with plain fetch, at build time or on every request.

View as Markdown

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.