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_SPACEandPLUMA_DELIVERY_KEYare set, the collections read from Pluma. If not, fromsrc/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
// 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
// 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'snext/image), allow Pluma's file domains, or the build stops withRemoteImageNotAllowed:// astro.config.mjs export default defineConfig({ image: { domains: ["pluma.so", "pluma.fly.dev"] } })pluma.sois 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 todist/). 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.
Gotchas the pilot found
- Tailwind scans the
.mdfiles. 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 tosafelistintailwind.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 atextfield and render it with your pipeline.