Build a System Architecture Visualizer

Design and save system diagrams with a full-stack Next.js app.

Introduction

30 Second Summary

Every production app is a system of connected pieces. Services talk to databases, queues buffer messages, and caches speed things up. Tech teams draw architecture diagrams to visualize how all these pieces fit together and how data flows between them.

In this project, you will build an interactive system architecture visualizer that lets you drag components onto a canvas, connect them with edges, and save your diagrams to a real PostgreSQL database. When you reload the page, your diagram is still there.

What You'll Build

A full-stack diagramming tool built with Next.js, React Flow, Prisma, and Neon PostgreSQL that renders an interactive canvas with custom architecture nodes, drag-and-drop, and persistent storage.

By the end of this project, you'll have:

  • An interactive canvas with four custom architecture node types (Service, Database, Queue, Cache) that you can drag from a sidebar, drop onto the canvas, and connect with edges.
  • A full-stack data pipeline that flows from the React Flow canvas through Next.js API routes and Prisma ORM to a Neon PostgreSQL database.
  • Working save and load functionality that persists your diagrams across browser sessions, proving you understand how frontend state flows to a database and back.
  • Secret Mission: Add export-to-PNG functionality so you can screenshot and share your architecture diagrams.

Are there any prerequisites?

You should have basic familiarity with React components, HTML/CSS, and TypeScript syntax. No database or backend experience is needed. This project teaches you those skills from scratch.

Install Dependencies and Configure the Database

Every full-stack app needs three layers: a frontend users interact with, an API that processes requests, and a database that stores data permanently. In this project, those layers map to React Flow (canvas), Next.js API routes, and a PostgreSQL database hosted on Neon.

Before you can build anything, you need to install the libraries that power each layer and connect them together. This step gets all the plumbing in place so that later steps can focus purely on building features.

In this step, get ready to:
  • Install React Flow and Prisma packages.
  • Create a free Neon PostgreSQL database and connect it to your project.
  • Configure Prisma and React Flow styles so everything is wired up.
Install React Flow and Prisma dependencies

Your project needs two sets of packages. React Flow provides the interactive canvas components. Prisma is an ORM that lets you talk to a database using TypeScript instead of raw SQL.

  • Install React Flow by running this command in your VS Code terminal:
npm install @xyflow/react
  • Install the Prisma runtime and database dependencies by running these commands:
npm install @prisma/client @prisma/adapter-pg dotenv pg
npm install prisma tsx @types/pg --save-dev

What do these packages do?

  • @prisma/client is the auto-generated database client you use in your app code.
  • @prisma/adapter-pg connects Prisma to PostgreSQL using a direct TCP connection.
  • dotenv loads environment variables from your .env file.
  • pg is the low-level PostgreSQL driver that the adapter uses under the hood.
  • prisma (dev) is the CLI tool for migrations and schema management.
  • tsx (dev) lets Prisma execute TypeScript config files directly.
  • Confirm the packages installed correctly by opening package.json and checking that @xyflow/react appears in the dependencies section.

Install failed or timed out?

  • Make sure you are running the command from your project's root folder (the one containing package.json).
  • If you see a permissions error on macOS, try prefixing the command with sudo.
  • Check your internet connection and try again.

Why won't my npm packages install?

Initialize Prisma and create a Neon PostgreSQL database

Prisma needs a schema file to know what your database looks like, and an environment file to store the connection URL. The npx prisma init command scaffolds both of these for you automatically.

  • Initialize Prisma in your project by running:
npx prisma init

What does this command create?

This creates three files in your project:

  • prisma/schema.prisma where you define your database models.
  • .env for storing your database connection URL.
  • prisma.config.ts that tells Prisma where to find your schema and database URL.

Now you need a real PostgreSQL database for Prisma to connect to. Neon provides a free serverless PostgreSQL database that scales to zero when idle, so you pay nothing.

  • Open https://neon.tech in your browser.
  • Sign up with GitHub or Google. No credit card is required.
  • After signing in, you are guided to create a project. Enter architecture-visualizer as the project name.
  • Click Create Project.
  • On the Project Dashboard, click the Connect button.
  • Copy the connection string shown in the modal. It looks like postgresql://neondb_owner:...@ep-....neon.tech/neondb?sslmode=require.

What is Neon?

Neon is a serverless PostgreSQL provider. Its free tier gives you 0.5 GB of storage and 100 compute-hours per month. Compute automatically suspends after 5 minutes of inactivity, so you will not be charged while your project is idle.

  • Back in VS Code, open the .env file in your project root.
  • Find the DATABASE_URL line and replace the placeholder value with the connection string you just copied from Neon.
  • Save .env.

Keep your connection string private

The .env file contains your database password. Never commit it to version control. Your project's .gitignore already excludes .env by default.

Can't find a connection string?

  • Make sure you clicked Create Project and waited for the project to finish provisioning.
  • The connection string appears in the Connect modal on the Project Dashboard. Look for the full string starting with postgresql://.

Help me find my Neon connection string.

Configure Prisma for Neon and add the React Flow CSS

The auto-generated prisma.config.ts already tells Prisma to read DATABASE_URL from your environment. You need to verify it matches what the project expects, then update the schema and add the React Flow stylesheet.

  • Open prisma.config.ts in your project root and confirm it matches the following code:
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});

What does this config do?

  • import "dotenv/config" loads your .env file so Prisma can read DATABASE_URL.
  • schema tells Prisma where your schema file lives.
  • datasource.url points Prisma at your Neon database using the connection string from .env.

Next, you need to update the generated schema.prisma so the generator matches what this project uses.

  • Open prisma/schema.prisma and replace its entire contents with:
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
}

Why change the generator?

The default npx prisma init scaffolds the newer prisma-client generator with an output path. This project uses the prisma-client-js generator, which installs the client into node_modules and is imported from @prisma/client. The datasource block no longer needs a url line because the connection URL is defined in prisma.config.ts instead.

  • Save prisma/schema.prisma.

Finally, React Flow ships its own CSS that must be imported after Tailwind CSS to avoid style conflicts.

  • Open app/globals.css and add the React Flow import on the line directly after the existing Tailwind import, so the file looks like this:
@import "tailwindcss";
@import "@xyflow/react/dist/style.css";

Why does import order matter?

Tailwind CSS 4 uses @import for its stylesheet. React Flow's CSS must come after the Tailwind import so that its styles load on top of Tailwind's reset. If you put it first, Tailwind will override React Flow's layout and the canvas will not render correctly.

  • Save app/globals.css.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files: prisma.config.ts, prisma/schema.prisma, and app/globals.css.

ⓧ I'd like to double check the full code

Here is each file in full for comparison.

import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
}
@import "tailwindcss";
@import "@xyflow/react/dist/style.css";

Now verify that Prisma can reach your Neon database.

  • Run the following command to test the connection:
npx prisma db pull
  • You should see output confirming the schema was loaded and the database was introspected. It may mention that the database is empty. That is expected because you have not created any tables yet.

Connection refused or authentication failed?

  • Open .env and confirm the DATABASE_URL value starts with postgresql:// and includes ?sslmode=require at the end.
  • Make sure you copied the full connection string from the Neon dashboard, including the password.
  • Confirm prisma.config.ts has import "dotenv/config" as its first line. Without it, Prisma cannot read your .env file.

Why can't Prisma connect to my Neon database?

Your environment is fully wired. Next up, you will build the interactive canvas where architecture components live.

Build the Interactive Canvas

Your environment is wired up and your database is ready. Now it's time to build the visual heart of your project.

In this step, you'll create an interactive canvas where architecture components can be dragged, dropped, and connected. This is the same kind of infinite canvas that tools like Miro and Figma use to let users build diagrams freely.

In this step, get ready to:
  • Build a full-viewport React Flow canvas with zoom, pan, and a grid background.
  • Create four custom node components representing architecture building blocks.
  • Add a draggable component panel sidebar for dropping nodes onto the canvas.
Create the base React Flow canvas page

React Flow needs a parent page to render inside. You'll set up the app layout and then build the main canvas page as a client component.

  • Replace the contents of app/layout.tsx with this code to set the page title and a dark background for the app:
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "System Architecture Visualizer",
  description: "Design and save system architecture diagrams",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-slate-950">{children}</body>
    </html>
  );
}

What does this code do?

  • The Metadata export sets the browser tab title and description for SEO.
  • The bg-slate-950 class gives the entire app a very dark background that matches the canvas theme.
  • Every page in your Next.js app renders inside this layout shell.
  • Save app/layout.tsx.
  • Now create app/page.tsx and add the imports, node type registration, and helper function at the top of the file:
"use client";

import { useState, useCallback, useRef, useEffect } from "react";
import {
  ReactFlow,
  Controls,
  Background,
  BackgroundVariant,
  applyNodeChanges,
  applyEdgeChanges,
  addEdge,
  type Node,
  type Edge,
  type OnNodesChange,
  type OnEdgesChange,
  type OnConnect,
  useReactFlow,
  ReactFlowProvider,
} from "@xyflow/react";
import ServiceNode from "@/components/nodes/ServiceNode";
import DatabaseNode from "@/components/nodes/DatabaseNode";
import QueueNode from "@/components/nodes/QueueNode";
import CacheNode from "@/components/nodes/CacheNode";
import ComponentPanel from "@/components/ComponentPanel";

// Register custom node types so React Flow knows how to render them
const nodeTypes = {
  service: ServiceNode,
  database: DatabaseNode,
  queue: QueueNode,
  cache: CacheNode,
};

// Default labels when a new node is dropped onto the canvas
const defaultLabels: Record<string, string> = {
  service: "Service",
  database: "Database",
  queue: "Queue",
  cache: "Cache",
};

// Generate unique IDs for each new node
let nodeId = 0;
function getNodeId() {
  return `node_${Date.now()}_${nodeId++}`;
}

What does this code do?

  • The "use client" directive marks this as a client component. React Flow needs browser APIs like drag events and DOM measurements, so it cannot run on the server.
  • The nodeTypes object maps type strings (like "service") to custom React components. React Flow uses this map to render the correct component for each node.
  • The getNodeId function creates unique IDs combining the current timestamp with an incrementing counter, ensuring no two nodes share an ID.
  • Below the getNodeId function in the same file, add the FlowCanvas component and the Home export:
function FlowCanvas() {
  const [nodes, setNodes] = useState<Node[]>([]);
  const [edges, setEdges] = useState<Edge[]>([]);
  const reactFlowWrapper = useRef<HTMLDivElement>(null);
  const { screenToFlowPosition } = useReactFlow();

  // Handle node position changes (dragging nodes around)
  const onNodesChange: OnNodesChange = useCallback(
    (changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
    []
  );

  // Handle edge changes (selecting/deleting edges)
  const onEdgesChange: OnEdgesChange = useCallback(
    (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
    []
  );

  // Handle new connections between nodes
  const onConnect: OnConnect = useCallback(
    (params) => setEdges((eds) => addEdge(params, eds)),
    []
  );

  // Allow dropping by preventing the default browser behavior
  const onDragOver = useCallback((event: React.DragEvent) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = "move";
  }, []);

  // Create a new node when a component is dropped onto the canvas
  const onDrop = useCallback(
    (event: React.DragEvent) => {
      event.preventDefault();
      const type = event.dataTransfer.getData("application/reactflow");
      if (!type) return;
      const position = screenToFlowPosition({
        x: event.clientX,
        y: event.clientY,
      });
      const newNode: Node = {
        id: getNodeId(),
        type,
        position,
        data: { label: defaultLabels[type] || "Node" },
      };
      setNodes((nds) => [...nds, newNode]);
    },
    [screenToFlowPosition]
  );

  return (
    <div className="h-screen flex flex-col bg-slate-950">
      <div className="flex flex-1 overflow-hidden">
        <ComponentPanel />
        <div className="flex-1" ref={reactFlowWrapper} onDragOver={onDragOver} onDrop={onDrop}>
          <ReactFlow
            nodes={nodes}
            edges={edges}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            onConnect={onConnect}
            nodeTypes={nodeTypes}
            fitView
            className="bg-slate-950"
          >
            <Controls className="!bg-slate-800 !border-slate-600 [&>button]:!bg-slate-700 [&>button]:!border-slate-600 [&>button]:!text-white" />
            <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#334155" />
          </ReactFlow>
        </div>
      </div>
    </div>
  );
}

export default function Home() {
  return (
    <ReactFlowProvider>
      <FlowCanvas />
    </ReactFlowProvider>
  );
}

What does this code do?

  • The FlowCanvas component manages the canvas state. It tracks all nodes and edges in React state using useState.
  • The three useCallback hooks (onNodesChange, onEdgesChange, onConnect) let React Flow update state when the user drags nodes, selects edges, or draws connections.
  • The onDrop handler reads the node type from the drag event, converts the screen pixel coordinates to canvas coordinates using screenToFlowPosition, and adds a new node at that position.
  • The ReactFlowProvider wrapper in Home is required so that hooks like useReactFlow can access the flow instance.
  • Save app/page.tsx.

Why won't the app compile yet?

The page imports components that don't exist yet (ServiceNode, DatabaseNode, QueueNode, CacheNode, ComponentPanel). You'll create these in the next two substeps, and the app will compile once all files are in place.

Create four custom architecture node components

Each architecture component type gets its own visual style. Custom nodes in React Flow are regular React components that receive data props and render connection points called Handles.

  • Create a components/nodes/ directory inside your project root.
  • Inside that directory, create ServiceNode.tsx with this code:
"use client";

import { Handle, Position } from "@xyflow/react";

interface ServiceNodeProps {
  data: { label: string };
}

export default function ServiceNode({ data }: ServiceNodeProps) {
  return (
    <div className="bg-slate-800 border-2 border-blue-500 rounded-lg px-4 py-3 min-w-[140px] shadow-lg">
      <Handle type="target" position={Position.Top} className="!bg-blue-400" />
      <div className="flex items-center gap-2">
        <div className="w-3 h-3 bg-blue-500 rounded-sm"></div>
        <span className="text-white text-sm font-medium">{data.label}</span>
      </div>
      <Handle type="source" position={Position.Bottom} className="!bg-blue-400" />
    </div>
  );
}

What does this code do?

  • The Handle components create connection points on the node. The target handle (top) receives incoming connections. The source handle (bottom) creates outgoing connections.
  • The blue border and accent color visually identify this as a "Service" type node on the canvas.
  • The data.label prop is passed by React Flow from the node's data object, letting each node display its own name.
  • Save ServiceNode.tsx.
  • Create DatabaseNode.tsx in the same components/nodes/ directory with this code:
"use client";

import { Handle, Position } from "@xyflow/react";

interface DatabaseNodeProps {
  data: { label: string };
}

export default function DatabaseNode({ data }: DatabaseNodeProps) {
  return (
    <div className="bg-slate-800 border-2 border-green-500 rounded-lg px-4 py-3 min-w-[140px] shadow-lg relative">
      <Handle type="target" position={Position.Top} className="!bg-green-400" />
      <div className="absolute top-0 left-0 right-0 h-2 bg-green-500/30 rounded-t-lg border-b border-green-500/50"></div>
      <div className="flex items-center gap-2 pt-1">
        <div className="w-3 h-3 bg-green-500 rounded-full"></div>
        <span className="text-white text-sm font-medium">{data.label}</span>
      </div>
      <Handle type="source" position={Position.Bottom} className="!bg-green-400" />
    </div>
  );
}

What's different about this node?

The green accent and a semi-transparent overlay stripe at the top give this a cylinder-like appearance, suggesting a database. The round icon dot reinforces that visual difference from the square Service icon.

  • Save DatabaseNode.tsx.
  • Create QueueNode.tsx in the same directory:
"use client";

import { Handle, Position } from "@xyflow/react";

interface QueueNodeProps {
  data: { label: string };
}

export default function QueueNode({ data }: QueueNodeProps) {
  return (
    <div className="bg-slate-800 border-2 border-orange-500 rounded-full px-5 py-3 min-w-[140px] shadow-lg">
      <Handle type="target" position={Position.Top} className="!bg-orange-400" />
      <div className="flex items-center gap-2 justify-center">
        <div className="w-3 h-3 bg-orange-500 rounded-full"></div>
        <span className="text-white text-sm font-medium">{data.label}</span>
      </div>
      <Handle type="source" position={Position.Bottom} className="!bg-orange-400" />
    </div>
  );
}

What's different about this node?

The rounded-full class creates a pill or capsule shape, which is the standard visual convention for message queues in architecture diagrams. The orange accent makes it distinct at a glance.

  • Save QueueNode.tsx.
  • Create the final node, CacheNode.tsx, in the same directory:
"use client";

import { Handle, Position } from "@xyflow/react";

interface CacheNodeProps {
  data: { label: string };
}

export default function CacheNode({ data }: CacheNodeProps) {
  return (
    <div className="bg-slate-800 border-2 border-purple-500 px-4 py-3 min-w-[140px] shadow-lg rotate-0 rounded-lg" style={{ clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)" }}>
      <Handle type="target" position={Position.Top} className="!bg-purple-400" />
      <div className="flex items-center gap-2 justify-center py-2">
        <div className="w-3 h-3 bg-purple-500 rounded-sm rotate-45"></div>
        <span className="text-white text-sm font-medium">{data.label}</span>
      </div>
      <Handle type="source" position={Position.Bottom} className="!bg-purple-400" />
    </div>
  );
}

What's different about this node?

The clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)" inline style clips the rectangle into a diamond shape. This is the standard visual convention for cache or decision nodes in architecture diagrams.

  • Save CacheNode.tsx.
  • Confirm all four node files exist by running:
ls components/nodes/

You should see ServiceNode.tsx, DatabaseNode.tsx, QueueNode.tsx, and CacheNode.tsx listed.

Missing a file?

Make sure you created the components/nodes/ directory (not components/node/ without the "s"). The import paths in app/page.tsx must match the folder name exactly.

Help me debug why my node components aren't being found.

Create the component panel sidebar

The component panel is a sidebar that lists the four node types as draggable items. When you drag one onto the canvas, the browser's drag-and-drop API transfers the node type string to the canvas drop handler.

  • Create components/ComponentPanel.tsx with this code:
"use client";

interface ComponentPanelProps {
  className?: string;
}

// Define the four architecture component types with their visual styles
const nodeTypes = [
  { type: "service", label: "Service", color: "border-blue-500", icon: "bg-blue-500" },
  { type: "database", label: "Database", color: "border-green-500", icon: "bg-green-500" },
  { type: "queue", label: "Queue", color: "border-orange-500", icon: "bg-orange-500" },
  { type: "cache", label: "Cache", color: "border-purple-500", icon: "bg-purple-500" },
];

export default function ComponentPanel({ className }: ComponentPanelProps) {
  // Store the node type in the drag event so the canvas knows what was dropped
  const onDragStart = (event: React.DragEvent, nodeType: string) => {
    event.dataTransfer.setData("application/reactflow", nodeType);
    event.dataTransfer.effectAllowed = "move";
  };

  return (
    <aside className={`bg-slate-900 border-r border-slate-700 p-4 w-56 ${className}`}>
      <h2 className="text-white text-sm font-semibold mb-4 uppercase tracking-wider">Components</h2>
      <div className="space-y-3">
        {nodeTypes.map((item) => (
          <div
            key={item.type}
            className={`bg-slate-800 border-2 ${item.color} rounded-lg px-3 py-2 cursor-grab active:cursor-grabbing flex items-center gap-2`}
            draggable
            onDragStart={(e) => onDragStart(e, item.type)}
          >
            <div className={`w-3 h-3 ${item.icon} rounded-sm`}></div>
            <span className="text-white text-sm">{item.label}</span>
          </div>
        ))}
      </div>
    </aside>
  );
}

What does this code do?

  • The onDragStart function stores the node type (like "service" or "database") in the browser's dataTransfer object. When the item is dropped on the canvas, the onDrop handler in page.tsx reads this string to know which node type to create.
  • The draggable attribute enables native browser drag-and-drop on each panel item.
  • The cursor-grab and active:cursor-grabbing classes give the user clear visual feedback that these items are draggable.
  • Save components/ComponentPanel.tsx.
  • Start your dev server by running:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see a dark canvas filling the screen with a dotted grid background, zoom controls in the bottom-left corner, and a sidebar panel on the left listing Service, Database, Queue, and Cache as colored draggable items.

  • Drag a "Service" item from the sidebar and drop it onto the canvas.
  • Drag a "Database" item onto the canvas nearby.
  • Hover over the blue dot at the bottom of the Service node, then click and drag to the green dot at the top of the Database node to create a connection.

You should see a line (edge) connecting the two nodes. You can drag nodes around the canvas, zoom with your scroll wheel, and pan by clicking on empty space.

Don't see the canvas or sidebar?

  • Check your terminal for compilation errors. The most common issue is a typo in an import path (e.g. @/components/nodes/ServiceNode must match the file path exactly, including capitalization).
  • Make sure app/globals.css still has @import "@xyflow/react/dist/style.css" after the Tailwind import. Without this, React Flow renders with zero height.
  • Verify that all component files have "use client" at the top. Server components cannot use browser events.

Help me debug why my React Flow canvas isn't displaying correctly.

✔️ Awesome, I've got everything!

Great. Make sure you've saved all your files before continuing.

ⓧ I'd like to double check the full code

Here is the full code for each file at this point in the project.

import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "System Architecture Visualizer",
  description: "Design and save system architecture diagrams",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-slate-950">{children}</body>
    </html>
  );
}
"use client";

import { useState, useCallback, useRef, useEffect } from "react";
import {
  ReactFlow,
  Controls,
  Background,
  BackgroundVariant,
  applyNodeChanges,
  applyEdgeChanges,
  addEdge,
  type Node,
  type Edge,
  type OnNodesChange,
  type OnEdgesChange,
  type OnConnect,
  useReactFlow,
  ReactFlowProvider,
} from "@xyflow/react";
import ServiceNode from "@/components/nodes/ServiceNode";
import DatabaseNode from "@/components/nodes/DatabaseNode";
import QueueNode from "@/components/nodes/QueueNode";
import CacheNode from "@/components/nodes/CacheNode";
import ComponentPanel from "@/components/ComponentPanel";

const nodeTypes = {
  service: ServiceNode,
  database: DatabaseNode,
  queue: QueueNode,
  cache: CacheNode,
};

const defaultLabels: Record<string, string> = {
  service: "Service",
  database: "Database",
  queue: "Queue",
  cache: "Cache",
};

let nodeId = 0;
function getNodeId() {
  return `node_${Date.now()}_${nodeId++}`;
}

function FlowCanvas() {
  const [nodes, setNodes] = useState<Node[]>([]);
  const [edges, setEdges] = useState<Edge[]>([]);
  const reactFlowWrapper = useRef<HTMLDivElement>(null);
  const { screenToFlowPosition } = useReactFlow();

  const onNodesChange: OnNodesChange = useCallback(
    (changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
    []
  );

  const onEdgesChange: OnEdgesChange = useCallback(
    (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
    []
  );

  const onConnect: OnConnect = useCallback(
    (params) => setEdges((eds) => addEdge(params, eds)),
    []
  );

  const onDragOver = useCallback((event: React.DragEvent) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = "move";
  }, []);

  const onDrop = useCallback(
    (event: React.DragEvent) => {
      event.preventDefault();
      const type = event.dataTransfer.getData("application/reactflow");
      if (!type) return;
      const position = screenToFlowPosition({
        x: event.clientX,
        y: event.clientY,
      });
      const newNode: Node = {
        id: getNodeId(),
        type,
        position,
        data: { label: defaultLabels[type] || "Node" },
      };
      setNodes((nds) => [...nds, newNode]);
    },
    [screenToFlowPosition]
  );

  return (
    <div className="h-screen flex flex-col bg-slate-950">
      <div className="flex flex-1 overflow-hidden">
        <ComponentPanel />
        <div className="flex-1" ref={reactFlowWrapper} onDragOver={onDragOver} onDrop={onDrop}>
          <ReactFlow
            nodes={nodes}
            edges={edges}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            onConnect={onConnect}
            nodeTypes={nodeTypes}
            fitView
            className="bg-slate-950"
          >
            <Controls className="!bg-slate-800 !border-slate-600 [&>button]:!bg-slate-700 [&>button]:!border-slate-600 [&>button]:!text-white" />
            <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#334155" />
          </ReactFlow>
        </div>
      </div>
    </div>
  );
}

export default function Home() {
  return (
    <ReactFlowProvider>
      <FlowCanvas />
    </ReactFlowProvider>
  );
}
"use client";

import { Handle, Position } from "@xyflow/react";

interface ServiceNodeProps {
  data: { label: string };
}

export default function ServiceNode({ data }: ServiceNodeProps) {
  return (
    <div className="bg-slate-800 border-2 border-blue-500 rounded-lg px-4 py-3 min-w-[140px] shadow-lg">
      <Handle type="target" position={Position.Top} className="!bg-blue-400" />
      <div className="flex items-center gap-2">
        <div className="w-3 h-3 bg-blue-500 rounded-sm"></div>
        <span className="text-white text-sm font-medium">{data.label}</span>
      </div>
      <Handle type="source" position={Position.Bottom} className="!bg-blue-400" />
    </div>
  );
}
"use client";

import { Handle, Position } from "@xyflow/react";

interface DatabaseNodeProps {
  data: { label: string };
}

export default function DatabaseNode({ data }: DatabaseNodeProps) {
  return (
    <div className="bg-slate-800 border-2 border-green-500 rounded-lg px-4 py-3 min-w-[140px] shadow-lg relative">
      <Handle type="target" position={Position.Top} className="!bg-green-400" />
      <div className="absolute top-0 left-0 right-0 h-2 bg-green-500/30 rounded-t-lg border-b border-green-500/50"></div>
      <div className="flex items-center gap-2 pt-1">
        <div className="w-3 h-3 bg-green-500 rounded-full"></div>
        <span className="text-white text-sm font-medium">{data.label}</span>
      </div>
      <Handle type="source" position={Position.Bottom} className="!bg-green-400" />
    </div>
  );
}
"use client";

import { Handle, Position } from "@xyflow/react";

interface QueueNodeProps {
  data: { label: string };
}

export default function QueueNode({ data }: QueueNodeProps) {
  return (
    <div className="bg-slate-800 border-2 border-orange-500 rounded-full px-5 py-3 min-w-[140px] shadow-lg">
      <Handle type="target" position={Position.Top} className="!bg-orange-400" />
      <div className="flex items-center gap-2 justify-center">
        <div className="w-3 h-3 bg-orange-500 rounded-full"></div>
        <span className="text-white text-sm font-medium">{data.label}</span>
      </div>
      <Handle type="source" position={Position.Bottom} className="!bg-orange-400" />
    </div>
  );
}
"use client";

import { Handle, Position } from "@xyflow/react";

interface CacheNodeProps {
  data: { label: string };
}

export default function CacheNode({ data }: CacheNodeProps) {
  return (
    <div className="bg-slate-800 border-2 border-purple-500 px-4 py-3 min-w-[140px] shadow-lg rotate-0 rounded-lg" style={{ clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)" }}>
      <Handle type="target" position={Position.Top} className="!bg-purple-400" />
      <div className="flex items-center gap-2 justify-center py-2">
        <div className="w-3 h-3 bg-purple-500 rounded-sm rotate-45"></div>
        <span className="text-white text-sm font-medium">{data.label}</span>
      </div>
      <Handle type="source" position={Position.Bottom} className="!bg-purple-400" />
    </div>
  );
}
"use client";

interface ComponentPanelProps {
  className?: string;
}

const nodeTypes = [
  { type: "service", label: "Service", color: "border-blue-500", icon: "bg-blue-500" },
  { type: "database", label: "Database", color: "border-green-500", icon: "bg-green-500" },
  { type: "queue", label: "Queue", color: "border-orange-500", icon: "bg-orange-500" },
  { type: "cache", label: "Cache", color: "border-purple-500", icon: "bg-purple-500" },
];

export default function ComponentPanel({ className }: ComponentPanelProps) {
  const onDragStart = (event: React.DragEvent, nodeType: string) => {
    event.dataTransfer.setData("application/reactflow", nodeType);
    event.dataTransfer.effectAllowed = "move";
  };

  return (
    <aside className={`bg-slate-900 border-r border-slate-700 p-4 w-56 ${className}`}>
      <h2 className="text-white text-sm font-semibold mb-4 uppercase tracking-wider">Components</h2>
      <div className="space-y-3">
        {nodeTypes.map((item) => (
          <div
            key={item.type}
            className={`bg-slate-800 border-2 ${item.color} rounded-lg px-3 py-2 cursor-grab active:cursor-grabbing flex items-center gap-2`}
            draggable
            onDragStart={(e) => onDragStart(e, item.type)}
          >
            <div className={`w-3 h-3 ${item.icon} rounded-sm`}></div>
            <span className="text-white text-sm">{item.label}</span>
          </div>
        ))}
      </div>
    </aside>
  );
}

Your interactive canvas is alive. You can build architecture diagrams by dragging components and wiring them together. Next up, you'll set up the database schema so these diagrams can be saved and loaded.

Set Up the Database Schema

Your interactive canvas lets you drag, drop, and connect architecture components. But the moment you close the browser, everything disappears.

To make diagrams persistent, you need a database. In this step, you will define a Prisma schema that tells your Neon PostgreSQL database how to store diagrams, then create a reusable client that your API routes can use to read and write data.

In this step, get ready to:
  • Define a Diagram model in your Prisma schema.
  • Run a database migration to create the table in Neon.
  • Create a singleton Prisma Client for your Next.js app.
Define the Prisma schema for diagrams

A Prisma schema defines the shape of your database tables using a declarative syntax. You need a single Diagram model that stores a diagram's name plus its nodes and edges as JSON.

  • In VS Code, open prisma/schema.prisma.
  • Below the existing datasource db block, add the Diagram model by pasting the following code:
model Diagram {
  id        String   @id @default(uuid())
  name      String
  nodes     Json
  edges     Json
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

What does this model do?

  • id is a UUID string that uniquely identifies each saved diagram.
  • nodes and edges use the Json type to store entire React Flow state arrays directly in the database.
  • updatedAt automatically refreshes its timestamp whenever a diagram is saved.
  • Save prisma/schema.prisma.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
}

model Diagram {
  id        String   @id @default(uuid())
  name      String
  nodes     Json
  edges     Json
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
Run the migration and generate the Prisma Client

A migration takes the model you defined in code and creates the matching table in your live database. It also generates a typed Prisma Client you can import in your app.

  • Run the migration by entering this command in your terminal:
npx prisma migrate dev --name init

What does this command do?

  • npx prisma migrate dev compares your schema to the current database state, creates a SQL migration file, and runs it against your Neon database.
  • --name init gives this migration a descriptive name so you can identify it later in prisma/migrations/.
  • After migrating, Prisma automatically generates a typed client you can import as @prisma/client.
  • You should see output confirming the migration was applied successfully and the client was generated.

Seeing a connection error?

  • Double check the DATABASE_URL in your .env file matches the connection string from your Neon dashboard exactly.
  • Make sure there are no extra spaces or line breaks inside the connection string.
  • Confirm your Neon project is active (not deleted) in the Neon console.

Help me fix a Prisma migration connection error with Neon PostgreSQL.

Next, you need a reusable Prisma Client instance. In a Next.js development environment, hot module reloading can create multiple database connections if you instantiate a new client on every reload. A singleton pattern avoids that problem.

  • Create a new folder called lib in the root of your project.
  • Create a file called prisma.ts inside the lib folder.
  • Add the Prisma Client singleton by pasting this code:
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";

// Read the database connection string from environment variables
const connectionString = process.env.DATABASE_URL!;

// Create a PostgreSQL adapter for Prisma
const adapter = new PrismaPg({ connectionString });

// Cache the client on the global object to survive hot reloads
const globalForPrisma = global as unknown as { prisma: PrismaClient };

const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({ adapter });

// In development, attach to global so the next hot reload reuses this instance
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export default prisma;

What does this code do?

  • PrismaPg is a driver adapter that connects Prisma to PostgreSQL using standard TCP. This is the recommended approach for a Node.js Next.js server.
  • The globalForPrisma pattern stores the client on Node's global object. When Next.js hot-reloads in development, it reuses the existing connection instead of opening a new one every time.
  • In production, a fresh client is created once when the server starts. No global caching is needed because there is no hot reload.
  • Save lib/prisma.ts.

TypeScript error on PrismaPg?

  • Make sure you installed @prisma/adapter-pg in Step 1. Run npm install @prisma/adapter-pg if it is missing from your package.json.
  • Confirm that the migration step above completed successfully. The npx prisma migrate dev command generates the typed client that @prisma/client exports.

Help me fix a TypeScript import error with PrismaPg in my Next.js project.

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/prisma.ts before continuing.

ⓧ I'd like to double check the full code

import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";

const connectionString = process.env.DATABASE_URL!;

const adapter = new PrismaPg({ connectionString });

const globalForPrisma = global as unknown as { prisma: PrismaClient };

const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({ adapter });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export default prisma;

Now verify that everything is connected end-to-end by launching Prisma Studio.

  • Open Prisma Studio by running this command:
npx prisma studio
  • A browser window opens at http://localhost:5555. You should see your Diagram table listed in the left sidebar with zero records.
  • Press Ctrl+C in your terminal to stop Prisma Studio when you are done inspecting.

Don't see the Diagram table?

  • Make sure the npx prisma migrate dev --name init command completed without errors. If it failed, fix the connection issue and re-run it.
  • Prisma Studio reads directly from your live database. If the migration did not run, the table does not exist yet.

Help me troubleshoot why Prisma Studio is not showing my Diagram table.

Your database is ready to accept diagrams. Next up, you will build API routes that read and write to this table so the frontend canvas can save its state.

Build the API Routes

Your database schema is ready and connected to Neon. Now you need a way for the frontend canvas to talk to the database.

In this step, you will build API routes that sit between the React Flow canvas and the Prisma database layer. These routes handle creating, reading, updating, and deleting diagrams.

In this step, get ready to:
  • Create a collection route for listing and creating diagrams.
  • Create a dynamic route for loading, updating, and deleting individual diagrams.
  • Verify the API connects to your Neon database.
Create the save diagram API route

Next.js uses file-based routing for API endpoints. A file named route.ts inside the app/api/ directory becomes a callable endpoint that your frontend can fetch data from.

  • Create a new folder path app/api/diagrams/ in your project.
  • Inside that folder, create a file called route.ts.
  • Add the imports and the GET handler by pasting the following code:
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";

export async function GET() {
  const diagrams = await prisma.diagram.findMany({
    select: { id: true, name: true },
    orderBy: { updatedAt: "desc" },
  });
  return NextResponse.json(diagrams);
}

What does this code do?

The GET function runs whenever someone sends a GET request to /api/diagrams. It uses Prisma to query all diagrams from your Neon database, returning only the id and name fields.

The orderBy: { updatedAt: "desc" } ensures the most recently edited diagram appears first in the list.

  • You won't see a change yet. The next chunk adds the POST handler that creates new diagrams.
  • Add the POST handler below the GET function in the same file:
export async function POST(request: Request) {
  const body = await request.json();
  const { name, nodes, edges } = body;

  const diagram = await prisma.diagram.create({
    data: {
      name: name || "Untitled Architecture",
      nodes: nodes || [],
      edges: edges || [],
    },
  });

  return NextResponse.json(diagram, { status: 201 });
}

What does this code do?

The POST function runs when the frontend sends a POST request to save a new diagram. It reads the request body for name, nodes, and edges, then creates a new record in the database.

The status: 201 tells the client that a resource was successfully created. If no name is provided, it defaults to "Untitled Architecture".

  • Save app/api/diagrams/route.ts.
  • Open your browser and navigate to http://localhost:3000/api/diagrams. You should see an empty array [] displayed on the page.

Seeing an error instead of an empty array?

  • Check that your dev server is still running. If it stopped, run npm run dev in your terminal again.
  • Make sure the DATABASE_URL in your .env file is correct. A wrong connection string causes a database connection error.
  • Verify the import path @/lib/prisma matches where your Prisma singleton lives. The file should be at lib/prisma.ts in your project root.

Help me debug my API route connection error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/api/diagrams/route.ts before moving on.

ⓧ I'd like to double check the full code

import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";

export async function GET() {
  const diagrams = await prisma.diagram.findMany({
    select: { id: true, name: true },
    orderBy: { updatedAt: "desc" },
  });
  return NextResponse.json(diagrams);
}

export async function POST(request: Request) {
  const body = await request.json();
  const { name, nodes, edges } = body;

  const diagram = await prisma.diagram.create({
    data: {
      name: name || "Untitled Architecture",
      nodes: nodes || [],
      edges: edges || [],
    },
  });

  return NextResponse.json(diagram, { status: 201 });
}
Create the load and update API routes

The collection route handles listing all diagrams and creating new ones. Now you need a dynamic route that handles operations on a single diagram by its ID.

What is a dynamic route segment?

In Next.js, wrapping a folder name in square brackets like [id] creates a dynamic segment. A request to /api/diagrams/abc123 matches the [id] route, and the value abc123 becomes accessible through the params object.

  • Inside your app/api/diagrams/ folder, create a new folder called [id].
  • Inside app/api/diagrams/[id]/, create a file called route.ts.
  • Add the imports and the GET handler for fetching a single diagram:
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const diagram = await prisma.diagram.findUnique({ where: { id } });

  if (!diagram) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  return NextResponse.json(diagram);
}

What does this code do?

This GET handler fetches a single diagram from the database using its id. If no diagram exists with that ID, it returns a 404 status so the frontend knows the diagram was not found.

Notice that params is typed as a Promise and must be awaited. This is how Next.js handles dynamic route parameters in the App Router.

  • You won't see a change yet. The next chunk adds the PUT handler for updating diagrams.
  • Add the PUT handler below the GET function in the same file:
export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();
  const { name, nodes, edges } = body;

  const diagram = await prisma.diagram.update({
    where: { id },
    data: { name, nodes, edges },
  });

  return NextResponse.json(diagram);
}

What does this code do?

The PUT handler updates an existing diagram record. When the user clicks Save on a diagram they have already saved before, the frontend will send a PUT request with the updated name, nodes, and edges to this endpoint.

  • Add the DELETE handler below the PUT function to complete the file:
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  await prisma.diagram.delete({ where: { id } });
  return new NextResponse(null, { status: 204 });
}

What does this code do?

The DELETE handler removes a diagram from the database permanently. It returns a 204 status with no body, which is the standard HTTP response for a successful deletion with no content to return.

  • Save app/api/diagrams/[id]/route.ts.
  • Return to your browser and refresh http://localhost:3000/api/diagrams. You should still see [] confirming both route files work without errors.

Seeing an error after adding the dynamic route?

  • Make sure the folder is named exactly [id] with square brackets. Without the brackets, Next.js treats it as a static route segment.
  • Confirm that the route.ts file is inside app/api/diagrams/[id]/, not at the same level as the collection route.ts.
  • Check for any TypeScript errors in your terminal. A missing await on params causes type errors in Next.js.

Help me debug my dynamic API route.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/api/diagrams/[id]/route.ts before moving on.

ⓧ I'd like to double check the full code

import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const diagram = await prisma.diagram.findUnique({ where: { id } });

  if (!diagram) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  return NextResponse.json(diagram);
}

export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const body = await request.json();
  const { name, nodes, edges } = body;

  const diagram = await prisma.diagram.update({
    where: { id },
    data: { name, nodes, edges },
  });

  return NextResponse.json(diagram);
}

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  await prisma.diagram.delete({ where: { id } });
  return new NextResponse(null, { status: 204 });
}

What is a RESTful API?

You just built a REST API. REST organizes endpoints around resources (in this case, diagrams) and uses HTTP methods to define actions: GET to read, POST to create, PUT to update, and DELETE to remove.

This is the same pattern used by production apps. Your frontend will call these endpoints to persist canvas state to the database.

Your API layer is complete. Next up, you will wire the canvas frontend to these endpoints so diagrams save and load from the database.

Wire the Frontend to the Backend

You have a canvas full of draggable architecture nodes and a database ready to store them. The missing link is the connection between the two.

In this step, you will build a toolbar component with Save, Load, and New buttons that talk to your API routes. By the end, your diagrams will persist across page refreshes.

In this step, get ready to:
  • Create a Toolbar component with Save, Load, and New buttons.
  • Write save, load, and new diagram functions that call your API routes.
  • Add auto-load on refresh so diagrams persist across browser sessions.
Create the Toolbar component

The toolbar sits at the top of the canvas and gives the user controls for naming, saving, loading, and creating diagrams. It receives callback functions as props so it stays decoupled from the canvas state logic.

  • Create a new file called components/Toolbar.tsx and paste the following code:
"use client";

import { useState, useEffect } from "react";

interface DiagramListItem {
  id: string;
  name: string;
}

interface ToolbarProps {
  diagramName: string;
  onDiagramNameChange: (name: string) => void;
  onSave: () => Promise<void>;
  onLoad: (id: string) => Promise<void>;
  onNew: () => void;
  currentDiagramId: string | null;
}

export default function Toolbar({
  diagramName,
  onDiagramNameChange,
  onSave,
  onLoad,
  onNew,
  currentDiagramId,
}: ToolbarProps) {
  const [diagrams, setDiagrams] = useState<DiagramListItem[]>([]);
  const [saving, setSaving] = useState(false);
  const [loadingList, setLoadingList] = useState(false);

  const fetchDiagrams = async () => {
    setLoadingList(true);
    try {
      const res = await fetch("/api/diagrams");
      if (res.ok) {
        const data = await res.json();
        setDiagrams(data);
      }
    } finally {
      setLoadingList(false);
    }
  };

  useEffect(() => {
    fetchDiagrams();
  }, []);

  const handleSave = async () => {
    setSaving(true);
    try {
      await onSave();
      await fetchDiagrams();
    } finally {
      setSaving(false);
    }
  };

  return (
    <div className="bg-slate-900 border-b border-slate-700 px-4 py-2 flex items-center gap-4">
      <input
        type="text"
        value={diagramName}
        onChange={(e) => onDiagramNameChange(e.target.value)}
        className="bg-slate-800 border border-slate-600 rounded px-3 py-1 text-white text-sm w-56 focus:outline-none focus:border-blue-500"
        placeholder="Diagram name"
      />
      <button
        onClick={handleSave}
        disabled={saving}
        className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 text-white text-sm px-4 py-1.5 rounded font-medium"
      >
        {saving ? "Saving..." : "Save"}
      </button>
      <button
        onClick={onNew}
        className="bg-slate-700 hover:bg-slate-600 text-white text-sm px-4 py-1.5 rounded font-medium"
      >
        New
      </button>
      <div className="flex items-center gap-2">
        <select
          onChange={(e) => {
            if (e.target.value) onLoad(e.target.value);
          }}
          value={currentDiagramId || ""}
          className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-white text-sm focus:outline-none focus:border-blue-500"
        >
          <option value="">
            {loadingList ? "Loading..." : "Load diagram..."}
          </option>
          {diagrams.map((d) => (
            <option key={d.id} value={d.id}>
              {d.name}
            </option>
          ))}
        </select>
      </div>
    </div>
  );
}

What does this component do?

The Toolbar receives all its behavior through props. It does not directly manage canvas state.

  • fetchDiagrams calls GET /api/diagrams on mount to populate the Load dropdown.
  • handleSave toggles a loading state, delegates the actual save logic to the parent via onSave, then refreshes the diagram list.
  • The select dropdown calls onLoad with the selected diagram's ID whenever the user picks one.
  • Save components/Toolbar.tsx.

Seeing a TypeScript error in Toolbar.tsx?

If you see a red underline on the props interface, make sure you have the "use client" directive at the very top of the file. Client components need this directive to use hooks like useState and useEffect.

Help me debug a TypeScript error in my Toolbar component

Integrate the toolbar with the canvas state

The Toolbar handles UI. Now you need the actual logic that serializes canvas state, sends it to the API, and hydrates the canvas when loading a diagram.

  • In app/page.tsx, find the existing import for ComponentPanel:
import ComponentPanel from "@/components/ComponentPanel";
  • Add this import directly below it:
import Toolbar from "@/components/Toolbar";
  • Next, find the two existing state declarations inside the FlowCanvas function:
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
  • Add these two new state variables directly below them:
const [diagramName, setDiagramName] = useState("Untitled Architecture");
const [currentDiagramId, setCurrentDiagramId] = useState<string | null>(null);

Why track currentDiagramId?

This ID determines whether Save creates a new diagram (POST) or updates an existing one (PUT). When currentDiagramId is null, the next save creates a fresh record. Once saved, it stores the returned ID so future saves update in place.

  • Now add the saveDiagram function after the onDrop callback. Copy this code below the closing of onDrop:
  const saveDiagram = async () => {
    const body = { name: diagramName, nodes, edges };

    if (currentDiagramId) {
      // Update the existing diagram
      const res = await fetch(`/api/diagrams/${currentDiagramId}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error("Failed to save");
    } else {
      // Create a new diagram
      const res = await fetch("/api/diagrams", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error("Failed to save");
      const data = await res.json();
      setCurrentDiagramId(data.id);
      // Store the ID in the URL so refresh reloads this diagram
      const url = new URL(window.location.href);
      url.searchParams.set("id", data.id);
      window.history.replaceState({}, "", url.toString());
    }
  };

What does saveDiagram do?

  • If currentDiagramId exists, it sends a PUT request to update the existing record.
  • If there is no ID yet, it sends a POST to create a new diagram and stores the returned ID.
  • After creating, it updates the browser URL with an id query param so the page knows which diagram to reload.
  • Add the loadDiagram and newDiagram functions directly below saveDiagram:
  const loadDiagram = async (id: string) => {
    const res = await fetch(`/api/diagrams/${id}`);
    if (!res.ok) return;
    const data = await res.json();
    // Replace canvas state with the loaded diagram
    setNodes(data.nodes as Node[]);
    setEdges(data.edges as Edge[]);
    setDiagramName(data.name);
    setCurrentDiagramId(data.id);
    // Update URL so refresh reloads this diagram
    const url = new URL(window.location.href);
    url.searchParams.set("id", data.id);
    window.history.replaceState({}, "", url.toString());
  };

  const newDiagram = () => {
    // Reset all state to a blank canvas
    setNodes([]);
    setEdges([]);
    setDiagramName("Untitled Architecture");
    setCurrentDiagramId(null);
    // Remove the ID from the URL
    const url = new URL(window.location.href);
    url.searchParams.delete("id");
    window.history.replaceState({}, "", url.toString());
  };

What do these functions do?

  • loadDiagram fetches a single diagram by ID, then replaces the current nodes, edges, name, and ID state with the loaded data.
  • newDiagram clears the canvas completely and resets the URL, giving the user a blank slate.
  • Both functions update the URL search params so the browser always reflects the current diagram state.
  • Save app/page.tsx.

Seeing a red underline on the function names?

If TypeScript complains about diagramName or currentDiagramId not being defined, make sure you added both new useState lines from the previous section. These functions rely on the state you just declared.

Help me fix TypeScript errors in my page.tsx save/load functions

Add auto-load on refresh and wire the Toolbar into the JSX

The final piece is an effect that reads the diagram ID from the URL when the page loads. This means a saved diagram reappears automatically after a refresh.

  • In app/page.tsx, find the reactFlowWrapper ref line:
const reactFlowWrapper = useRef<HTMLDivElement>(null);
  • Add this useEffect directly after the screenToFlowPosition line (below const { screenToFlowPosition } = useReactFlow();):
  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const id = params.get("id");
    if (id) {
      loadDiagram(id);
    }
  }, []);

What does this effect do?

On mount, it checks the URL for an id query parameter. If one exists (because the user saved a diagram earlier), it calls loadDiagram to fetch and display that diagram automatically.

  • Finally, update the return statement of the FlowCanvas function. Find the opening <div className="h-screen flex flex-col bg-slate-950"> line and add the Toolbar component directly inside it, above the existing flex container:
  return (
    <div className="h-screen flex flex-col bg-slate-950">
      <Toolbar
        diagramName={diagramName}
        onDiagramNameChange={setDiagramName}
        onSave={saveDiagram}
        onLoad={loadDiagram}
        onNew={newDiagram}
        currentDiagramId={currentDiagramId}
      />
      <div className="flex flex-1 overflow-hidden">
        <ComponentPanel />
        <div className="flex-1" ref={reactFlowWrapper} onDragOver={onDragOver} onDrop={onDrop}>
          <ReactFlow
            nodes={nodes}
            edges={edges}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            onConnect={onConnect}
            nodeTypes={nodeTypes}
            fitView
            className="bg-slate-950"
          >
            <Controls className="!bg-slate-800 !border-slate-600 [&>button]:!bg-slate-700 [&>button]:!border-slate-600 [&>button]:!text-white" />
            <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#334155" />
          </ReactFlow>
        </div>
      </div>
    </div>
  );

What does this JSX do?

The Toolbar component is placed above the flex container that holds the sidebar and canvas. It receives all the state and functions as props, keeping the data flow one-directional. The Toolbar calls the parent's functions. The parent manages all state.

  • Save app/page.tsx.
  • Return to your browser. You should see a dark toolbar at the top of the page with a text input reading Untitled Architecture, a blue Save button, a New button, and a Load diagram... dropdown.

Test the full data flow now:

  • Drag a few components onto the canvas and connect them with edges.
  • Type a name like My First Architecture in the name input.
  • Click Save. The button should briefly show Saving... then return to Save.
  • Refresh the page. Your diagram should reload automatically from the database.
  • Click New to get a blank canvas. Create a second diagram, save it, then use the Load diagram... dropdown to switch between your two saved diagrams.

Save button does nothing or shows an error?

  • Open DevTools (F12) and check the Console and Network tabs. A 500 error from /api/diagrams usually means your DATABASE_URL in .env is incorrect or the Neon database is not reachable.
  • If you see a 404 on /api/diagrams, confirm your app/api/diagrams/route.ts file exists in exactly that path.
  • If the diagram disappears on refresh, check that your URL now includes ?id= followed by a UUID after you save.

Help me debug why my Save button is not persisting diagrams to the database

✔️ Awesome, I've got everything!

Great. Double check you have saved both components/Toolbar.tsx and app/page.tsx.

ⓧ I'd like to double check the full code

Here is the complete components/Toolbar.tsx:

"use client";

import { useState, useEffect } from "react";

interface DiagramListItem {
  id: string;
  name: string;
}

interface ToolbarProps {
  diagramName: string;
  onDiagramNameChange: (name: string) => void;
  onSave: () => Promise<void>;
  onLoad: (id: string) => Promise<void>;
  onNew: () => void;
  currentDiagramId: string | null;
}

export default function Toolbar({
  diagramName,
  onDiagramNameChange,
  onSave,
  onLoad,
  onNew,
  currentDiagramId,
}: ToolbarProps) {
  const [diagrams, setDiagrams] = useState<DiagramListItem[]>([]);
  const [saving, setSaving] = useState(false);
  const [loadingList, setLoadingList] = useState(false);

  const fetchDiagrams = async () => {
    setLoadingList(true);
    try {
      const res = await fetch("/api/diagrams");
      if (res.ok) {
        const data = await res.json();
        setDiagrams(data);
      }
    } finally {
      setLoadingList(false);
    }
  };

  useEffect(() => {
    fetchDiagrams();
  }, []);

  const handleSave = async () => {
    setSaving(true);
    try {
      await onSave();
      await fetchDiagrams();
    } finally {
      setSaving(false);
    }
  };

  return (
    <div className="bg-slate-900 border-b border-slate-700 px-4 py-2 flex items-center gap-4">
      <input
        type="text"
        value={diagramName}
        onChange={(e) => onDiagramNameChange(e.target.value)}
        className="bg-slate-800 border border-slate-600 rounded px-3 py-1 text-white text-sm w-56 focus:outline-none focus:border-blue-500"
        placeholder="Diagram name"
      />
      <button
        onClick={handleSave}
        disabled={saving}
        className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 text-white text-sm px-4 py-1.5 rounded font-medium"
      >
        {saving ? "Saving..." : "Save"}
      </button>
      <button
        onClick={onNew}
        className="bg-slate-700 hover:bg-slate-600 text-white text-sm px-4 py-1.5 rounded font-medium"
      >
        New
      </button>
      <div className="flex items-center gap-2">
        <select
          onChange={(e) => {
            if (e.target.value) onLoad(e.target.value);
          }}
          value={currentDiagramId || ""}
          className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-white text-sm focus:outline-none focus:border-blue-500"
        >
          <option value="">
            {loadingList ? "Loading..." : "Load diagram..."}
          </option>
          {diagrams.map((d) => (
            <option key={d.id} value={d.id}>
              {d.name}
            </option>
          ))}
        </select>
      </div>
    </div>
  );
}

And here is the complete app/page.tsx:

"use client";

import { useState, useCallback, useRef, useEffect } from "react";
import {
  ReactFlow,
  Controls,
  Background,
  BackgroundVariant,
  applyNodeChanges,
  applyEdgeChanges,
  addEdge,
  type Node,
  type Edge,
  type OnNodesChange,
  type OnEdgesChange,
  type OnConnect,
  useReactFlow,
  ReactFlowProvider,
} from "@xyflow/react";
import ServiceNode from "@/components/nodes/ServiceNode";
import DatabaseNode from "@/components/nodes/DatabaseNode";
import QueueNode from "@/components/nodes/QueueNode";
import CacheNode from "@/components/nodes/CacheNode";
import ComponentPanel from "@/components/ComponentPanel";
import Toolbar from "@/components/Toolbar";

const nodeTypes = {
  service: ServiceNode,
  database: DatabaseNode,
  queue: QueueNode,
  cache: CacheNode,
};

const defaultLabels: Record<string, string> = {
  service: "Service",
  database: "Database",
  queue: "Queue",
  cache: "Cache",
};

let nodeId = 0;
function getNodeId() {
  return `node_${Date.now()}_${nodeId++}`;
}

function FlowCanvas() {
  const [nodes, setNodes] = useState<Node[]>([]);
  const [edges, setEdges] = useState<Edge[]>([]);
  const [diagramName, setDiagramName] = useState("Untitled Architecture");
  const [currentDiagramId, setCurrentDiagramId] = useState<string | null>(null);
  const reactFlowWrapper = useRef<HTMLDivElement>(null);
  const { screenToFlowPosition } = useReactFlow();

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const id = params.get("id");
    if (id) {
      loadDiagram(id);
    }
  }, []);

  const onNodesChange: OnNodesChange = useCallback(
    (changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
    []
  );

  const onEdgesChange: OnEdgesChange = useCallback(
    (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
    []
  );

  const onConnect: OnConnect = useCallback(
    (params) => setEdges((eds) => addEdge(params, eds)),
    []
  );

  const onDragOver = useCallback((event: React.DragEvent) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = "move";
  }, []);

  const onDrop = useCallback(
    (event: React.DragEvent) => {
      event.preventDefault();
      const type = event.dataTransfer.getData("application/reactflow");
      if (!type) return;
      const position = screenToFlowPosition({
        x: event.clientX,
        y: event.clientY,
      });
      const newNode: Node = {
        id: getNodeId(),
        type,
        position,
        data: { label: defaultLabels[type] || "Node" },
      };
      setNodes((nds) => [...nds, newNode]);
    },
    [screenToFlowPosition]
  );

  const saveDiagram = async () => {
    const body = { name: diagramName, nodes, edges };

    if (currentDiagramId) {
      const res = await fetch(`/api/diagrams/${currentDiagramId}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error("Failed to save");
    } else {
      const res = await fetch("/api/diagrams", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      if (!res.ok) throw new Error("Failed to save");
      const data = await res.json();
      setCurrentDiagramId(data.id);
      const url = new URL(window.location.href);
      url.searchParams.set("id", data.id);
      window.history.replaceState({}, "", url.toString());
    }
  };

  const loadDiagram = async (id: string) => {
    const res = await fetch(`/api/diagrams/${id}`);
    if (!res.ok) return;
    const data = await res.json();
    setNodes(data.nodes as Node[]);
    setEdges(data.edges as Edge[]);
    setDiagramName(data.name);
    setCurrentDiagramId(data.id);
    const url = new URL(window.location.href);
    url.searchParams.set("id", data.id);
    window.history.replaceState({}, "", url.toString());
  };

  const newDiagram = () => {
    setNodes([]);
    setEdges([]);
    setDiagramName("Untitled Architecture");
    setCurrentDiagramId(null);
    const url = new URL(window.location.href);
    url.searchParams.delete("id");
    window.history.replaceState({}, "", url.toString());
  };

  return (
    <div className="h-screen flex flex-col bg-slate-950">
      <Toolbar
        diagramName={diagramName}
        onDiagramNameChange={setDiagramName}
        onSave={saveDiagram}
        onLoad={loadDiagram}
        onNew={newDiagram}
        currentDiagramId={currentDiagramId}
      />
      <div className="flex flex-1 overflow-hidden">
        <ComponentPanel />
        <div className="flex-1" ref={reactFlowWrapper} onDragOver={onDragOver} onDrop={onDrop}>
          <ReactFlow
            nodes={nodes}
            edges={edges}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            onConnect={onConnect}
            nodeTypes={nodeTypes}
            fitView
            className="bg-slate-950"
          >
            <Controls className="!bg-slate-800 !border-slate-600 [&>button]:!bg-slate-700 [&>button]:!border-slate-600 [&>button]:!text-white" />
            <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#334155" />
          </ReactFlow>
        </div>
      </div>
    </div>
  );
}

export default function Home() {
  return (
    <ReactFlowProvider>
      <FlowCanvas />
    </ReactFlowProvider>
  );
}

Your architecture visualizer now has full persistence. Diagrams survive page refreshes, you can create multiple diagrams and switch between them, and every save goes straight to your Neon PostgreSQL database. Next up, a secret mission that lets you export your diagrams as shareable PNG images.

Secret mission

Export to PNG

Your architecture diagrams look great on screen, but what about sharing them in a Slack thread or a design doc? In this secret mission, you will add an Export PNG button that captures your entire canvas as a downloadable image file.

Clean Up Your Resources

Clean Up Your Resources

This project runs on Neon's free tier, which auto-suspends compute after 5 minutes of inactivity. There are no ongoing costs while your project sits idle.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Neon PostgreSQL project ("architecture-visualizer") with Diagram records stored in the database.
  • Local project files (prisma/, components/, lib/, app/api/, and .env).

Keep everything running

No action needed. Choose this if you plan to keep building on your architecture visualizer.

  • The Neon free tier scales to zero automatically after 5 minutes of inactivity. No charges will accrue.
  • Your data persists indefinitely on the free plan.
  • Your local project files remain on your machine ready for future development.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database data stay intact.

  • Stop the Next.js dev server by pressing Ctrl+C in your terminal.
  • Neon auto-suspends compute when idle. There is nothing else to do on the cloud side.
  • When you come back, run npm run dev in your project folder to resume.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Delete your Neon project by following these steps:
  • Go to the Neon Console at https://console.neon.tech.
  • Select your architecture-visualizer project.
  • Click Settings in the sidebar.
  • Click Delete and confirm the deletion. This permanently removes all databases, branches, and diagram records.

Delete your local project files by running these commands in your terminal:

macOS

rm -rf prisma/ components/nodes/ lib/ app/api/diagrams/
rm -f prisma.config.ts .env components/ComponentPanel.tsx components/Toolbar.tsx app/page.tsx app/globals.css app/layout.tsx

Windows

Remove-Item -Recurse -Force prisma/, components/nodes/, lib/, app/api/diagrams/
Remove-Item -Force prisma.config.ts, .env, components/ComponentPanel.tsx, components/Toolbar.tsx, app/page.tsx, app/globals.css, app/layout.tsx
  • Remove the packages that were installed for this project by running:
npm uninstall @xyflow/react @prisma/client @prisma/adapter-pg dotenv pg html-to-image
npm uninstall -D prisma tsx @types/pg

Nice Work!

Nice Work!

You just built a full-stack system architecture visualizer from scratch. You can drag components onto a canvas, connect them with edges, save your diagrams to a real PostgreSQL database, and load them back on refresh.

You've learned how to:

  • Build an interactive React Flow canvas with four custom architecture node types (Service, Database, Queue, Cache) and drag-and-drop from a component panel.
  • Design a Prisma database schema and connect it to a Neon PostgreSQL database that scales to zero when idle.
  • Build RESTful API routes in Next.js for full CRUD operations, wiring frontend canvas state to the backend so diagrams persist across sessions.
  • Secret Mission: Added export-to-PNG functionality so you can share your architecture diagrams as images.

Ready to quiz yourself?