Thomas Pedot

mercredi 18 mars 2026

Building visual-c4.com: C4 Model Documentation SaaS Architecture

Building visual-c4.com: C4 Model Documentation SaaS Architecture

Overview

visual-c4.com is an open-source SaaS tool for creating C4 model diagrams and managing Architecture Decision Records (ADRs) — all in one place. This article documents the architecture decisions, technology choices, and technical challenges behind building it.

The project combines a marketing blog (Next.js 15, MDX) and a full interactive diagramming application (Next.js 13+, Prisma, Cytoscape) — two separate Next.js apps sharing a monorepo.


Why Build Another C4 Tool?

Existing C4 tools fall into two camps: code-first tools like Structurizr (powerful but developer-only) and general diagramming tools like draw.io (visual but no C4 model semantics). Neither integrates Architecture Decision Records into the diagram workflow.

The goal was to build a tool that:

  1. Works visually — drag, drop, connect
  2. Enforces C4 model semantics (levels, element types, relationships)
  3. Integrates ADRs directly linked to diagram components
  4. Is open-source and self-hostable

System Architecture (C4 Context Level)

At the highest level, visual-c4.com consists of three main systems:

  • Marketing Blog (c4-panel-blog/) — Next.js 15 with MDX content, deployed on Vercel
  • Main Application (main/) — Next.js 13+ App Router, interactive C4 builder
  • Database — SQLite with Prisma ORM, accessed via ZenStack for access control

Users authenticate via NextAuth, create projects, build C4 diagrams, and attach ADRs to diagram nodes — all without leaving the app.


Container Architecture

Marketing Blog (c4-panel-blog)

Stack: Next.js 15, Turbopack, MDX, Tailwind CSS, Biome

The blog is a separate Next.js app that handles content marketing SEO. Articles are MDX files with frontmatter (title, slug, keywords, description). The content pipeline uses gray-matter for parsing and next-mdx-remote/rsc for React Server Component rendering.

Key design decision: keeping the blog as a completely separate app means it can be deployed independently with zero risk of breaking the main app.

Main Application (main)

Stack: Next.js 13.5, TypeScript, Prisma 5, ZenStack 2, Zustand 4, Cytoscape 3, XYFlow, TanStack Query 5

The main app is where users build their C4 diagrams. It follows a feature-based folder structure:

Plain Text
1src/
2├── app/          # Next.js App Router pages
3│   ├── builder/  # C4 diagram editor
4│   ├── adr/      # Architecture Decision Records
5│   ├── projects/ # Project management
6│   └── docs/     # Documentation
7├── components/   # Reusable UI components (Shadcn/Radix)
8├── store/        # Zustand state management
9└── lib/          # Utility functions

Component Architecture: The C4 Builder

The builder is the core feature. It's built on two graph libraries used at different levels:

Cytoscape.js handles the low-level graph model — nodes, edges, layouts, and selection. I chose Cytoscape over D3 because it has a proper graph model with built-in layout algorithms (ELK, Dagre, FCOSE, Euler, KLay, hierarchical).

XYFlow (React Flow) provides the React integration layer — the canvas, zoom/pan controls, and node rendering. Custom node types render C4 elements (Person, Software System, Container, Component) with their C4-specific styling.

The layout pipeline:

  1. User adds/moves nodes → Zustand store updated
  2. Store triggers Cytoscape layout recalculation
  3. Layout result fed back to XYFlow for rendering
  4. Positions persisted to Prisma/SQLite

Data Model (ADR Integration)

The most interesting architectural challenge was linking ADRs to diagram nodes. The Prisma schema has:

PRISMA
1model Project {
2  id        String     @id @default(cuid())
3  diagrams  Diagram[]
4  adrs      Adr[]
5}
6
7model Diagram {
8  id        String  @id @default(cuid())
9  nodes     Node[]
10  edges     Edge[]
11  projectId String
12  project   Project @relation(...)
13}
14
15model Adr {
16  id          String    @id @default(cuid())
17  title       String
18  status      AdrStatus
19  context     String
20  decision    String
21  consequences String
22  nodeLinks   AdrNodeLink[]
23  projectId   String
24}
25
26model AdrNodeLink {
27  adrId     String
28  nodeId    String
29  adr       Adr    @relation(...)
30}

This schema lets you attach one or more ADRs to any diagram node. When reviewing a Container diagram, you can click any component and see the ADRs that explain why it was built that way.


Access Control with ZenStack

Rather than writing manual middleware for multi-tenant access control, I used ZenStack — a layer on top of Prisma that adds policy-based access control directly in the schema.

ZMODEL
model Project {
  // Only the owner can read and write
  @@allow('all', auth() == owner)
  @@allow('read', members?[user == auth()])
}

ZenStack generates a policy-aware Prisma client that enforces these rules at the query level — no manual WHERE userId = ? filters scattered across the codebase.


State Management

The builder uses Zustand for client state, split into multiple stores:

  • diagramStore — current diagram nodes and edges
  • selectionStore — selected nodes/edges
  • layoutStore — layout algorithm config
  • uiStore — sidebar state, panels, zoom level

TanStack Query handles all server state (projects list, ADR data, user settings) with background refetching and optimistic updates.


Architecture Decisions

Several key decisions are documented as ADRs in the project itself (using visual-c4.com to document visual-c4.com):

ADR-001: Use SQLite over PostgreSQL for initial deployment SQLite dramatically simplifies self-hosting — no separate database process to manage. Prisma handles migrations. For teams that outgrow SQLite, migrating to PostgreSQL is a schema change.

ADR-002: Separate blog and app as two Next.js projects The blog needs fast static builds for SEO. The app needs SSR with auth. Keeping them separate means each can be optimized independently and deployed with zero cross-dependency risk.

ADR-003: Cytoscape for graph model, XYFlow for rendering Cytoscape's layout algorithms (especially ELK and FCOSE) are significantly better than anything custom-built. XYFlow provides the React canvas. Using both together gives the best of each library.


Deployment

Both apps deploy to Vercel. The SQLite database uses Vercel's persistent storage. A Docker image is available for self-hosting.

Blog: c4-panel-blog/ → deployed as Next.js static + ISR App: main/ → deployed as Next.js server (SSR required for auth)


Lessons Learned

  1. Two Next.js apps in a monorepo is the right call for this combination. Shared components were minimal enough that a shared package wasn't worth the overhead.

  2. ZenStack saved weeks of access control work. The upfront schema investment pays off immediately in reduced middleware code.

  3. Layout algorithms are hard. Cytoscape's ELK integration gave automatic, readable layouts for free — one of the features users praise most.

  4. SQLite is underrated for SaaS. For a self-hosted open-source tool targeting small teams, SQLite is perfect. Zero ops overhead.


Try It