+1 (415) 649-9454

Building a Graph Investigation UI with the Neo4j Visualization Library (NVL) and React

Every graph project eventually meets the same request: "can we see it?" Analysts want to pivot from an account to its devices to the shared addresses; a fraud reviewer wants to expand two hops and stop; an ops lead wants a saved view to hand to compliance. Neo4j Explore (and before it, Bloom) answers that for internal power users, but the moment the audience is a customer, a call-centre agent, or an app with its own permissions model, you are building your own UI.

This tutorial builds that UI with the Neo4j Visualization Library (NVL) — the same rendering engine behind Explore, published as npm packages you can embed in a React app. We will render a graph, wire click-to-expand, keep the browser from melting on supernodes, and enforce that the front end never sends raw Cypher.

What NVL is (and what it is not)

NVL ships as a small family of packages:

  • @neo4j-nvl/base — the renderer. Framework-agnostic, WebGL/canvas for large graphs, SVG for small crisp ones.
  • @neo4j-nvl/react<InteractiveNvlWrapper> and <BasicNvlWrapper> React components.
  • @neo4j-nvl/layout-workers — force-directed, hierarchical, grid and other layouts, run off the main thread in a web worker.

NVL is a renderer, not a data layer. It knows nothing about Bolt, auth, or your schema. You feed it two arrays — nodes and relationships — and it draws and lays them out. That separation is the point: your API decides what a user is allowed to see, NVL just paints it.

It is not a replacement for Explore when the user is an internal analyst who wants free-form search — buy the tool, do not rebuild it. Build with NVL when visualization is a feature of your product.

Step 1 — install

npm install @neo4j-nvl/base @neo4j-nvl/react @neo4j-nvl/layout-workers

The layout workers need to be served as assets. With Vite:

// vite.config.js
export default {
  optimizeDeps: { exclude: ['@neo4j-nvl/layout-workers'] },
  worker: { format: 'es' }
}

If layouts silently do nothing in production but work in dev, this config is almost always why: the worker bundle did not ship.

Step 2 — a read-only graph endpoint

The front end must never hold database credentials or send Cypher. Expose named queries instead. Node/Express with the v6 driver:

import neo4j from 'neo4j-driver'
import express from 'express'

const driver = neo4j.driver(
  process.env.NEO4J_URI,
  neo4j.auth.basic(process.env.NEO4J_USER, process.env.NEO4J_PASSWORD),
  { maxConnectionPoolSize: 50 }
)

const app = express()

// Seed view: one account and its immediate neighbourhood.
app.get('/api/graph/account/:id', async (req, res) => {
  const session = driver.session({
    database: 'neo4j',
    defaultAccessMode: neo4j.session.READ
  })
  try {
    const result = await session.executeRead(tx => tx.run(
      `MATCH (a:Account {id: $id})
       OPTIONAL MATCH (a)-[r:USED_DEVICE|SHARES_ADDRESS|TRANSFERRED_TO]-(n)
       WITH a, collect({rel: r, other: n})[0..$limit] AS nbrs
       RETURN a, nbrs`,
      { id: req.params.id, limit: neo4j.int(75) }
    ))
    res.json(toNvl(result.records))
  } finally {
    await session.close()
  }
})

Two things to notice. executeRead routes to a follower in a cluster, keeping your leader free for writes. And the neighbourhood is capped in Cypher, not in JavaScript — a supernode with 400k relationships must never reach the wire.

Step 3 — shape records into NVL's model

NVL wants flat objects with string ids:

const COLORS = {
  Account: '#0a7cff', Device: '#f59f00', Address: '#12b886', Merchant: '#7048e8'
}

function toNvl(records) {
  const nodes = new Map()
  const rels = new Map()

  const addNode = (n) => {
    const id = n.elementId
    if (!n || nodes.has(id)) return
    nodes.set(id, {
      id,
      caption: n.properties.name ?? n.properties.id ?? n.labels[0],
      labels: n.labels,
      color: COLORS[n.labels[0]] ?? '#9aa0a6',
      size: n.labels.includes('Account') ? 28 : 18,
      properties: n.properties        // your own payload; NVL passes it through
    })
  }

  for (const rec of records) {
    addNode(rec.get('a'))
    for (const { rel, other } of rec.get('nbrs') ?? []) {
      if (!rel) continue
      addNode(other)
      rels.set(rel.elementId, {
        id: rel.elementId,
        from: rel.startNodeElementId,
        to: rel.endNodeElementId,
        caption: rel.type
      })
    }
  }
  return { nodes: [...nodes.values()], relationships: [...rels.values()] }
}

Use elementId, not the legacy numeric id — numeric ids are reused after deletion and will happily merge two unrelated nodes in your view. Also strip properties the caller is not entitled to here, server-side, before serialization: anything you send is visible in devtools.

Step 4 — render it in React

import { InteractiveNvlWrapper } from '@neo4j-nvl/react'
import { useCallback, useEffect, useState } from 'react'

export function GraphView({ rootId }) {
  const [graph, setGraph] = useState({ nodes: [], relationships: [] })
  const [selected, setSelected] = useState(null)

  useEffect(() => {
    fetch(`/api/graph/account/${rootId}`).then(r => r.json()).then(setGraph)
  }, [rootId])

  const expand = useCallback(async (node) => {
    const res = await fetch(`/api/graph/expand/${encodeURIComponent(node.id)}`)
    setGraph(prev => merge(prev, await res.json()))
  }, [])

  const mouseEventCallbacks = {
    onNodeClick: (node) => setSelected(node),
    onNodeDoubleClick: (node) => expand(node),
    onCanvasClick: () => setSelected(null),
    onZoom: true,
    onPan: true,
    onDrag: true
  }

  return (
    <div style={{ height: '70vh' }}>
      <InteractiveNvlWrapper
        nodes={graph.nodes}
        rels={graph.relationships}
        nvlOptions={{ layout: 'forceDirected', initialZoom: 1, renderer: 'canvas' }}
        mouseEventCallbacks={mouseEventCallbacks}
      />
      {selected && <DetailPanel node={selected} />}
    </div>
  )
}

function merge(a, b) {
  const byId = (arr) => Object.fromEntries(arr.map(x => [x.id, x]))
  return {
    nodes: Object.values({ ...byId(a.nodes), ...byId(b.nodes) }),
    relationships: Object.values({ ...byId(a.relationships), ...byId(b.relationships) })
  }
}

That is a working investigation UI in about sixty lines: seed, click to inspect, double-click to expand, drag to rearrange.

Step 5 — the expand endpoint, with a degree guard

Expansion is where naive graph UIs die. Check degree before you fetch:

app.get('/api/graph/expand/:elementId', async (req, res) => {
  const session = driver.session({ defaultAccessMode: neo4j.session.READ })
  try {
    const out = await session.executeRead(tx => tx.run(
      `MATCH (n) WHERE elementId(n) = $eid
       WITH n, COUNT { (n)--() } AS degree
       MATCH (n)-[r]-(m)
       WITH n, degree, collect({rel: r, other: m})[0..$cap] AS nbrs
       RETURN n AS a, nbrs, degree`,
      { eid: req.params.elementId, cap: neo4j.int(100) }
    ))
    const degree = out.records[0]?.get('degree')?.toNumber() ?? 0
    const payload = toNvl(out.records)
    payload.degree = degree
    payload.truncated = degree > 100
    res.json(payload)
  } finally {
    await session.close()
  }
})

Send truncated to the UI and show it — "showing 100 of 4,312 connections; filter by relationship type to narrow" beats a hairball every time. COUNT { } is cheap because it reads stored relationship degrees rather than materialising the pattern. For genuinely huge hubs, add a relationship-type filter parameter and make the user choose before you expand at all. If you are unsure whether your model has supernodes, our post on graph model debt shows how to find them.

Step 6 — layouts, and when to leave the main thread

const nvlOptions = {
  layout: 'forceDirected',
  layoutOptions: { gravity: -50 },
  allowDynamicMinZoom: true,
  renderer: 'canvas'
}

Rules of thumb from real deployments:

  • Under 200 nodes: SVG renderer, animated force-directed layout. Looks best, labels stay crisp.
  • 200–10,000 nodes: canvas renderer, force-directed in the worker, captions hidden until the user zooms in.
  • Over 10,000 nodes: do not render it. Aggregate first — run community detection in GDS, draw the communities, drill into one. Nobody has ever read a 50,000-node hairball.

Hierarchical layout is the right default for lineage, org charts and supply chains; grid layout is useful for "show me these 60 accounts side by side" comparisons.

Step 7 — styling that carries meaning

Colour by label, size by a metric the user cares about — PageRank, transaction volume, risk score — and use captions sparingly. If you already compute centrality or community scores with GDS (see our recommendation engine walkthrough), write them back as node properties and map them straight onto the visual channels:

size: 12 + Math.sqrt(n.properties.pagerank ?? 0) * 20,
color: COMMUNITY_PALETTE[(n.properties.communityId ?? 0) % COMMUNITY_PALETTE.length]

Then add a legend. A graph view without a legend is a Rorschach test.

Production checklist

  • Authorization: seed and expand endpoints must filter by the caller's entitlements. Neo4j's fine-grained RBAC can enforce much of this at the database level — see multi-tenant Neo4j — so the UI literally cannot fetch what the user may not see.
  • No raw Cypher from the browser. Named endpoints only, parameters always — the same rule as text-to-Cypher guardrails.
  • Caps everywhere: neighbourhood limit, expansion cap, total nodes on the canvas. Enforced server-side.
  • Timeouts: set a transaction timeout of a few seconds on interactive reads. A UI query that runs longer is a bug, not a slow query.
  • Cache seed views: the first view of a popular entity is identical for everyone in the same role.
  • Save and share views: persist the node/relationship id set plus positions. Analysts will ask within a week of launch.
  • Accessibility: a canvas is opaque to screen readers. Ship a table view of the same result set.

Where teams actually get stuck

The visualization is rarely the hard part — the model is. Views that need six hops to assemble, entities that resolve to three different nodes, supernodes that make expansion meaningless: these are modelling problems the UI makes visible rather than causes. If your graph UI feels slow or confusing, profile the seed query first (how to read a PROFILE plan), then look hard at the model.

We build these interfaces as part of Neo4j application development engagements, usually alongside the API and the data model behind them. If you want senior help designing the graph and the view onto it, get in touch.