This guide outlines how we at Operate set up an Astro-powered documentation site for our design system, with automatic content sourcing from your component library package. The project is led by Joe Bell.
This setup automatically generates documentation pages from markdown files located within your design system package (e.g., @org/pipeline). It uses Astro's content collections with the glob loader to source .md and .mdx files directly from node_modules.
- Auto-sourced documentation: Pulls markdown files directly from your component library package
- React component support: Embed interactive React components in documentation
- Dark/light theme toggle: Persistent theme with system preference fallback
- Tailwind CSS v4: Modern styling with Vite plugin
- Shiki syntax highlighting: GitHub-style code blocks with theme support
- GitHub-flavored markdown: Alerts, GFM, and image handling
- Vercel deployment: Static output with edge caching
apps/design/
├── astro.config.ts # Astro configuration
├── package.json
├── tsconfig.json
├── vercel.json # Deployment headers/caching
├── public/
│ └── assets/ # Static assets (favicon, og image, etc.)
└── src/
├── assets/
│ └── fonts.ts # Font configuration (Astro experimental fonts)
├── components/
│ ├── link.astro
│ ├── nav-list.astro # Recursive navigation component
│ ├── theme-toggle.astro
│ └── react/ # React components for interactivity
├── config.ts # Site configuration
├── content.config.ts # Content collection definitions
├── layouts/
│ └── layout.astro # Main layout with sidebar navigation
├── pages/
│ ├── index.astro
│ └── pipeline/
│ ├── index.astro # Redirects to /pipeline/readme
│ └── [...slug].astro # Dynamic route for all docs
├── store/
│ └── theme.ts # Theme state management (nanostores)
└── styles/
├── globals.css
├── astro-code.css # Shiki dark mode overrides
└── rehype-github-alerts.css
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
import rehypeUnwrapImages from "rehype-unwrap-images";
import { rehypeGithubAlerts } from "rehype-github-alerts";
import react from "@astrojs/react";
import mdx from "@astrojs/mdx";
import vercel from "@astrojs/vercel";
import icon from "astro-icon";
import sitemap from "@astrojs/sitemap";
import { config } from "./src/config";
import * as fonts from "./src/assets/fonts";
export default defineConfig({
site: config.site,
output: "static",
experimental: {
fonts: [fonts.muoto.family], // Local font loading
},
prefetch: {
defaultStrategy: "hover",
prefetchAll: true,
},
markdown: {
gfm: true,
rehypePlugins: [rehypeGithubAlerts, rehypeUnwrapImages],
syntaxHighlight: "shiki",
shikiConfig: {
themes: {
light: "github-light",
dark: "github-dark",
},
},
},
integrations: [react({}), mdx(), icon(), sitemap()],
adapter: vercel(),
vite: {
plugins: [tailwindcss()],
ssr: {
noExternal: ["use-sound"], // Packages that need bundling
},
},
});This is where the automatic documentation sourcing happens:
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const pipeline = defineCollection({
// Source markdown files from your component library in node_modules
loader: glob({
pattern: "{README,src/**/*}.{md,mdx}",
base: "./node_modules/@org/pipeline",
}),
schema: z.object({
title: z.string().optional(),
status: z.enum(["ready", "undocumented", "unused"]).optional(),
}),
});
export const collections = { pipeline };This configuration:
- Loads the root
README.mdand any.md/.mdxfiles insrc/ - Allows frontmatter with optional
titleandstatusfields - Creates routes based on file paths (e.g.,
src/components/button.md→/pipeline/src/components/button)
---
import { getCollection, render } from "astro:content";
import Layout from "@/layouts/layout.astro";
export const getStaticPaths = async () => {
const pipeline = await getCollection("pipeline");
return pipeline.map((entry) => ({
params: { slug: entry.id },
props: { entry },
}));
};
const { entry } = Astro.props;
const { data } = entry;
const { Content } = await render(entry);
---
<Layout title={data.title}>
<Content />
</Layout>@import "tailwindcss";
@import "@org/pipeline/theme.css"; /* Import your design system theme */
@source "../../node_modules/@org/pipeline"; /* Scan for Tailwind classes */
@import "./astro-code.css";
@import "./rehype-github-alerts.css";.astro-code,
.astro-code span {
@apply dark:!bg-(--shiki-dark-bg)
dark:![font-weight:var(--shiki-dark-font-weight)]
dark:!text-(--shiki-dark)
dark:![font-style:var(--shiki-dark-font-style)]
dark:![text-decoration:var(--shiki-dark-text-decoration)];
}
.astro-code code {
font-size: inherit;
}import { persistentAtom } from "@nanostores/persistent";
export const THEME_VALUES = ["dark", "light"] as const;
export type ThemeValue = (typeof THEME_VALUES)[number];
export const THEME_FALLBACK: ThemeValue = "light";
export const THEME_KEY = "theme";
export const THEME = persistentAtom<ThemeValue>(THEME_KEY);The layout includes an inline script that:
- Checks localStorage for saved theme preference
- Falls back to system preference (
prefers-color-scheme) - Sets
data-theme,color-scheme, and class on<html> - Updates
<meta name="theme-color">for mobile browsers
The layout automatically generates navigation from the content collection:
const pipeline = await getCollection("pipeline");
// Sort by: alphabetical → directory depth → README first
const sortedItems = pipeline
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => a.id.split("/").length - b.id.split("/").length)
.sort((a, b) => {
const aIsReadme = a.id.split("/").pop()?.toLowerCase() === "readme";
const bIsReadme = b.id.split("/").pop()?.toLowerCase() === "readme";
if (aIsReadme && !bIsReadme) return -1;
if (bIsReadme && !aIsReadme) return 1;
return 0;
});This creates a nested tree structure matching your file system.
{
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/mdx": "^4.3.12",
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.6.0",
"@astrojs/vercel": "^9.0.2",
"@nanostores/persistent": "1.0.0",
"astro": "^5.16.5",
"astro-icon": "^1.1.5",
"react": "^19.x",
"react-dom": "^19.x"
},
"devDependencies": {
"@tailwindcss/vite": "^4.x",
"rehype-github-alerts": "^3.0.0",
"rehype-unwrap-images": "^1.0.0",
"tailwindcss": "^4.x"
}
}{
"extends": "astro/tsconfigs/strictest",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["public", "dist"],
"compilerOptions": {
"baseUrl": ".",
"jsx": "react-jsx",
"jsxImportSource": "react",
"paths": {
"@/*": ["src/*"]
},
"plugins": [{ "name": "@astrojs/ts-plugin" }]
}
}{
"$schema": "https://openapi.vercel.sh/vercel.json",
"headers": [
{
"source": "/.pipeline/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}| Command | Action |
|---|---|
pnpm dev |
Starts local dev server at localhost:4321 |
pnpm build |
Build production site to ./dist/ |
pnpm preview |
Preview build locally before deploying |
-
Structure your component library docs: Place
.mdor.mdxfiles alongside your components (e.g.,src/components/button/button.mdx) -
Use frontmatter for metadata:
--- title: Button status: ready ---
-
The glob pattern is flexible: Adjust
patternincontent.config.tsto match your file structure -
Hot reload works: Changes to markdown in
node_modulestrigger rebuilds during development -
Consider a monorepo: This setup works best when the design system package is in the same monorepo, allowing real-time documentation updates