Interactive TypeScript Schema Visualizer for Next.js

Step-by-step guide using ts-morph and React Flow to parse TypeScript interfaces and render an interactive schema…

·Updated on:·Matija Žiberna·
Interactive TypeScript Schema Visualizer for Next.js

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

How to Build an Interactive TypeScript Schema Visualizer in Next.js

Last month, I was preparing a database schema presentation for stakeholders on a Next.js project. The project had dozens of TypeScript interfaces spread across multiple files, with complex relationships between collections, blocks, and shared types. I needed a way to visualize these relationships that was more engaging than static diagrams or documentation pages.

After exploring various options like generating ERD diagrams from database schemas or using external tools, I realized I could build something better. I wanted an interactive visualization that parsed our existing TypeScript types directly, showed relationships visually, and lived right inside our Next.js application where stakeholders could explore it themselves.

This guide walks you through building exactly that: an interactive schema visualization page that automatically parses TypeScript interfaces using ts-morph and renders them as an explorable graph using React Flow.

Understanding the Architecture

Before diving into code, let's outline what we're building. The solution consists of four main components that work together to transform TypeScript type definitions into an interactive visual diagram.

First, we need a parser that can read TypeScript files and extract interface definitions along with their properties and relationships. This is where ts-morph comes in. It provides a TypeScript compiler API wrapper that makes it straightforward to traverse the Abstract Syntax Tree of our type files.

Second, we need an API endpoint that runs the parser on the server side and returns structured data about our schema. This keeps the heavy parsing work on the server and provides a clean data API for our frontend.

Third, we need a React component that fetches this schema data and transforms it into a format React Flow can understand. React Flow expects nodes and edges, so we'll map our interfaces to nodes and our type relationships to edges.

Finally, we need a Next.js page that renders the visualization component and provides the user interface for stakeholders to explore.

Installing Dependencies

We'll need two key libraries for this implementation. React Flow provides the interactive graph visualization, while ts-morph gives us the ability to parse TypeScript source files programmatically.

npm install @xyflow/react ts-morph

The @xyflow/react package is the modern version of React Flow, built specifically for React 18 and beyond. The ts-morph library is a wrapper around the TypeScript compiler API that makes working with TypeScript's AST much more approachable than using the raw compiler API directly.

Building the Schema Parser

The parser is the foundation of our visualization. It needs to read TypeScript files, find interface definitions, extract their properties, and identify relationships between types. Let's create a utility that handles all of this.

// File: src/lib/schema-parser.ts
import { Project, SyntaxKind } from "ts-morph";
import path from "path";

export interface SchemaNode {
  id: string;
  name: string;
  type: "collection" | "block" | "shared";
  properties: PropertyInfo[];
  filePath: string;
}

export interface PropertyInfo {
  name: string;
  type: string;
  isArray: boolean;
  isOptional: boolean;
  relationTo?: string;
}

export interface SchemaRelationship {
  id: string;
  source: string;
  target: string;
  type: "one-to-one" | "one-to-many" | "many-to-many";
  property: string;
}

export function parseSchema(): {
  nodes: SchemaNode[];
  relationships: SchemaRelationship[];
} {
  const project = new Project({
    tsConfigFilePath: path.join(process.cwd(), "tsconfig.json"),
  });

  const nodes: SchemaNode[] = [];
  const relationships: SchemaRelationship[] = [];

  const typesDir = path.join(process.cwd(), "src", "types");

  const collectionsDir = path.join(
    typesDir,
    "collections",
    "payload-collections",
  );
  const blocksDir = path.join(typesDir, "blocks", "payload-blocks");
  const sharedDir = path.join(typesDir, "shared");

  const parseDirectory = (
    dir: string,
    type: "collection" | "block" | "shared",
  ) => {
    try {
      const sourceFiles = project.addSourceFilesAtPaths(`${dir}/*.ts`);

      sourceFiles.forEach((sourceFile) => {
        const interfaces = sourceFile.getInterfaces();

        interfaces.forEach((interfaceDecl) => {
          const interfaceName = interfaceDecl.getName();

          if (
            interfaceName.toLowerCase().includes("payload") ||
            interfaceName.toLowerCase().includes("locked") ||
            interfaceName.toLowerCase().includes("migration") ||
            interfaceName.toLowerCase().includes("preference")
          ) {
            return;
          }

          const properties: PropertyInfo[] = [];

          interfaceDecl.getProperties().forEach((prop) => {
            const propName = prop.getName();
            const propType = prop.getType();
            const typeText = propType.getText();

            const isArray =
              typeText.includes("[]") || typeText.includes("Array<");
            const isOptional = prop.hasQuestionToken();

            let relationTo: string | undefined;

            if (typeText.includes("number | ")) {
              const match = typeText.match(/number \| (\w+)/);
              if (match) {
                relationTo = match[1];
              }
            } else if (typeText.includes("(number | null) | ")) {
              const match = typeText.match(/\(number \| null\) \| (\w+)/);
              if (match) {
                relationTo = match[1];
              }
            } else if (
              typeText.match(/^\w+$/) &&
              !["string", "number", "boolean"].includes(typeText)
            ) {
              relationTo = typeText;
            }

            properties.push({
              name: propName,
              type: typeText.replace(/\s+/g, " ").substring(0, 100),
              isArray,
              isOptional,
              relationTo,
            });

            if (relationTo) {
              const relType: "one-to-one" | "one-to-many" | "many-to-many" =
                isArray ? "one-to-many" : "one-to-one";

              relationships.push({
                id: `${interfaceName}-${propName}-${relationTo}`,
                source: interfaceName,
                target: relationTo,
                type: relType,
                property: propName,
              });
            }
          });

          nodes.push({
            id: interfaceName,
            name: interfaceName,
            type,
            properties,
            filePath: sourceFile.getFilePath(),
          });
        });
      });
    } catch (error) {
      console.error(`Error parsing ${dir}:`, error);
    }
  };

  parseDirectory(collectionsDir, "collection");
  parseDirectory(blocksDir, "block");
  parseDirectory(sharedDir, "shared");

  return { nodes, relationships };
}

This parser does several important things. It creates a ts-morph Project instance that understands your TypeScript configuration, ensuring type resolution works correctly. The parseDirectory function is the workhorse that reads all TypeScript files in a given directory, extracts interface definitions, and analyzes each property.

The relationship detection logic is particularly important. When a property has a type like "number | Media", this indicates a Payload CMS relationship pattern where the property can be either an ID (number) or the populated document (Media). The parser detects these patterns and creates relationship edges between nodes.

We categorize nodes into three types: collections (your main data models), blocks (reusable content blocks), and shared types (common interfaces). This categorization helps with visual organization in the final diagram.

Creating the API Endpoint

Next, we need an API route that executes the parser and returns the schema data. This keeps the parsing logic on the server where it belongs, since ts-morph needs access to the file system.

// File: src/app/api/schema/route.ts
import { NextResponse } from "next/server";
import { parseSchema } from "@/lib/schema-parser";

export async function GET() {
  try {
    const schema = parseSchema();
    return NextResponse.json(schema);
  } catch (error) {
    console.error("Error parsing schema:", error);
    return NextResponse.json(
      {
        error: "Failed to parse schema",
        details: error instanceof Error ? error.message : "Unknown error",
      },
      { status: 500 },
    );
  }
}

This is a straightforward Next.js API route that calls our parser and returns the results as JSON. The error handling ensures that if something goes wrong during parsing, we get useful debugging information rather than a cryptic error.

The API route approach also means the heavy parsing work only happens when someone actually visits the schema page, not on every build or page load. This keeps your application performant.

Building the Visualization Component

Now comes the interesting part: transforming our schema data into an interactive visual diagram. React Flow handles the rendering and interaction, but we need to transform our data into the format it expects and create custom node components that display our schema information clearly.

// File: src/components/schema-visualization.tsx
'use client';

import React, { useCallback, useEffect, useState } from 'react';
import {
  ReactFlow,
  Node,
  Edge,
  Controls,
  Background,
  useNodesState,
  useEdgesState,
  addEdge,
  Connection,
  MarkerType,
  BackgroundVariant,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { SchemaNode, SchemaRelationship, PropertyInfo } from '@/lib/schema-parser';

interface SchemaNodeData extends Record<string, unknown> {
  label: string;
  type: 'collection' | 'block' | 'shared';
  properties: PropertyInfo[];
  filePath: string;
}

const nodeTypeColors = {
  collection: {
    bg: 'bg-blue-50 dark:bg-blue-950',
    border: 'border-blue-500',
    text: 'text-blue-700 dark:text-blue-300',
  },
  block: {
    bg: 'bg-purple-50 dark:bg-purple-950',
    border: 'border-purple-500',
    text: 'text-purple-700 dark:text-purple-300',
  },
  shared: {
    bg: 'bg-green-50 dark:bg-green-950',
    border: 'border-green-500',
    text: 'text-green-700 dark:text-green-300',
  },
};

const CustomNode = ({ data }: { data: SchemaNodeData }) => {
  const colors = nodeTypeColors[data.type];
  const [isExpanded, setIsExpanded] = useState(false);

  const displayProperties = isExpanded
    ? data.properties
    : data.properties.slice(0, 5);

  return (
    <div className={`${colors.bg} ${colors.border} border-2 rounded-lg shadow-lg min-w-[280px] max-w-[400px]`}>
      <div className={`${colors.text} px-4 py-3 font-bold text-lg border-b ${colors.border}`}>
        {data.label}
        <span className="ml-2 text-xs font-normal opacity-70 uppercase">
          {data.type}
        </span>
      </div>
      <div className="p-3 text-sm bg-white dark:bg-gray-900">
        {displayProperties.map((prop, idx) => (
          <div key={idx} className="mb-2 font-mono text-xs">
            <span className="text-gray-700 dark:text-gray-300">
              {prop.name}{prop.isOptional ? '?' : ''}
            </span>
            <span className="text-gray-500 dark:text-gray-500">: </span>
            <span className={prop.relationTo ? 'text-orange-600 dark:text-orange-400 font-semibold' : 'text-gray-600 dark:text-gray-400'}>
              {prop.type.length > 40 ? prop.type.substring(0, 40) + '...' : prop.type}
            </span>
          </div>
        ))}
        {data.properties.length > 5 && (
          <button
            onClick={() => setIsExpanded(!isExpanded)}
            className="text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2"
          >
            {isExpanded ? 'Show less' : `Show ${data.properties.length - 5} more...`}
          </button>
        )}
      </div>
    </div>
  );
};

const nodeTypes = {
  custom: CustomNode,
};

export function SchemaVisualization() {
  const [nodes, setNodes, onNodesChange] = useNodesState<Node<SchemaNodeData>>([]);
  const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

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

  useEffect(() => {
    async function loadSchema() {
      try {
        setIsLoading(true);
        const response = await fetch('/api/schema');
        if (!response.ok) {
          throw new Error('Failed to fetch schema');
        }
        const data: { nodes: SchemaNode[]; relationships: SchemaRelationship[] } = await response.json();

        const flowNodes: Node<SchemaNodeData>[] = [];
        const flowEdges: Edge[] = [];

        const typeGroups: Record<string, SchemaNode[]> = {
          collection: [],
          block: [],
          shared: [],
        };

        data.nodes.forEach(node => {
          typeGroups[node.type].push(node);
        });

        let yOffset = 0;
        const xSpacing = 450;
        const ySpacing = 250;

        Object.entries(typeGroups).forEach(([type, nodesInGroup], groupIndex) => {
          nodesInGroup.forEach((node, index) => {
            const col = Math.floor(index / 3);
            const row = index % 3;

            flowNodes.push({
              id: node.id,
              type: 'custom',
              position: {
                x: groupIndex * (xSpacing * 3) + col * xSpacing,
                y: yOffset + row * ySpacing
              },
              data: {
                label: node.name,
                type: node.type,
                properties: node.properties,
                filePath: node.filePath,
              },
            });
          });

          yOffset += Math.ceil(nodesInGroup.length / 3) * ySpacing + 100;
        });

        data.relationships.forEach(rel => {
          const sourceExists = flowNodes.some(n => n.id === rel.source);
          const targetExists = flowNodes.some(n => n.id === rel.target);

          if (sourceExists && targetExists) {
            flowEdges.push({
              id: rel.id,
              source: rel.source,
              target: rel.target,
              label: rel.property,
              type: 'smoothstep',
              animated: rel.type === 'one-to-many',
              markerEnd: {
                type: MarkerType.ArrowClosed,
                width: 20,
                height: 20,
              },
              style: {
                stroke: rel.type === 'one-to-many' ? '#f97316' : '#6b7280',
                strokeWidth: 2,
              },
            });
          }
        });

        setNodes(flowNodes);
        setEdges(flowEdges);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error');
      } finally {
        setIsLoading(false);
      }
    }

    loadSchema();
  }, [setNodes, setEdges]);

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-[calc(100vh-200px)]">
        <div className="text-center">
          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-gray-100 mx-auto mb-4"></div>
          <p className="text-muted-foreground">Loading schema...</p>
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex items-center justify-center h-[calc(100vh-200px)]">
        <div className="text-center text-red-600 dark:text-red-400">
          <p className="text-xl font-bold mb-2">Error loading schema</p>
          <p>{error}</p>
        </div>
      </div>
    );
  }

  return (
    <div className="w-full h-[calc(100vh-200px)] border rounded-lg overflow-hidden bg-white dark:bg-gray-950">
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        nodeTypes={nodeTypes}
        fitView
        minZoom={0.1}
        maxZoom={1.5}
      >
        <Controls />
        <Background variant={BackgroundVariant.Dots} gap={12} size={1} />
      </ReactFlow>
      <div className="absolute bottom-4 left-4 bg-white dark:bg-gray-800 p-4 rounded-lg shadow-lg border">
        <div className="text-sm font-semibold mb-2">Legend</div>
        <div className="flex flex-col gap-2 text-xs">
          <div className="flex items-center gap-2">
            <div className="w-4 h-4 bg-blue-500 rounded"></div>
            <span>Collections</span>
          </div>
          <div className="flex items-center gap-2">
            <div className="w-4 h-4 bg-purple-500 rounded"></div>
            <span>Blocks</span>
          </div>
          <div className="flex items-center gap-2">
            <div className="w-4 h-4 bg-green-500 rounded"></div>
            <span>Shared Types</span>
          </div>
          <div className="flex items-center gap-2">
            <div className="w-12 h-0.5 bg-orange-500"></div>
            <span>One-to-Many (animated)</span>
          </div>
          <div className="flex items-center gap-2">
            <div className="w-12 h-0.5 bg-gray-500"></div>
            <span>One-to-One</span>
          </div>
        </div>
      </div>
    </div>
  );
}

This component does a lot of work to transform our schema data into a visual format. The CustomNode component is key here. It receives the schema data for each interface and renders it as a styled card showing the interface name, type category, and properties. Properties that represent relationships to other types are highlighted in orange to make them stand out visually.

The layout algorithm groups nodes by type and arranges them in a grid pattern. Collections appear in one area, blocks in another, and shared types in a third. This spatial organization makes it easier to understand the overall structure at a glance.

The edge styling differentiates between relationship types. One-to-many relationships use animated orange lines, while one-to-one relationships use static gray lines. This visual distinction helps you quickly identify cardinality in your schema.

React Flow's built-in features handle all the interactivity. Users can zoom in and out, pan around the diagram, and drag nodes to rearrange them. The fitView option ensures the entire diagram is visible when the page first loads.

Creating the Schema Page

Finally, we need a Next.js page that renders our visualization component and provides context for users viewing the schema.

// File: src/app/schema/page.tsx
'use client';

import { SchemaVisualization } from '@/components/schema-visualization';

export default function SchemaPage() {
  return (
    <div className="min-h-screen bg-background">
      <div className="container mx-auto py-8">
        <div className="mb-8">
          <h1 className="text-4xl font-bold mb-2">Database Schema</h1>
          <p className="text-muted-foreground">
            Interactive visualization of the current database schema and relationships
          </p>
        </div>
        <SchemaVisualization />
      </div>
    </div>
  );
}

This page is deliberately simple. It provides a title and description to orient users, then renders the visualization component. The simplicity keeps the focus on the schema diagram itself rather than cluttering the interface with unnecessary elements.

How It All Works Together

When a user navigates to /schema in your application, the page component renders and immediately starts fetching data from the API endpoint. The API endpoint calls the schema parser, which uses ts-morph to read your TypeScript files and extract interface definitions and relationships.

The parser returns structured data about all your types, which the visualization component transforms into React Flow nodes and edges. Each interface becomes a node positioned in a grid layout based on its type category. Each relationship becomes an edge connecting two nodes with styling that indicates the relationship type.

React Flow handles rendering this as an interactive SVG diagram with pan and zoom controls. Users can explore the entire schema, drag nodes around to see relationships more clearly, and expand nodes to see all their properties.

The beauty of this approach is that it's completely automated. As you add or modify TypeScript interfaces in your codebase, the visualization automatically reflects those changes the next time someone loads the schema page. There's no manual diagram maintenance or documentation drift to worry about.

Conclusion

Building a schema visualization tool directly into your Next.js application solves the documentation problem in an elegant way. By parsing TypeScript types directly, you ensure the visualization always reflects your actual codebase. By using React Flow, you provide stakeholders with an interactive exploration experience that static diagrams can't match.

The approach demonstrated here works for any TypeScript project with well-structured type definitions. Whether you're using Payload CMS like in this example, or any other framework, the parsing logic can be adapted to your specific patterns and conventions.

You now have a complete implementation that parses TypeScript interfaces, identifies relationships, and renders them as an interactive diagram, all living right inside your Next.js application where your team can easily access it.

Let me know in the comments if you have questions about adapting this to your specific use case, and subscribe for more practical development guides.

Thanks, Matija

0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.