---
title: Astro
status: current
phase: 8
order: 1
---

# Astro

How an Astro site reads its content from Pluma **without changing its pages**, with the repo's files as a fallback. It's the pattern from the pilot, with a real 97-page school site: the build from Pluma came out with HTML identical to the build from files.

The examples below are **simplified** so the idea is clear. On the tested site the full code lives in two files, `src/lib/pluma.mjs` and `src/content.config.ts`. Requires Astro 5 (content layer).

## The idea

- Everything that talks to Pluma lives in **one file** (`src/lib/pluma.mjs`).
- If `PLUMA_SPACE` and `PLUMA_DELIVERY_KEY` are set, the collections read from Pluma. If not, from `src/content/**` as always.
- If the variables are set and Pluma fails, **the build stops**. It doesn't silently fall back to the files: that would publish old content that looks healthy.

## 1. The client

```js
// src/lib/pluma.mjs
const env = process.env
export const PLUMA_ENABLED = Boolean(env.PLUMA_SPACE && env.PLUMA_DELIVERY_KEY)
const API = (env.PLUMA_URL || "https://pluma.so").replace(/\/$/, "")

async function get(pathAndQuery) {
  const res = await fetch(`${API}/api/v1/spaces/${env.PLUMA_SPACE}${pathAndQuery}`, {
    headers: { Authorization: `Bearer ${env.PLUMA_DELIVERY_KEY}` },
  })
  const body = await res.json().catch(() => null)
  if (!res.ok) {
    const e = body?.error
    throw new Error(`Pluma ${res.status} on ${pathAndQuery}: ${e?.code} — ${e?.message} ${e?.fix ?? ""}`)
  }
  return body
}

// All published entries of a type, with rich text as markdown and references resolved.
export async function getAll(contentType) {
  const items = [], assets = new Map()
  for (let skip = 0, limit = 1000; ; skip += limit) {
    const page = await get(`/entries?content_type=${contentType}&rich_text=markdown&include=1&limit=${limit}&skip=${skip}`)
    items.push(...page.items)
    for (const a of page.includes?.Asset ?? []) assets.set(a.sys.id, a)
    if (skip + limit >= page.total) break
  }
  return { items, assets }
}
```

## 2. One loader per collection

```ts
// src/content.config.ts
import { defineCollection, z } from "astro:content"
import { glob } from "astro/loaders"
import { PLUMA_ENABLED, getAll } from "./lib/pluma.mjs"

const plumaLoader = (contentType: string) => ({
  name: `pluma-${contentType}`,
  async load({ store, renderMarkdown }) {
    const { items } = await getAll(contentType)
    store.clear()
    for (const item of items) {
      const { slug, body, ...data } = item.fields
      store.set({ id: slug, data, body, rendered: await renderMarkdown(body ?? "") })
    }
  },
})

const blog = defineCollection({
  loader: PLUMA_ENABLED ? plumaLoader("blog_post") : glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({ title: z.string(), publishedAt: z.coerce.date(), /* … your usual schema … */ }),
})

export const collections = { blog }
```

With the same schema, the pages that use `getCollection("blog")` and `render()` don't change.

## 3. Images

The API returns `{ sys: { type: "Link", link_type: "Asset", id } }` in file fields and the URL in `includes.Asset`. Two options:

- **Use the Pluma URL** directly in `<img src>`. With Astro's `<Image>` (or Next's `next/image`), allow Pluma's file domains, or the build stops with `RemoteImageNotAllowed`:

  ```js
  // astro.config.mjs
  export default defineConfig({ image: { domains: ["pluma.so", "pluma.fly.dev"] } })
  ```

  `pluma.so` is where file URLs point now; `pluma.fly.dev` (the old one) still serves them, for URLs saved before October 1, 2026. In Next.js: `images.remotePatterns: [{ hostname: "pluma.so" }, { hostname: "pluma.fly.dev" }]`.
- **Download them before the build** to the usual paths (`public/images/…` or an ignored folder that gets copied to `dist/`). That way the HTML stays the same as with files. This is what the pilot did.

## 4. Variables on Netlify

`PLUMA_URL`, `PLUMA_SPACE` and `PLUMA_DELIVERY_KEY`. See [Deploy on Netlify](../guides/deploy-netlify.md).

## Gotchas the pilot found

- **Tailwind scans the `.md` files.** If you ever delete the files from the repo, classes that only appeared there (for example in a markdown table) disappear from the CSS. Before deleting them, add them to `safelist` in `tailwind.config.mjs`.
- **Empty text:** Pluma doesn't return empty fields. Set defaults (`?? ""`) when reading.
- **HTML inside markdown** is stored as text in `rich_text`. If your content uses it, store the markdown in a `text` field and render it with your pipeline.
