ApexCharts with Nuxt 3

Using ApexCharts in Nuxt 3

ApexCharts renders SVG in the browser and depends on APIs that only exist there: window, document, and the SVG DOM. Nuxt 3 renders Vue components on the server by default. This combination means a naive import will fail on Node.js because those browser globals are absent.

The vue3-apexcharts wrapper guards its own rendering with onMounted, so the chart itself never tries to paint on the server. The problem is earlier: when Node.js executes the module import, the apexcharts package may reach for window at load time and throw a ReferenceError before your component code even runs.

This page shows three patterns for handling that, then covers reactive data, imperative chart access, and true server-side rendering for static/print use cases.

Installation

Install both packages. apexcharts is the core library; vue3-apexcharts is the Vue 3 component wrapper.

npm install vue3-apexcharts apexcharts

Pattern A: <ClientOnly> wrapper

The simplest approach. Nuxt's built-in <ClientOnly> component skips rendering its children on the server entirely. No import guard or plugin is needed.

<!-- components/SalesChart.vue -->
<template>
  <ClientOnly>
    <VueApexCharts
      type="bar"
      height="350"
      :series="series"
      :options="options"
    />
    <template #fallback>
      <!-- Rendered on the server and shown until the client hydrates -->
      <div style="height: 350px; display: flex; align-items: center; justify-content: center;">
        Loading chart...
      </div>
    </template>
  </ClientOnly>
</template>

<script setup>
import VueApexCharts from 'vue3-apexcharts'

const series = [{ name: 'Sales', data: [44, 55, 41, 67, 22] }]

const options = {
  chart: { type: 'bar' },
  xaxis: { categories: ['Jan', 'Feb', 'Mar', 'Apr', 'May'] }
}
</script>

The #fallback slot is important for layout stability. Without it, the space collapses to zero height on the server and jumps open when the chart mounts, causing cumulative layout shift. Give the fallback the same height as the chart.

Pattern B: import.meta.client guard

If you prefer to keep the component flat rather than wrapping it, check import.meta.client. Nuxt sets this boolean to true only in the browser bundle, so the v-if prevents any attempt to mount the chart on the server.

<!-- components/SalesChart.vue -->
<template>
  <VueApexCharts
    v-if="isClient"
    type="bar"
    height="350"
    :series="series"
    :options="options"
  />
</template>

<script setup>
import VueApexCharts from 'vue3-apexcharts'

// import.meta.client is tree-shaken to `false` in the server bundle
// and `true` in the client bundle
const isClient = import.meta.client

const series = [{ name: 'Sales', data: [44, 55, 41] }]
const options = { chart: { type: 'bar' }, xaxis: { categories: ['Jan', 'Feb', 'Mar'] } }
</script>

This pattern is useful when <ClientOnly> would add unwanted wrapper markup or when you are composing inside a component that already controls SSR boundaries.

Pattern C: Nuxt plugin for global registration

If you use charts in many pages, importing VueApexCharts in each component file is repetitive. A Nuxt plugin registered once makes the component available everywhere without per-file imports.

The critical detail is the .client.ts suffix. Nuxt reads this suffix and automatically loads the plugin only in the browser bundle. Without it, Nuxt would try to run the plugin on the server, the apexcharts module would reach for window, and the server would throw.

// plugins/apexcharts.client.ts
import VueApexCharts from 'vue3-apexcharts'

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.component('VueApexCharts', VueApexCharts)
})

With this plugin in place, <VueApexCharts> is available in any template without an import statement. You still need <ClientOnly> or v-if="isClient" around the component, because the plugin being client-only only affects registration timing, not rendering:

<!-- pages/dashboard.vue - no import needed after the plugin is registered -->
<template>
  <ClientOnly>
    <VueApexCharts type="line" height="350" :series="series" :options="options" />
  </ClientOnly>
</template>

<script setup>
const series = [{ name: 'Revenue', data: [30, 40, 35, 50, 49, 60] }]
const options = {
  chart: { type: 'line' },
  xaxis: { categories: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'] }
}
</script>

Fetching data with useFetch

useFetch runs on both server and client, so the fetched data is available during SSR and is already populated when the client hydrates. Pass the result to the chart through a computed so the chart receives an update if the data changes.

<!-- pages/sales.vue -->
<template>
  <ClientOnly>
    <VueApexCharts
      type="bar"
      height="350"
      :series="series"
      :options="options"
    />
    <template #fallback>
      <div style="height: 350px" />
    </template>
  </ClientOnly>
</template>

<script setup>
import VueApexCharts from 'vue3-apexcharts'

// useFetch is SSR-aware: data is prefetched on the server and
// passed to the client via the Nuxt payload, avoiding a second request
const { data: salesData } = await useFetch('/api/sales')

const series = computed(() => [
  { name: 'Sales', data: salesData.value ?? [] }
])

const options = {
  chart: { type: 'bar' },
  xaxis: { categories: ['Jan', 'Feb', 'Mar', 'Apr', 'May'] }
}
</script>

salesData is a Ref that starts populated on the client (from the server payload). Wrapping series in computed means ApexCharts re-renders automatically if salesData is later refreshed via refresh() or execute().

Updating chart data reactively

When series data changes in response to user interaction (a date range picker, a filter toggle), update the reactive value and the chart re-renders automatically.

<!-- pages/metrics.vue -->
<template>
  <div>
    <div style="margin-bottom: 1rem">
      <button @click="setRange('weekly')">Weekly</button>
      <button @click="setRange('monthly')">Monthly</button>
    </div>

    <ClientOnly>
      <VueApexCharts
        type="area"
        height="350"
        :series="series"
        :options="options"
      />
    </ClientOnly>
  </div>
</template>

<script setup>
import VueApexCharts from 'vue3-apexcharts'

const datasets = {
  weekly:  { data: [10, 14, 9, 17, 22, 19, 25], categories: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] },
  monthly: { data: [44, 55, 41, 67, 22, 78, 55, 63, 80, 70, 55, 90], categories: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] }
}

const activeRange = ref('weekly')

// Both series and options are computed so changing activeRange
// triggers a full re-render with the correct axis labels too
const series = computed(() => [
  { name: 'Visitors', data: datasets[activeRange.value].data }
])

const options = computed(() => ({
  chart: { type: 'area' },
  xaxis: { categories: datasets[activeRange.value].categories }
}))

function setRange(range) {
  activeRange.value = range
}
</script>

Making options a computed alongside series keeps the x-axis categories in sync with the data. If only series is reactive and options is a plain object, the axis labels will not update when the range changes.

Imperative access via ref

Some ApexCharts methods cannot be triggered through props: exporting to PNG, programmatically adding annotations, or calling toggleSeries. Access the underlying instance through a template ref.

<!-- components/ExportableChart.vue -->
<template>
  <div>
    <ClientOnly>
      <VueApexCharts
        ref="chartRef"
        type="line"
        height="350"
        :series="series"
        :options="options"
      />
    </ClientOnly>
    <button @click="exportChart">Download PNG</button>
  </div>
</template>

<script setup>
import VueApexCharts from 'vue3-apexcharts'

const chartRef = ref(null)

const series = [{ name: 'Conversions', data: [31, 40, 28, 51, 42, 62, 50] }]
const options = {
  chart: { type: 'line' },
  xaxis: { categories: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] }
}

async function exportChart() {
  // chartRef.value is the VueApexCharts component instance.
  // The .chart property on it is the underlying ApexCharts instance.
  const { imgURI } = await chartRef.value?.chart.dataURI()
  if (!imgURI) return

  const link = document.createElement('a')
  link.href = imgURI
  link.download = 'chart.png'
  link.click()
}
</script>

The optional chaining (?.) is defensive: on the server (or before <ClientOnly> hydrates), chartRef.value is null and the call is a no-op rather than a crash.

Server-side rendering for static charts

The patterns above keep ApexCharts entirely in the browser. For reports, print pages, or email-safe images where interactivity is not needed, you can render a chart to an SVG string on the server using apexcharts/ssr.

Create a Nuxt server API route:

// server/api/chart.ts
import ApexCharts from 'apexcharts/ssr'

export default defineEventHandler(async () => {
  const svg = await ApexCharts.renderToString(
    {
      chart: { type: 'bar' },
      series: [{ name: 'Sales', data: [44, 55, 41] }],
      xaxis: { categories: ['Jan', 'Feb', 'Mar'] }
    },
    { width: 600, height: 350 }
  )

  return svg
})

The apexcharts/ssr entry point is a Node.js-safe build that uses a lightweight DOM emulation instead of a real browser. It does not expose all chart types and does not support interactivity, but it produces a valid SVG string that can be inlined in HTML, passed to a PDF renderer, or embedded in an email template.

Consume it in a page:

<!-- pages/report.vue -->
<template>
  <!-- Render the SVG inline so it inherits page styles -->
  <!-- eslint-disable-next-line vue/no-v-html -->
  <div v-html="chartSvg" />
</template>

<script setup>
const { data: chartSvg } = await useFetch('/api/chart')
</script>

Because this approach fetches SVG from the server route, the client never loads the apexcharts bundle at all. That makes it appropriate for pages where the chart is static and bundle size matters.

Common mistakes

Missing .client suffix on the plugin

A plugin file named plugins/apexcharts.ts (no suffix) loads on both server and client. Nuxt will attempt to import vue3-apexcharts during SSR, Node.js will encounter window during module evaluation, and the server will crash with ReferenceError: window is not defined.

The fix is to rename the file to plugins/apexcharts.client.ts. The .client suffix is the only mechanism that keeps Nuxt from loading it on the server.

Accessing window in <script setup> at the top level

Code at the top level of <script setup> runs during SSR. This will throw on the server:

<!-- WRONG: do not access window at the top level of script setup -->
<script setup>
const width = window.innerWidth  // ReferenceError on the server
</script>

Move any browser API access inside onMounted:

<!-- Correct: onMounted only runs in the browser -->
<script setup>
const width = ref(0)

onMounted(() => {
  width.value = window.innerWidth
})
</script>

ApexCharts options are plain data objects, so they are fine at the top level. The restriction applies to browser globals like window, document, navigator, and localStorage.

Registering charts as a universal Nuxt module

Some guides suggest using nuxt.config.ts to register vue3-apexcharts as a global component via components. That will cause the same SSR crash as a universal plugin because Nuxt will attempt to import the module at build time for the server bundle. Use the .client.ts plugin pattern instead.