:::contentbit
How-to guides

Astro

Render validated documents with .astro components — renderer only, one validation path.

@contentbit/astro is a renderer, deliberately nothing more. There is no content-loader integration: Astro's own content collections load your Markdown, your Astro Markdown processor renders prose, contentbit validates structured block islands where they are used, and contentbit validate covers the same files in CI. One validation path, without replacing Astro's Markdown pipeline.

Setup

init detects Astro from your dependencies (or force it with -t astro):

npx contentbit@latest init

It scaffolds the full loop:

  • blocks/registry.ts — a custom quote block, shared with the validate CLI
  • blocks/QuoteBlock.astro — its .astro component
  • content/example.md — a starter document
  • src/content.config.ts — an articles collection over content/ using Astro's builtin glob loader
  • src/pages/example.astro — a rendered example page
  • the content:check script and the LLM-agent integration

Wiring it by hand instead:

pnpm add @contentbit/core @contentbit/blocks @contentbit/astro

Render a page

The pattern is the same as every other target — parse, validate, render — in component frontmatter:

---
import { genericBlocks } from '@contentbit/blocks'
import { assertValidDocument, compileDocument, createBlockRegistry } from '@contentbit/core'
import { getEntry } from 'astro:content'
import { ContentBlocks } from '@contentbit/astro/components'

const entry = await getEntry('articles', 'example')
if (!entry?.body) throw new Error('Entry not found.')

const registry = createBlockRegistry().use(genericBlocks())
const document = assertValidDocument(compileDocument(entry.body, registry), entry.id)
---

<ContentBlocks document={document} />

Static pages render at build time, so this is a real safety net: invalid blocks fail the build, not the reader.

ContentBlocks is intentionally headless. It walks the validated document, renders Markdown through renderMarkdown, and dispatches blocks to the components you pass. The pre-made block UI lives in the shadcn registry as editable .astro files, not inside the package.

Use Astro/Sätteri for prose

Astro 7 uses Sätteri for Markdown and MDX. Contentbit can stay complementary by delegating Markdown fragments back to that host renderer:

---
import { createSatteriMarkdownProcessor } from '@astrojs/markdown-satteri'
import { ContentBlocks } from '@contentbit/astro/components'

const processor = await createSatteriMarkdownProcessor({
  features: { directive: true, headingAttributes: true, math: true },
})

const renderMarkdown = async (markdown: string) => {
  const result = await processor.render(markdown)
  return result.code
}
---

<ContentBlocks document={document} renderMarkdown={renderMarkdown} />

The contentbit parser still finds and validates block directives, but normal Markdown syntax, heading ids, smart punctuation, math, and other enabled prose features stay owned by Astro.

Component overrides

Map block names to your own .astro components. Validated props arrive as component props, nested block content arrives via <slot />, and two reserved props are passed alongside them:

  • node — the full validated AST node, including parsed node.data
  • ctx — the render context, currently { renderMarkdown }
---
// RichNoteBlock.astro
import type { AstroBlockContext } from '@contentbit/astro'
import type { ValidatedBlockNode } from '@contentbit/core'

interface Props {
  node: ValidatedBlockNode<unknown>
  ctx: AstroBlockContext
  title?: string
}

const { node, ctx, title } = Astro.props
const data = node.data as { markdown: string }
const html = await ctx.renderMarkdown(data.markdown)
---

<aside>
  {title ? <h3>{title}</h3> : null}
  <div set:html={html} />
</aside>
<ContentBlocks document={document} components={{ 'rich-note': RichNoteBlock }} />

Because props were already validated against the block's schema, your component can use them without defensive parsing. Use <slot /> for nested content and ctx.renderMarkdown(...) for Markdown strings stored in node.data.

Options

  • renderMarkdown(md: string) => string | Promise<string> for prose and block bodies. It is also passed to block components as ctx.renderMarkdown, so copied shadcn components and your custom components share the same host-owned Markdown pipeline. The minimal fallback is exported as defaultRenderMarkdown from @contentbit/astro.
  • components — block-name to .astro component map. This is the only block UI path in the Astro runtime.
  • onInvalid — what to do with a block that failed validation: strict (throw), annotated (visible dev box), or fallback (escaped body as prose). The Astro default is annotated, which keeps invalid blocks loud during dev.

Validate in CI

init adds the gate for you:

{ "scripts": { "content:check": "contentbit validate" } }

The content glob and registry live once in contentbit.config.ts.

The CLI strips frontmatter before validating — the same thing Astro does when it hands you entry.body — so the CLI and your pages always judge identical content.

Styled components

A Tailwind-styled pack for Astro is distributed through the shadcn registry:

pnpm dlx shadcn@latest add @contentbit/astro-pack

Components land in your project as editable .astro source files. You own them after install.

On this page