Next.js
XenolithGraph is a WebGL/PIXI editor that runs only in the browser. Any framework with SSR has to render it inside a client-only boundary. Next.js (App Router or Pages Router) supports this in one line.
Install
pnpm add @xenolithengine/graph-editor @xenolithengine/graph-react pixi.jspixi.js is a peer dependency. Pin it to a version we test against — see the peerDependencies field of @xenolithengine/graph-render-pixi.
Mount (App Router, the path most modern apps use)
import dynamic from 'next/dynamic'
const Editor = dynamic(() => import('./Editor').then((m) => m.Editor), { ssr: false, loading: () => <div style={{ height: '100vh', background: '#121212' }} />,})
export default function Page() { return <Editor />}'use client'
import { XenolithGraph, XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-react'
function MyPanel() { const editor = useEditor() return ( <XenolithPanel position="top-left"> <XenolithButton onClick={() => editor.fitView()}>Fit view</XenolithButton> </XenolithPanel> )}
export function Editor() { return ( <XenolithGraph className="xeno" onReady={(e) => e.loadJSON(myGraph)}> <MyPanel /> </XenolithGraph> )}Two important details:
'use client'at the top ofEditor.tsx. The editor mounts PIXI into the DOM inuseEffect— that file must never run on the server.dynamic(... { ssr: false })in the page that uses it. Without this Next will try to render<XenolithGraph>server-side, the WebGL context fails, and you get a hydration mismatch.
Pages Router
Same idea, slightly different syntax:
import dynamic from 'next/dynamic'
const Editor = dynamic(() => import('../components/Editor').then((m) => m.Editor), { ssr: false,})
export default function EditorPage() { return <Editor /> }Guarantees (tested, not just documented)
Every package imports cleanly in a non-DOM environment. @xenolithengine/graph-core,
graph-adapter-core, graph-react, graph-editor (including the full PIXI import graph) and
graph-wc all evaluate without touching window/document/HTMLElement at module scope —
locked by the node-purity suite in @xenolithengine/graph-test-utils. Practically: you may
import editor symbols in server code (serializeXenolithGraph, importFromReactFlow,
diffGraphs, types) as long as you never construct an editor there. graph-wc’s
register() no-ops without customElements, so importing it on the server is safe too (its
element base class is lazily guarded since 2026-09-28 — it used to crash server bundles).
React StrictMode is safe. The adapter serializes mount/destroy through a single async
chain, so the dev-mode double-invoke (mount → cleanup → mount) boots exactly ONE editor,
destroys it fully on unmount, survives unmount-during-boot, and leaks no canvases — each of
those is an executed test (renderXenolithToDOM(props, { strictMode: true }) from
graph-test-utils exercises the same path in your own suite). Production
builds are unaffected; StrictMode double-invocation is dev-only.
Why no SSR mode
A “headless” SSR mode would mean pre-rendering the graph as static SVG on the server. That’s a different product. @xenolithengine/graph-render-pixi is the only renderer we ship; it requires a <canvas> + GPU. If you need a server-rendered preview, use editor.exportImage() on the client and upload the resulting PNG — that’s a one-shot static render, not interactivity.
Bundle size
@xenolithengine/graph-editor + @xenolithengine/graph-react together are ~80 kB gzip. PIXI v8 (your peer dep) adds another ~250 kB gzip. The whole thing lazy-loads on the client side, so it doesn’t affect your server-rendered initial paint.
Streaming, RSC, Suspense
The loading prop on next/dynamic is the only Suspense-style API we recommend touching. Don’t try to suspend on XenolithGraph itself — the binding has internal lifecycles (PIXI app, canvas attach, mount-once events) that don’t tolerate React suspending mid-mount.