Built with ApexCharts.js, ApexGrid, ApexMaps
A chart, a grid and a map on one App Router page need three different mounts, and only one of the three can render on the server at all. The one that will break is the grid: it is a Lit custom element, and the import that registers it is exactly the kind of import a bundler is allowed to delete. When it does, two panels render perfectly, the third is absent, and nothing is logged.
This page is only about the composite. Next.js
integration owns ApexCharts in the App Router and
writes out the client island, the ssr: false dynamic import and the
server-rendered SVG; this page assumes you have read that one and does not
repeat any of it.
npm install apexcharts apex-grid apexmaps
What does each product need in the App Router?
Three products, three answers. Every cell below was checked against the installed version by importing the package in Node 22.23.1 and reading what came back.
| ApexCharts 7.8.0 | ApexGrid 3.5.0 | ApexMaps 1.0.0 | |
|---|---|---|---|
| Server render | Yes, renderToHTML() | No | No |
| Registration step | none, it is a constructor | setup() or ApexGrid.register(), called by you | none, it is a constructor |
| How data arrives | constructor argument | property assignment only | constructor argument |
| Import on the server (Node 22) | resolves to the SSR build | registers into a shim | evaluates, cannot render |
The three rows underneath that table are the whole page.
ApexCharts resolves to a different file on the server, and the bare specifier
is enough. Its exports map declares a node condition and no react-server
key, and Next 15.5.27 builds the RSC layer with the condition list
['react-server', '...'], so a package without that key falls through to
node. Importing apexcharts at 7.8.0 in Node resolves to
dist/apexcharts.ssr.esm.js, which carries renderToHTML, renderToString,
hydrate, hydrateAll and isHydrated. Rendering a five-point bar at 640x240
with renderToHTML returned a 17,634 character string holding one <svg> and
zero <script> tags, wrapped in a div marked data-apexcharts-hydrate with
the config base64-encoded into data-apexcharts-config. The apexcharts/ssr
subpath still exists and still works, but you do not need it to get the server
build in a Server Component. The API itself is documented in server-side
rendering.
ApexMaps has no server path, and that is the whole of it. The statics on the
imported constructor at 1.0.0 are setLicense, registerMap, registerLayout,
registerProjection, registerPalette, setGeoSource, listMaps,
catalogue, mapMeta, listProjections, listPalettes, palette,
getInstance and version. renderToHTML, renderToString and hydrate are
all undefined. It is a plain constructor called inside an effect, with the
async render lifecycle described in the ApexMaps installation
guide.
ApexGrid is the odd one out twice over. It has no renderToHTML and ships
no declarative shadow DOM, so there is no server markup to produce: the element
attaches its shadow root at runtime. And data and columns are declared
property({ attribute: false }) at 3.5.0, so they are properties and never
attributes. Rows cannot ride in on markup.
Why is the grid missing while the chart and the map render?
Here is the composite that fails. One client component, three static imports, all three panels in the same tree:
'use client'
import { useEffect, useRef } from 'react'
import ApexCharts from 'apexcharts'
import ApexMaps from 'apexmaps'
import 'apex-grid/define' // the line that disappears
export function Dashboard({ rows, columns }) {
const chartHost = useRef(null)
const mapHost = useRef(null)
// chart and map built into those hosts inside effects, as usual
return (
<>
<div ref={chartHost} />
<div ref={mapHost} />
<apex-grid data={rows} columns={columns} style={{ height: 240 }} />
</>
)
}
The chart renders. The map renders. The grid does not, because nothing in the
bundle that ships ever calls customElements.define, and an unknown tag is not
an error in HTML: it stays an inert HTMLElement with no shadow root and no
size. Nothing throws and nothing is logged.
The cause is in the package, not in Next.js. apex-grid@3.5.0's package.json
declares "sideEffects": false, and define.js is three lines:
import { ApexGrid } from './components/grid.js'
export { ApexGrid }
ApexGrid.register()
A bare import 'apex-grid/define' is a side-effect import of a module whose
package promises it has no side effects, so the bundler drops the module and the
register() call with it. That was measured as a controlled A/B: the same
package copied twice, identical bytes, with sideEffects flipped in one copy and
nothing else changed, bundled by webpack 5.98.0 (the copy Next 15.5.27 ships)
with target: 'web' and minification off.
| Entry, bundled from an identical package | sideEffects: false, as shipped | sideEffects: true, patched |
|---|---|---|
import 'apex-grid/define' (production) | 79 bytes, define.js absent | 461,001 bytes, register() present |
import 'apex-grid/define' (development) | 911 bytes, define.js absent | 816,371 bytes, register() present |
import { ApexGrid } from 'apex-grid/define' (production) | 450,035 bytes, define.js still absent | 461,032 bytes |
import { setup } from 'apex-grid' then setup() (production) | 451,527 bytes, registration runs | 462,508 bytes |
Two things in that table are worth more than the byte counts.
Development mode does not save you. webpack honours a package's declared
sideEffects flag in both modes, so this is not a production-only surprise you
find after shipping. It is the same in next dev.
Importing the named export does not help either. Row three pulls 450KB of
grid into the bundle and still never registers anything. define.js is a
re-export plus one call, so a side-effect-free package lets the bundler resolve
the import past it: the grid's own module is in the output, define.js is not,
and the only .register() call left in those 450,035 bytes is the internal one
inside registerComponent. The patched build has two, the second being
define.js's. You get the whole library and an unregistered element, which is
the most confusing of the three outcomes.
The only form that survives is a function call, because no bundler may
delete one. That is setup() or ApexGrid.register(), in your own code, where
you can see it.
Does apex-grid/define register the element or not?
It depends entirely on who evaluates the module, and the answer is different in all three places a reader meets it.
| Where | Does <apex-grid> get registered? |
|---|---|
Browser, <script type="module"> from a CDN | Yes. The browser evaluates the module, so the call runs. This is what the demos on this site do. |
| Browser, after a bundler | No, per the table above |
| Node, for example inside a Server Component | Yes, into Lit's SSR DOM shim |
That last row is the one nobody expects, so here it is as a command you can run yourself from a project that has the package installed:
It prints true. In bare Node 22.23.1 with window, document and
HTMLElement all undefined, importing apex-grid/define does not throw and
changes exactly one global: customElements, from undefined to an object,
because Lit resolves its own node condition and loads
@lit-labs/ssr-dom-shim. window, document, HTMLElement, ShadowRoot,
Element, Node, CSSStyleSheet, getComputedStyle and location are all
still undefined afterwards, so importing the grid on the server does not flip
any other library's typeof window === 'undefined' check. Importing the bare
apex-grid entry installs that same shim registry.
Our own ApexGrid installation guide
describes apex-grid/define as registering the element and its dependencies in
the browser registry. That is what happens in a browser module script and in
Node. It is not what happens on the other side of a bundler, and the install
page is the right place to fix rather than this one.
The grid is registered now, so why is it empty?
Because fixing the registration exposes a second problem that was hidden behind the first one, and this is the half that costs the afternoon.
Keep the JSX from the failing example and add a setup() call. The element
upgrades, attaches its shadow root and takes its height, and it has no rows and
no columns, because columns and data are both initialised to [] in the
constructor at 3.5.0 and React 19.3.0 never replaces them.
Start with what the server actually wrote. Rendering that element through
react-dom@19.3.0's server renderer:
renderToStaticMarkup(
<apex-grid data={rows} columns={columns} style={{ height: 240 }} />
)
// <apex-grid style="height:240px"></apex-grid>
The rows and the columns are not in the HTML at all. React does not serialize
object props on a custom element, and since data and columns are
attribute: false on the grid, there is nowhere for them to go even if it did.
Hydration does not repair that. In react-dom 19.3.0's shipped client bundle, the
custom-element branch of the hydration diff walks the props and calls
getValueForAttributeOnCustomComponent plus warnForPropDifference on each.
It compares; nothing assigns. Worse, when the attribute is absent and the
expected value is an object, that helper returns the expected object itself, so
the comparison matches and React does not even report a difference for data.
On a plain client render React does assign, and the rule it uses is one line:
key in domElement ? (domElement[key] = value) : setAttribute(...). That in
test is the whole ordering requirement. If the tag has not been registered by
the time React creates the node, the node is an unupgraded HTMLElement,
'data' in el is false, and your array is handed to setAttribute and lost.
What you do get is a hydration mismatch, and its wording sends you to the wrong
place. The element rewrites its own host as it comes up: controllers/dom.js
sets --scrollbar-offset and --apex-host-width as inline style properties on
the host, and updated() sets role, aria-rowcount and aria-colcount on
it. React's diffHydratedStyles builds a style string from your props and
compares it against domElement.getAttribute('style'), so one extra custom
property on that attribute is enough to record a difference. The warning React
prints for that class begins:
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.
and then lists Date.now(), Math.random(), locale date formatting and browser
extensions. None of those is the cause here.
Do not chase the unit. style={{ height: 240 }} and style={{ height: '240px' }}
produce byte-identical server HTML, which I checked by rendering both:
renderToStaticMarkup(<apex-grid style={{ height: 240 }} />)
// <apex-grid style="height:240px"></apex-grid>
renderToStaticMarkup(<apex-grid style={{ height: '240px' }} />)
// <apex-grid style="height:240px"></apex-grid>
Same string. The upgrade is the difference, not the spelling.
What mount shape actually works?
Two, and they trade against each other.
Keep the server HTML: own the element yourself. Do not put <apex-grid> in
JSX at all. Give React a plain host div, then register and build the element
inside the effect, after customElements.whenDefined has resolved:
'use client'
import { useEffect, useRef } from 'react'
export function GridPanel({ rows, columns }) {
const host = useRef(null)
useEffect(() => {
let cancelled = false
let grid
;(async () => {
const { setup } = await import('apex-grid')
setup() // registers the element, adopts the host height stylesheet
await customElements.whenDefined('apex-grid')
if (cancelled || !host.current) return
grid = document.createElement('apex-grid')
grid.columns = columns
grid.data = rows // set before append, so they land in the first update
host.current.appendChild(grid)
await grid.updateComplete
})()
return () => {
cancelled = true
grid?.remove()
}
}, [rows, columns])
return <div ref={host} style={{ height: 240 }} />
}
React never owns the custom element node, so there is no hydration diff to fail,
and the properties are assigned to an element that is already upgraded. This is
the shape this site ships in its own ApexGrid banner. What setup() does, and
the host height rule it adopts, are in the installation
guide.
Or drop SSR for that subtree. Move the grid into its own module that calls
setup() at module scope, and load that module with a ssr: false dynamic
import. There is no server HTML to hydrate against, and the module finishes
evaluating before its component renders, so the element is registered before
React creates the node and key in domElement is true. JSX object props then
work. Next.js integration has that pattern written
out.
| Element built in an effect | ssr: false subtree | |
|---|---|---|
| Server HTML for the rest of the page | kept | kept |
| Server HTML for the grid panel | an empty host div | nothing renders until the client |
| Code change | a few more lines | one import |
| JSX props on the element | not used | fine |
Pick the second when the grid is the whole panel and you want the smallest diff. Pick the first when the grid sits inside a server-rendered layout you care about. For the React wrapper rather than the raw element, see the ApexGrid React guide; every measurement on this page was taken against the raw custom element, which is what the installation guide quick-starts with.
Why does this site load ApexMaps from a script tag instead of importing it?
Because of bundle size and CDN cache, not because the import does not work. It
does. import 'apexmaps' evaluates in Node 22.23.1 without throwing and without
touching a single DOM global, exactly like apexcharts; what it cannot do is
render without a browser.
This app loads the published bundle from jsDelivr with a <script> tag, which
src/lib/apexmaps-runtime.ts explains in its header: the tag keeps ApexMaps out
of this app's client bundle and shares the CDN cache with the demo iframes,
which already load the same URL. ApexMaps 1.0.0 ships dist/apexmaps.min.js at
264,544 bytes, 83,910 bytes under gzip -9, so the saving is real for a site
where the map appears on two pages. In your own app, where the map is probably the
point of the page, import it and let your bundler handle it. There is no
mounting constraint either way: with a script tag you wait on the loader
promise, with an import you wait on the dynamic import. Both end in a
constructor inside an effect, as in the ApexMaps React
guide.
Which plans include these three?
ApexCharts.js, ApexGrid and ApexMaps are all included from Community upward, and Community is free for organizations under $2M USD in annual revenue, budget or funding; at or above that it is a paid licence like the others. ApexGrid Enterprise and the ApexMaps premium features are separate, from Premium upward, and nothing on this page touches either. Nothing in the family is open source. The pricing page has the matrix.
Once all three are mounted, the contract that keeps one selection across themSee the pieces running
Reference documentation
Frequently Asked Questions
Why is my ApexGrid missing in Next.js when the chart and the map render?
Because the bundler deleted the line that registers the element. apex-grid 3.5.0 declares "sideEffects": false, and apex-grid/define is a re-export plus one call, so a bare import of it is dropped along with the ApexGrid.register() inside. Measured with webpack 5.98.0 on identical package bytes: 79 bytes of production output as shipped, 461,001 bytes with that one field flipped to true. Call setup() from apex-grid instead, because no bundler may delete a function call.
Does importing apex-grid in a Server Component throw?
No. In bare Node 22.23.1 with window, document and HTMLElement all undefined, importing apex-grid/define succeeds and changes exactly one global, customElements, from undefined to an object, because Lit loads @lit-labs/ssr-dom-shim. It registers into that shim rather than crashing. There is still no server markup to produce, because the grid ships no declarative shadow DOM.
Can I pass rows to apex-grid as a JSX prop?
Not through hydration. react-dom 19.3.0 serializes an apex-grid element with data and columns object props to markup carrying neither, and its hydration path compares custom-element props rather than assigning them. On a client render it assigns only when the key is already in the element, so the tag must be registered before React creates the node. Assign ref.current.columns and ref.current.data after customElements.whenDefined resolves, or render that subtree with ssr: false.
Do I need the apexcharts/ssr subpath in a Server Component?
Not at 7.8.0. The package exports map declares a node condition and no react-server key, and Next 15.5.27 builds its server layer with react-server first and the default conditions after, so a bare import of apexcharts resolves to dist/apexcharts.ssr.esm.js, which carries renderToHTML, renderToString and hydrate. The subpath still works and says what it means, which is why the docs use it.
Can ApexMaps render on the server?
No. At 1.0.0 the imported constructor exposes setLicense, registerMap, registerLayout, registerProjection, registerPalette, setGeoSource, listMaps, catalogue, mapMeta, listProjections, listPalettes, palette, getInstance and version. renderToHTML, renderToString and hydrate are all undefined. It is constructed inside an effect, and the import itself evaluates cleanly on the server.
Which plans include ApexCharts, ApexGrid and ApexMaps?
All three are included from Community upward, and Community is free for organizations under $2M USD in annual revenue, budget or funding. ApexGrid Enterprise and the ApexMaps premium features are separate and start at Premium, and nothing in this recipe uses either.
Related
Read the per-pattern guide first
The App Router patterns for ApexCharts, including the server-rendered SVG, are written out in the Next.js integration guide.