Appearance
MongoDB — your data is already a graph
Most applications store relationships as references in their documents. A product has a category field. An order links a customer to a list of products. These references are edges in a graph; you just haven't visualized them yet.
In this tutorial we build a product co-purchase graph for a small online shop, backed by MongoDB through a thin Express connector. The result is a fully interactive Ogma visualization that reveals hidden buying patterns — cross-category bridge products, customer segments — with fewer than 200 lines of server code and no graph database required.
What we're building
The dataset contains products grouped into categories (electronics, gaming, kitchen, fitness, …). Whenever two products appear together in a purchase basket frequently enough, we record a CO_PURCHASED edge between them. Visualized with a force layout, the graph naturally separates into product clusters per category, with a few hub nodes — bridge products bought across segments — sitting in the middle.
Products (nodes) ←→ CO_PURCHASED edges
──────────────────────────────────────────────────────
Noise-cancel Headphones ←→ Mechanical Keyboard (co-bought often)
Wireless Mouse ←→ Gaming Mouse (cross-category bridge)
Smartwatch ←→ Yoga Mat (cross-category bridge)The architecture has three layers:
MongoDB Express connector Browser + Ogma
(entities + → $graphLookup + → setGraph()
relationships) toGraph() force layout + stylesProject setup
Create a new project and install the dependencies. We use TypeScript throughout and Vite for the frontend dev server.
sh
npm init -y
npm install mongodb express dotenv @linkurious/ogma
npm install --save-dev typescript tsx vite @types/node @types/expressCreate a .env file for your MongoDB connection:
sh
# .env
MONGODB_URI=mongodb://localhost:27017
MONGODB_DB=shop
PORT=3000Mock mode
All three playground demos below run against a pre-generated JSON snapshot — no MongoDB installation needed. The same frontend code works unchanged against a live database by switching from mock-db.json to the Express API.
Step 1 — Map your data shape
Define TypeScript interfaces that reflect the document shapes in your MongoDB collections. These become a shared contract imported by both server and browser, so mismatches are caught at compile time rather than at runtime.
ts
// types.ts
export type RelType = 'CO_PURCHASED' | 'IN_CATEGORY' | 'PURCHASED';
export interface ProductDoc {
_id: string;
type: 'Product';
name: string;
price: number;
category: string;
}
export interface RelDoc {
_id: string;
type: RelType;
source: string; // MongoDB _id of the source entity
target: string; // MongoDB _id of the target entity
weight?: number; // number of times co-purchased
}
// The shapes Ogma's generic parameters expect
export type NodeData = ProductDoc;
export interface EdgeData { type: RelType; weight?: number; }Two MongoDB collections back this data model:
| Collection | Contains |
|---|---|
entities | One document per product (or customer, category, …) |
relationships | One document per directed edge between two entities |
Storing relationships as their own documents — rather than embedding arrays — is the key design decision. It lets you traverse the graph with $graphLookup without any schema migration when you add new relationship types later.
Edge direction convention
For a single outward $graphLookup walk to work, edges must be stored in a consistent direction. In this demo all CO_PURCHASED edges are stored with the lower _id as source and the higher as target (arbitrary, but consistent). For richer graphs (customers + products + categories) the convention is:
IN_CATEGORY: Category → Product
PURCHASED: Product → CustomerA single $graphLookup walk from a Category node then reaches its products at depth 1 and the customers who bought them at depth 2.
Step 2 — MongoDB connection
A cached MongoClient singleton that connects once on the first request and reuses the connection thereafter:
ts
// db.ts
import { MongoClient, Db } from 'mongodb';
import dotenv from 'dotenv';
dotenv.config();
let cachedDb: Db | null = null;
export async function getDb(): Promise<Db> {
if (cachedDb) return cachedDb;
const client = new MongoClient(
process.env.MONGODB_URI ?? 'mongodb://localhost:27017'
);
await client.connect();
cachedDb = client.db(process.env.MONGODB_DB ?? 'shop');
return cachedDb;
}Step 3 — Graph traversal with $graphLookup
$graphLookup is MongoDB's built-in graph traversal operator. It walks an adjacency list — the relationships collection — outward from a seed document, collecting all reachable edge documents up to a configurable depth.
ts
// queries.ts
export async function loadProducts(db: Db): Promise<LoaderResult> {
// Start from every product and collect edges within 2 hops
const rows = await db.collection('entities').aggregate([
{ $match: { type: 'Product' } },
{
$graphLookup: {
from: 'relationships', // collection to traverse
startWith: '$_id', // seed: this document's _id
connectFromField: 'target', // follow edge.target …
connectToField: 'source', // … to find edge.source
as: 'reachable', // output field name
maxDepth: 2 // at most 2 hops from the seed
}
}
]).toArray();
// Collect all unique entity ids mentioned in the edges
const relationships: RelDoc[] = rows.flatMap(r => r.reachable ?? []);
const ids = new Set(rows.map(r => r._id));
for (const rel of relationships) {
ids.add(rel.source);
ids.add(rel.target);
}
const entities = await db.collection('entities')
.find({ _id: { $in: [...ids] } })
.toArray();
return { entities, relationships };
}connectFromField and connectToField together describe how edges link: start from a node's _id, find relationship documents whose source equals that _id, then jump to those documents' target, and repeat. This matches the CO_PURCHASED documents stored as { source: prodA, target: prodB }.
Indexes for performance
Create indexes on the fields $graphLookup touches before querying large collections:
ts
await db.collection('relationships').createIndex({ type: 1, source: 1 });
await db.collection('relationships').createIndex({ type: 1, target: 1 });
await db.collection('entities').createIndex({ type: 1 });Step 4 — Parser: documents → graph
toGraph() maps raw MongoDB documents into the { nodes, edges } shape that ogma.setGraph() expects. It deduplicates by _id and silently drops edges whose endpoints are not in the node set (which can happen when $graphLookup reaches further than the entity query):
ts
// parser.ts
export function toGraph({ entities, relationships }: LoaderResult): RawGraph {
const nodeIds = new Set<string>();
const nodes = entities
.filter(e => !nodeIds.has(e._id) && nodeIds.add(e._id))
.map(e => ({ id: e._id, data: e }));
const edgeIds = new Set<string>();
const edges = relationships
.filter(
r =>
!edgeIds.has(r._id) &&
edgeIds.add(r._id) &&
nodeIds.has(r.source) &&
nodeIds.has(r.target)
)
.map(r => ({
id: r._id,
source: r.source,
target: r.target,
data: { type: r.type, weight: r.weight }
}));
return { nodes, edges };
}This is deliberately thin. The MongoDB query already handles the graph traversal; toGraph() just repackages the result.
Step 5 — Connector API
A thin Express server bridges MongoDB and the browser. All database access stays server-side — the client never sees the connection string or raw collection data.
ts
// server.ts
import express from 'express';
import { getDb } from './db.js';
import { loadProducts, loadCustomerNeighborhood, loadProductBuyers } from './queries.js';
import { toGraph } from './parser.js';
const app = express();
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.get('/api/products', async (req, res, next) => {
try {
const db = await getDb();
res.json(toGraph(await loadProducts(db)));
} catch (err) { next(err); }
});
app.get('/api/customer/:id', async (req, res, next) => {
try {
const db = await getDb();
res.json(toGraph(await loadCustomerNeighborhood(db, req.params.id)));
} catch (err) { next(err); }
});
app.get('/api/product/:id', async (req, res, next) => {
try {
const db = await getDb();
res.json(toGraph(await loadProductBuyers(db, req.params.id)));
} catch (err) { next(err); }
});
app.listen(3000, () => console.log('Connector API ready on :3000'));Configure Vite to proxy /api requests to the Express server during development:
ts
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: { '/api': 'http://localhost:3000' }
}
});Step 6 — API client (with mock mode)
A single API class abstracts both the live server and the static JSON snapshot. Toggle mode via ?mock=0 / ?mock=1 in the URL:
ts
// client.ts
export class API {
private snapshot?: Record<string, RawGraph>;
constructor(private readonly mock: boolean) {}
private async ensureSnapshot() {
if (!this.snapshot)
this.snapshot = await fetch('/mock-db.json').then(r => r.json());
return this.snapshot!;
}
async getProducts(): Promise<RawGraph> {
if (this.mock) return (await this.ensureSnapshot())['graph:products'];
return fetch('/api/products').then(r => r.json());
}
async getProduct(id: string): Promise<RawGraph> {
if (this.mock) return (await this.ensureSnapshot())[`product:${id}`];
return fetch(`/api/product/${id}`).then(r => r.json());
}
}The mock snapshot is generated once from the in-memory data generator and committed alongside the frontend:
sh
# Seed MongoDB AND regenerate mock-db.json
npx tsx generate.ts
# Only regenerate mock-db.json (no MongoDB required)
npx tsx generate.ts --mockStage 1 — Getting your graph on the screen
Load the graph, run a force layout, and render it — no styling, no colors. The force layout alone separates connected products into visible lobes. The central nodes that sit between lobes are the bridge products purchased across category lines.
ts
import Ogma from '@linkurious/ogma';
const graph = await fetch('/api/products').then(r => r.json());
const ogma = new Ogma({ container: 'graph-container' });
await ogma.setGraph(graph);
await ogma.layouts.force({ locate: true, gravity: 0.05, charge: 8, edgeStrength: 0.6 });The structure you see is already meaningful. Before adding a single color rule, the layout has done most of the analytical work for you.
Stage 2 — Interpretation
Four progressive styling layers, added one at a time so you can see exactly what each addRule() contributes.
2a — Labels
ts
ogma.styles.addRule({
nodeAttributes: {
text: {
content: n => n?.getData('name') ?? '',
size: 10,
minVisibleSize: 0, // show at every zoom level
outline: { color: '#F5F5F5', width: 1 }
}
}
});2b — Category color
Map each product's category field to a fixed color. The lobes you saw in Act 1 are now explicitly named segments:
ts
const CATEGORY_COLORS = {
electronics: 'rgba(59, 130, 246, 1)',
kitchen: 'rgba(249, 115, 22, 1)',
gaming: 'rgba(139, 92, 246, 1)',
fitness: 'rgba(34, 211, 238, 1)',
// …
};
ogma.styles.addRule({
nodeAttributes: {
color: n => CATEGORY_COLORS[n?.getData('category')] ?? '#4C8BF5'
}
});2c — Degree halo (influence)
Products with many co-purchase edges are sized larger via a degree-proportional halo. Bridge products become visually prominent:
ts
ogma.styles.addRule({
nodeAttributes: {
halo: n => {
const category = n?.getData('category');
const color = CATEGORY_COLORS[n?.getData('category')];
return {
width: Math.max(n?.getDegree() * 0.8, 8),
color: withOpacity(color, 0.3),
strokeColor: color,
strokeWidth: 1,
scalingMethod: 'scaled'
};
}
}
});2d — Node grouping
addNodeGrouping() collapses each category cluster into a single meta-node. This is a one-liner that works on the graph as already styled:
ts
const grouping = ogma.transformations.addNodeGrouping({
groupIdFunction: node => node.getData('category'),
showContents: true
});
// To undo:
await grouping.destroy();Use the toolbar to step through labels → category color → degree halo → grouping.
Stage 3 — Exploration
This is where graph visualization pays off commercially. A category page in your shop shows products in a flat list — it has no way to see that Wireless Mouse is bought almost as often by gamers as by office workers, or that Smartwatch straddles the fitness and electronics segments equally.
Click any node below and Ogma draws its edges in two colours: grey for same-category co-purchases, orange dashes for cross-category connections. An orange edge is a recommendation opportunity your existing category model is blind to.
The playground opens with Wireless Mouse already selected so you can see the insight immediately — then click any other node to explore.
ts
let selectedId: string | null = null;
// Rule 1: outer stroke on the selected node
const selectionRule = ogma.styles.addRule({
nodeSelector: n => String(n.getId()) === selectedId,
nodeAttributes: {
outerStroke: { color: '#ffffff', width: 6 },
innerStroke: { color: '#ffffff', width: 2 }
}
});
// Rule 2: orange dashed edges that cross categories
const crossCategoryRule = ogma.styles.addRule({
edgeSelector: e => {
if (!selectedId) return false;
const src = e.getSource();
const tgt = e.getTarget();
// Only edges connected to the selected node
if (String(src.getId()) !== selectedId && String(tgt.getId()) !== selectedId)
return false;
// Only edges where the two endpoints are in different categories
return src.getData('category') !== tgt.getData('category');
},
edgeAttributes: {
color: '#f97316',
width: 1.5,
shape: { style: 'dashed', tail: 'arrow' }
}
});
ogma.events.on('click', evt => {
selectedId = isNode(evt.target) ? String(evt.target.getId()) : null;
selectionRule.refresh();
crossCategoryRule.refresh();
});The two rules together cost about 20 lines of code. The insight — that certain products are bought across category lines and therefore belong in multiple recommendation surfaces — would take a dedicated BI query and a business analyst to surface from raw purchase logs. Here it's just the graph, rendered.
Running the full stack
sh
# 1. Seed MongoDB and generate the mock snapshot
npx tsx generate.ts
# 2. Start the Express connector
npx tsx server.ts
# 3. Start the Vite frontend
npm run dev
# Open http://localhost:5173
# Append ?mock=0 to use the live MongoDB API instead of the snapshotWhat you can add
- Richer graph: add
CustomerandCategorynodes withPURCHASEDandIN_CATEGORYedges. The$graphLookupquery from a category root reaches products at depth 1 and customers at depth 2 — no query rewrite needed. - On-demand expansion: double-clicking a node fetches its neighborhood from
/api/customer/:idor/api/product/:idand merges it into the graph withogma.addGraph(). - Semantic zoom: use
minVisibleSizeon labels so names appear only when nodes are large enough to read comfortably.