Recipe

Mount a chart, a grid and a map in the Next.js App Router

Two of the three panels render, the third is absent, and the console is clean. The grid is a custom element, its registration arrives as a side-effect import, and a bundler that has been told the package has no side effects deletes it.

A chart, a grid and a map on one page, loaded from script tags with no bundler involvedOpen in new tab

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.0ApexGrid 3.5.0ApexMaps 1.0.0
Server renderYes, renderToHTML()NoNo
Registration stepnone, it is a constructorsetup() or ApexGrid.register(), called by younone, it is a constructor
How data arrivesconstructor argumentproperty assignment onlyconstructor argument
Import on the server (Node 22)resolves to the SSR buildregisters into a shimevaluates, 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 packagesideEffects: false, as shippedsideEffects: true, patched
import 'apex-grid/define' (production)79 bytes, define.js absent461,001 bytes, register() present
import 'apex-grid/define' (development)911 bytes, define.js absent816,371 bytes, register() present
import { ApexGrid } from 'apex-grid/define' (production)450,035 bytes, define.js still absent461,032 bytes
import { setup } from 'apex-grid' then setup() (production)451,527 bytes, registration runs462,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.

WhereDoes <apex-grid> get registered?
Browser, <script type="module"> from a CDNYes. The browser evaluates the module, so the call runs. This is what the demos on this site do.
Browser, after a bundlerNo, per the table above
Node, for example inside a Server ComponentYes, 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 effectssr: false subtree
Server HTML for the rest of the pagekeptkept
Server HTML for the grid panelan empty host divnothing renders until the client
Code changea few more linesone import
JSX props on the elementnot usedfine

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 them

See 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.

Get started