I wanted charts in Astro Markdown without turning the whole page into a React island, so I ended up wiring ApexCharts into a custom rehype flow. The result is pretty clean: you write a chart code block with JSON in Markdown, it renders as an interactive chart, and it follows the site’s dark mode.
This is the exact setup this site uses, so every chart in this post is live. Hover over this one to see what you’re building:
That one is from an ApexCharts example, and it’s defined entirely in this post’s markdown. A simpler one looks like this
```chart
{
"chart": {
"type": "line"
},
"series": [{
"name": "sales",
"data": [30,40,35,50,49,60,70,91,125]
}],
"xaxis": {
"categories": [1991,1992,1993,1994,1995,1996,1997, 1998,1999]
}
}
```
which renders out as
What You Need
- An existing Astro site that renders markdown, content collections or plain markdown pages both work
- Node and npm
- Around 15 minutes
The dark mode section assumes you track your theme with a data-theme attribute on the html tag (the DaisyUI way), but it should be easy to adapt to however you do it. If your site doesn’t have dark mode you can skip that section entirely and still get working charts.
Why ApexCharts?
This is what I wanted and why I chose ApexCharts
- Interactive charts, tooltips, zooming, and legend toggling without writing any javascript per chart
- Define the chart in Markdown, ApexCharts configs are plain JSON so they can live directly in a code block
- Support darkmode, there’s a built in dark theme so I don’t have to hand pick colors
I also didn’t want to ship a React island just to render a chart on an otherwise static page, and ApexCharts runs from a normal script tag. Chart.js would also work, but the theming is more manual. D3 gives you way more control, but it also turns each chart into a mini project. React-first libraries like Recharts were overkill for what I wanted here. The same general setup from this post would work with any of them; only the render step really changes.
The setup is three parts. A custom rehype plugin that turns chart code blocks into divs at build time, a small client side script that renders an ApexCharts chart into each div on page load, and a theme helper that applies dark or light mode and re-applies it when the theme toggles. This is the same code-block-to-component trick I used for mermaid UML diagrams if you want more of it.
Architecture Overview
| Step | What happens |
|---|---|
| Markdown code block | You write a chart code block with strict JSON in the post. |
| Rehype plugin | At build time, the plugin finds language-chart blocks, parses the JSON, and replaces the block with a div.chart-container. |
| Static HTML | The rendered page ships a div[data-chart] with the serialized chart config attached. |
| ApexCharts render | A small client-side script reads data-chart, instantiates ApexCharts, and renders the interactive chart in place. |
| Theme sync | A second helper updates the chart theme when the site theme changes. |
Initial Setup
Integration With Rehype
Our first step is to tell our markdown, rehype in this case, to support these kinds of codeblocks.
```chart
{
// our Apex Charts config
}
```
To get this done, we’ll use a custom Rehype plugin. At build time it walks the html tree, finds <pre><code class="language-chart"> blocks, and replaces each one with a single div that has the chart config stored in a data-chart attribute. It also generates a unique ID per chart which we’ll need later to update charts when the theme changes. Here’s what mine looks like, although my conditions might be different, since I’m accumulating a lot of these Rehype plugins.
import { visit } from "unist-util-visit";
import crypto from "crypto";
export function rehypeChart() {
return (tree) => {
visit(tree, "element", (node) => {
// The structure my markdown gets parsed as is <pre><code>...</code></pre>
// and because of styling I want to replace the <pre><code> with just one <div>
if (
node.tagName === "pre" &&
node.children.length === 1 &&
node.children[0].tagName === "code"
) {
const childNode = node.children[0];
// check if the child node is a code block with the language set to 'chart'
if (
childNode.tagName === "code" &&
childNode.properties.className &&
childNode.properties.className.length > 0 &&
childNode.properties.className[0] === "language-chart"
) {
// Check if the code block contains only text
if (
childNode.children.length !== 1 ||
childNode.children[0].type !== "text"
) {
throw new Error("Invalid chart code block");
}
// Parse the chart's data from the code block
var chartData = JSON.parse(childNode.children[0].value);
// Generate a unique ID for the chart, this will let us reference the chart later for dark mode
const uuid = crypto.randomBytes(16).toString("hex");
chartData.chart.id = uuid;
// Replace the node with a div element with the chart data attached
node.type = "element";
node.tagName = "div";
node.properties = {
id: uuid,
className: ["chart-container"],
"data-chart": JSON.stringify(chartData),
};
node.children = [];
}
}
});
};
}The reason we replace the <pre><code> pair with just one div is so your code block styling doesn’t bleed into the chart containers.
Then we can use this function in our astro config which should look like something like this
export default defineConfig({
markdown: {
rehypePlugins: [rehypeChart], // add to your existing plugins array
},
});Adding The Javascript
From that, we’ll just get a div without any interactive components to it (or a cool visualization). Next, we’ll add javascript so that the chart actually renders out.
First install ApexCharts to your project with
npm i apexcharts
Then, in your page where you render out the markdown in my case ArticleLayout.astro, add the following. It finds every chart container on the page, parses the config back out of the data attribute, and renders a chart into it.
---
<script>
import ApexCharts from "apexcharts";
// when the page loads
document.addEventListener("DOMContentLoaded", () => {
// look for each of our charts
document.querySelectorAll(".chart-container").forEach((chartDiv) => {
// extract our ApexCharts config and render it out
const chartDataAttr = chartDiv.getAttribute("data-chart");
if (chartDataAttr) {
const chartData = JSON.parse(chartDataAttr);
const chart = new ApexCharts(
chartDiv,
chartData
);
chart.render();
}
});
});
</script>With this you should be able to get your charts working and maybe try out some of the examples or just use the simple one from earlier with
```chart
{
"chart": {
"type": "line"
},
"series": [{
"name": "sales",
"data": [30,40,35,50,49,60,70,91,125]
}],
"xaxis": {
"categories": [1991,1992,1993,1994,1995,1996,1997, 1998,1999]
}
}
```
However the styling isn’t that great and I wanted to support dark-mode.
Supporting Darkmode
Luckily the charts have a dark theme. I found this nice function on a GitHub issues page that allows us to take our config and conditionally apply the dark mode or not.
function flipChartThemeMode(chartOptions: any, darkMode: boolean) {
const theme = chartOptions.theme;
const tooltip = chartOptions.tooltip;
const chart = chartOptions.chart;
chartOptions = {
...chartOptions,
chart: {
...chart,
background: "rgba(0, 0, 0, 0)", // also make the background transparent
fontFamily: "inherit", // inherit the font family
},
theme: {
...theme,
mode: darkMode ? "dark" : "light",
},
tooltip: {
...tooltip,
theme: darkMode ? "dark" : "light",
},
};
return chartOptions;
}Then I updated our function from earlier to this for the initialization of the page. It reads the current theme and passes every config through that helper before rendering.
<script>
import ApexCharts from "apexcharts";
import { flipChartThemeMode } from "../utils/theme-helpers"
document.addEventListener("DOMContentLoaded", () => {
// this is how I track my theme, with DaisyUI
const dataTheme = document.documentElement.getAttribute("data-theme");
document.querySelectorAll(".chart-container").forEach((chartDiv) => {
const chartDataAttr = chartDiv.getAttribute("data-chart");
if (chartDataAttr) {
const chartData = JSON.parse(chartDataAttr);
const chart = new ApexCharts(
chartDiv,
flipChartThemeMode(chartData, dataTheme === "dark")
);
chart.render();
}
});
});
</script>That just handles page load though, so if someone flips the theme toggle mid read the charts would stay in the old theme. To fix that I added this segment to my function that is triggered when the theme should switch. It looks each chart back up by the ID our rehype plugin generated and updates its options in place.
// inside of an existing function called toggleTheme()
// this will depend if you also have a toggle for your theme
document.querySelectorAll(".chart-container").forEach((chartDiv) => {
const chartDataAttr = chartDiv.getAttribute("data-chart");
if (chartDataAttr) {
const chartData = JSON.parse(chartDataAttr);
const chartID = chartDiv.firstElementChild?.id; // uses our ID from earlier
if (!chartID) return;
const chart = ApexCharts.getChartByID(chartID.replace("apexcharts", ""));
if (chart) {
chart.updateOptions(flipChartThemeMode(chartData, newTheme === "dark"));
} else {
console.error("chart not found");
}
}
});Using updateOptions re-renders the chart with the new theme without destroying and recreating it, so the swap is instant.
Finally I added the following global css. I’m using tailwind prose to format my markdown and it has some builtin colors/styles that I wanted to keep the charts the style in. So feel free to change to fit your design
.apexcharts-text {
fill: var(--tw-prose-headings) !important;
color: var(--tw-prose-headings) !important;
}
.apexcharts-tooltip {
color: var(--tw-prose-headings) !important;
}
.apexcharts-xaxistooltip {
color: var(--tw-prose-headings) !important;
background-color: transparent !important;
display: none; /* I wanted to hide the x-axis tooltip */
}
.apexcharts-legend-text {
color: var(--tw-prose-headings) !important;
}I’m not totally happy with the tooltip background colors at the moment but it fits well enough for now. If you have any better css for tailwind prose please shoot them my way and I’ll update the blog post!
Troubleshooting
Some things that got me, or might get you:
- The config has to be strict JSON since
JSON.parseruns at build time. A trailing comma, a comment, or unquoted keys will fail your build. The ApexCharts docs show javascript object literals not JSON, so if you copy one of their examples you’ll need to quote the keys. - Every config needs a
"chart"object since the rehype plugin setschartData.chart.id. Even an empty"chart": {}is enough. - If you’re using Astro’s view transitions,
DOMContentLoadedonly fires on the first full page load so charts won’t render after client side navigation. Listen forastro:page-loadinstead and the same init code works. - If the theme toggle logs “chart not found”, the toggle probably ran before the chart finished its initial render, or the
chart.idnever got set. - Apex charts measures its container when it renders, so a chart inside a collapsed accordion or hidden tab will render at zero width. Render it once the container is actually visible.
Once it’s all working some easy customizations: hide the toolbar with "chart": { "toolbar": { "show": false } } for a cleaner look in posts, set a consistent "height" across charts so your article layout doesn’t jump around, or extend flipChartThemeMode to pull your brand colors from css variables so every chart matches your palette automatically.
And that’s it, hopefully now you can use charts in Astro. If you’re building out an Astro blog I’ve also written about adding a search bar, optimizing your images, and pulling live stats into a static site.
If you found this helpful or want more Astro tutorial content, let me know.