Feb 06
Adding D2 diagrams to an Astro site
by Ryan Welch · 5 Min Read
D2 ↗ is a declarative diagramming language that turns plain text into clean architecture diagrams, flowcharts, and sequence diagrams. Unlike Mermaid, it produces SVG output with a more polished look and supports features like multiple layout engines, custom themes, and sketch mode. The astro-d2 ↗ integration makes it easy to embed D2 diagrams directly in your markdown or MDX content by fencing them in a d2 code block, with no separate tooling or build step needed beyond the integration itself.
Install the integration and the D2 CLI:
# Install the Astro integrationpnpm add astro-d2
# Install the D2 CLI (macOS)brew install d2
# Or via the install script (Linux / CI)curl -fsSL https://d2lang.com/install.sh | sh -s --Import and add the integration in astro.config.mjs:
import { defineConfig } from 'astro/config';import mdx from '@astrojs/mdx';import d2 from 'astro-d2';
export default defineConfig({ integrations: [ d2({ theme: { default: '0', // D2 default theme dark: '200', // Dark theme ID }, inline: true, }), mdx(), ],});The d2 integration must come before mdx() in the integrations array. The theme option controls which D2 theme ↗ is used; pass separate default and dark IDs to generate light and dark variants. Setting inline: true embeds each diagram as an SVG directly in the page HTML rather than as a separate file, and is required for the class-based dark mode workaround covered below.
Add a fenced code block with the language set to d2 anywhere in a .md or .mdx file:
```d2 sketchdirection: right
browser: "Browser" { style.fill: "#e1f5ff" style.stroke: "#0077cc"}
astro: "Astro\n(build time)" { style.fill: "#ffe1e1" style.stroke: "#cc0000"}
nginx: "Nginx\n(runtime)" { style.fill: "#e1ffe1" style.stroke: "#00aa00"}
browser -> nginx: HTTP requestnginx -> browser: serves static filesastro -> nginx: dist/ output```Which renders as:
The integration runs d2 at build time and either embeds the SVG inline or writes it to public/d2/ (see below). No JavaScript is required to render diagrams on the client.
D2 supports dark mode by embedding a @media (prefers-color-scheme: dark) block inside the SVG’s <style> element. This works fine when the SVG is loaded as an external file (the default inline: false mode), but breaks when using inline: true: browsers do not evaluate prefers-color-scheme inside inline SVGs (astro-d2 issue #45 ↗).
If your site also uses a class-based dark mode toggle (e.g. a .dark class on <html>) rather than relying solely on the OS preference, that adds a second problem: even with external SVGs, the media query will ignore your toggle entirely.
The fix is a small remark plugin that runs after astro-d2 and rewrites the media query selector to a CSS class selector:
interface RemarkNode { type: string; value?: string; children?: RemarkNode[];}
export function remarkD2DarkMode() { return function (tree: RemarkNode) { walkTree(tree); };}
function walkTree(node: RemarkNode) { if (node.type === 'html' && typeof node.value === 'string') { node.value = node.value.replace( /@media screen and \(prefers-color-scheme:dark\)/g, ':root.dark', ); } if (node.children) { for (const child of node.children) walkTree(child); }}The plugin must run after astro-d2’s remark plugin, which means it can’t just be added to the markdown.remarkPlugins array directly, since Astro appends integration-provided plugins before user-provided ones. The fix is to wrap it in a small inline integration registered immediately after d2():
import { remarkD2DarkMode } from './plugins/remark-d2-dark-mode';
export default defineConfig({ integrations: [ d2({ theme: { default: '0', dark: '200' }, inline: true }), // Must be registered after d2() so its remark plugin runs after d2's { name: 'd2-dark-mode', hooks: { 'astro:config:setup': ({ updateConfig }) => { updateConfig({ markdown: { remarkPlugins: [remarkD2DarkMode] } }); }, }, }, mdx(), ],});With inline: true, SVGs are embedded directly in the page and no files are written to disk, so there is nothing to add to .gitignore.
If you use inline: false (the default file-based mode), the generated SVGs are build artefacts and should be excluded:
public/d2/Note
As mentioned, inline: false does not support the class-based dark mode workaround above so you would need to rely solely on prefers-color-scheme.
D2 supports multiple layout engines. The default (dagre) handles most cases well. For more complex graphs, elk often produces cleaner results:
```d2vars: { d2-config: { layout-engine: elk }}
# rest of diagram...```The D2 CLI needs to be available during the build stage. In a multi-stage Dockerfile, install it in the builder stage only; the nginx serving stage doesn’t need it.
# Stage 1: Build the Astro siteFROM node:24-alpine AS builderWORKDIR /appRUN apk add --no-cache git curl make
# Install the D2 CLIRUN curl -fsSL https://d2lang.com/install.sh | sh -s --
COPY package*.json pnpm-lock.yaml ./RUN npm install -g pnpm && pnpm install
COPY . .RUN pnpm run build
# Stage 2: Serve with NginxFROM nginx:alpineRUN rm -rf /usr/share/nginx/html/*COPY --from=builder /app/dist /usr/share/nginx/html
# Fix file permissions so nginx can read all filesRUN find /usr/share/nginx/html -type f -exec chmod 644 {} \; && \ find /usr/share/nginx/html -type d -exec chmod 755 {} \;
COPY nginx.conf /etc/nginx/conf.d/default.confEXPOSE 80CMD ["nginx", "-g", "daemon off;"]Note
If using inline: false then without the chmod step, D2’s generated SVG files will likely be served with a 403 Forbidden response from nginx. This happens because the D2 CLI creates its output files with restrictive permissions and the nginx user can’t read them, so nginx returns 403 instead of serving the file.
That’s all it takes to get clean, build-time SVG diagrams living alongside your content. Install astro-d2, drop a fenced d2 block anywhere in your markdown, and you’re done. If you want dark mode to follow your site’s theme toggle rather than the OS preference, the remark plugin trick above sorts that out in a few lines. Now go draw some diagrams.