ApexCharts with Nuxt 3
Using ApexCharts in Nuxt 3
ApexCharts draws SVG in the browser, and Nuxt 3 renders every page on the server first. With ApexCharts 5.5.0 or newer, that needs no workaround. On Node, the apexcharts import resolves to the package's server build, and loading apexcharts or vue3-apexcharts there does not depend on window or document. Registering the wrapper with app.use(VueApexCharts) only adds three components. The <apexchart> component sends an empty <div class="vue-apexcharts"></div> from the server and draws the chart in onMounted, which Vue runs only in the browser.
So a Nuxt plugin without the .client suffix is the normal setup, and it is the one the vue3-apexcharts README shows. This page covers that plugin, charts rendered on the server, charts drawn only in the browser, reactive data, imperative chart access, and a static SVG from a server route.
Why older guides use .client
Up to ApexCharts 5.4.0, the package read window while it loaded. Any import on the server, including one in a plugin without the .client suffix, threw ReferenceError: window is not defined before a component ran, so the advice was to keep ApexCharts out of the server bundle. ApexCharts 5.5.0 added the server build and the node export condition that selects it, and its browser build stopped reading window at load.
vue3-apexcharts 1.10.0 and older still accepted ApexCharts 4.x, which is how projects kept meeting the crash. 1.11.0 and newer require ApexCharts 5.9.0 or newer (5.10.0 from 1.11.1), so a current wrapper with its peer dependency met does not crash on import. If you do see the error, run npm ls apexcharts and look for a copy at 5.4.0 or below.
Installation
Install both packages. apexcharts is the core library; vue3-apexcharts is the Vue 3 component wrapper.
npm install vue3-apexcharts apexcharts
Register the wrapper with a plugin
Register vue3-apexcharts once in a plugin and leave the .client suffix off. A plugin without a suffix runs on the server and in the browser. The server-rendering components need that: they have to be registered for the server render, and a chart type you import there has to reach both sides.
// plugins/apexcharts.ts
import VueApexCharts from 'vue3-apexcharts'
// A chart type outside the default bundle, imported once for server and browser
import 'apexcharts/sunburst'
export default defineNuxtPlugin({
name: 'apexcharts',
parallel: true,
setup(nuxtApp) {
// Registers <apexchart>, <apexchart-server> and <apexchart-hydrate>
nuxtApp.vueApp.use(VueApexCharts)
}
})
The apexcharts/sunburst line is an example; keep only the add-ons your charts use. Since ApexCharts 8.0 the default bundle leaves out the unit (with waffle and beeswarm), sunburst and violin chart types, and the drilldown, waterfall, dumbbell and streamgraph features. Vue Charts lists the import for each.
With the plugin in place, <apexchart> works in any template without an import, and it needs no <ClientOnly> around it:
<!-- pages/dashboard.vue: no import needed after the plugin is registered -->
<template>
<apexchart type="line" height="350" :series="series" :options="options" />
</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>
The server sends an empty <div class="vue-apexcharts"></div> for this chart, and the browser draws into it after hydration. That div has no height until then, so the content below it moves down when the chart appears. When that matters, hold the space with a <ClientOnly> fallback (below) or a wrapper with a fixed height.
A .client plugin
plugins/apexcharts.client.ts still works, and it keeps ApexCharts out of the server bundle. The cost is that the server does not know the components at all. Every <apexchart> then has to sit inside <ClientOnly>: without it, the server renders an empty comment where the chart goes, and the browser reports Hydration completed but contains mismatches. <apexchart-server> and <apexchart-hydrate> do not work with a client-only registration.
Server-rendered charts with <apexchart-server>
vue3-apexcharts 1.11.0 added two components for charts that arrive with the page. <apexchart-server> renders the chart during the server render through renderToHTML(), so the page gets the SVG and, when the chart shows a legend, the legend as HTML beside it. <apexchart-hydrate>, placed once per page, makes those charts interactive in the browser. When series names, labels or categories come from data you do not control, see Rendering Data You Do Not Control.
<!-- pages/report.vue -->
<template>
<apexchart-server
type="sunburst"
:width="400"
:height="400"
:series="series"
/>
<!-- Once per page: hydrates every server-rendered chart on it -->
<apexchart-hydrate />
</template>
<script setup>
const series = [{
data: [
{ x: 'Mobile', y: 55, children: [{ x: 'iOS', y: 30 }, { x: 'Android', y: 25 }] },
{ x: 'Desktop', y: 33 }
]
}]
</script>
Both components need the plugin above. The sunburst renders on the server because the plugin imports apexcharts/sunburst, and the plugin runs there too. Without that import, the server logs Failed to render ApexChart on server: with an error naming the import to add, and the page gets an empty container. The browser needs the import as well: <apexchart-hydrate> builds the chart again there, and with the import on the server only, the SVG arrives but hydration fails with the same "not registered" error and the chart stays static.
Use vue3-apexcharts 1.11.2 or newer for this. 1.11.0 and 1.11.1 ran these two components on a copy of ApexCharts bundled inside the wrapper (5.8.2 in 1.11.0, 5.10.0 in 1.11.1). That copy ignored the apexcharts version you installed and every chart type you imported: on 1.11.1 next to ApexCharts 8, a server-rendered sunburst stays empty with apexcharts/sunburst imported. 1.11.2 removes the copy and renders through apexcharts/ssr from your installed package. <apexchart> always used your installed version.
Charts drawn only in the browser
<ClientOnly> with a placeholder
Nuxt's built-in <ClientOnly> skips its children on the server and renders the #fallback slot in their place. Use it for a chart that should not be in the server HTML, or to hold the chart's space while the page hydrates. It works with the plugin, with a .client plugin, or with a local import as here.
<!-- 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.
A mounted flag
To leave the chart out of the server HTML without a wrapper component, render it behind a flag that turns on in onMounted. The server and the browser's hydration pass both see false, so the markup matches, and the chart renders right after.
<!-- components/SalesChart.vue -->
<template>
<VueApexCharts
v-if="mounted"
type="bar"
height="350"
:series="series"
:options="options"
/>
</template>
<script setup>
import VueApexCharts from 'vue3-apexcharts'
// false on the server and during hydration, true once the component is mounted
const mounted = ref(false)
onMounted(() => {
mounted.value = true
})
const series = [{ name: 'Sales', data: [44, 55, 41] }]
const options = { chart: { type: 'bar' }, xaxis: { categories: ['Jan', 'Feb', 'Mar'] } }
</script>
Do not gate the chart on import.meta.client instead (const isClient = import.meta.client with v-if="isClient"). It is false on the server and already true while the browser hydrates, so the two disagree and Vue reports Hydration completed but contains mismatches. The flag is useful when <ClientOnly> would add unwanted wrapper markup or when you are composing inside a component that already controls SSR boundaries.
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 all end with ApexCharts 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
})
On the server, the apexcharts/ssr entry point renders with a lightweight DOM emulation instead of a real browser. It carries the default bundle's chart types. A type outside it takes the same import on the server as in the browser, next to apexcharts/ssr: a sunburst route adds import 'apexcharts/sunburst'. See chart.type for the types that need one since 8.0. renderToString() returns a static SVG string with no interactivity, which can be inlined in HTML, passed to a PDF renderer, or embedded in an email template. It has no legend either: the legend is HTML beside the SVG, and only renderToHTML() emits it. The same entry also hydrates in the browser: <apexchart-server> and <apexchart-hydrate> above use it for both steps, and Server-Side Rendering shows renderToHTML() with a hand-written hydration step.
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
A chart type imported on one side only
An add-on import has to run on the server and in the browser. When import 'apexcharts/sunburst' (or any other add-on) runs only in the browser, in a .client plugin, <apexchart> still draws, because it only ever draws in the browser, but <apexchart-server> logs Failed to render ApexChart on server: and sends an empty container. When it runs only on the server, in a .server plugin, the server-rendered SVG arrives and <apexchart-hydrate> then fails in the browser, leaving the chart static. Put add-on imports in the plugin without a suffix.
ReferenceError: window is not defined on the server
With ApexCharts 5.5.0 or newer, the ApexCharts import is not the cause. Run npm ls apexcharts to rule out an older copy in the tree, then look for your own code reading a browser global while the server renders, which the next section covers.
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.