Pluma
Docs index
docs/frameworks/astro.md View markdown →

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

// 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's next/image), allow Pluma's file domains, or the build stops with RemoteImageNotAllowed:

    // 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.

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.