How to Add Interactive Charts to Astro with ApexCharts

Oct 29, 2024 Updated Jul 5, 2026

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

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

  1. Interactive charts, tooltips, zooming, and legend toggling without writing any javascript per chart
  2. Define the chart in Markdown, ApexCharts configs are plain JSON so they can live directly in a code block
  3. 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

StepWhat happens
Markdown code blockYou write a chart code block with strict JSON in the post.
Rehype pluginAt build time, the plugin finds language-chart blocks, parses the JSON, and replaces the block with a div.chart-container.
Static HTMLThe rendered page ships a div[data-chart] with the serialized chart config attached.
ApexCharts renderA small client-side script reads data-chart, instantiates ApexCharts, and renders the interactive chart in place.
Theme syncA 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

astro.config.mjs
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:

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.

Frequently asked questions

Can you use ApexCharts in Astro Markdown?

Yes. The setup in this post keeps the page as Markdown, uses a rehype plugin at build time to turn chart code blocks into divs, and then renders ApexCharts on the client without adding a React island.

Do Astro view transitions break ApexCharts?

They can if you only initialize charts on DOMContentLoaded. With Astro view transitions, client-side navigation needs the same chart setup to run on astro:page-load as well.

Can I use Chart.js instead of ApexCharts in Astro?

Yes. The same code-block-to-div approach works with Chart.js too. I used ApexCharts here because the JSON config fits cleanly in Markdown and the dark mode support is easier out of the box.

Related posts

← Back to blog More in Building with Astro · 7 posts →