# Astro

Source: https://capacms.com/docs/guides/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](https://capacms.com/docs/get-started/quickstart) shows how to make both.

## Add your key

Put the key in `.env` at the project root:

```bash
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:

```ts
// src/env.d.ts
interface ImportMetaEnv {
  readonly CAPA_API_URL: string;
  readonly CAPA_KEY: string;
}
```

## Write the helper

```ts
// 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

```astro
---
// 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](https://capacms.com/docs/guides/fetch).

## A page per article

```astro
---
// 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](https://capacms.com/docs/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:

```astro
---
// 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](https://capacms.com/docs/errors).

## Related

* [Entries reference](https://capacms.com/docs/api/entries) for `select`, filters, sorting and paging.
* [Images](https://capacms.com/docs/guides/images) for resizing on the CDN.
* [Keys](https://capacms.com/docs/concepts/keys) for production and draft keys.
