Skip to main content

Command Palette

Search for a command to run...

Building a VS Code File Tree in Next.js

Updated
11 min read
Building a VS Code File Tree in Next.js
S

Full-stack developer documenting what I’m learning as I go. This space is all about tech, understanding how things work, and writing things down as they start to make sense.

In this comprehensive guide, we'll build our own VS Code-like file explorer from scratch using Next.js. We'll cover everything from data structures to recursive rendering, with plenty of examples along the way.

1. The Problem We're Solving

Imagine you have a flat list of files from an API:

[
  { "fileName": "backend/src/index.js", "id": "1", "sourceCode": "..." },
  { "fileName": "backend/package.json", "id": "2", "sourceCode": "..." },
  { "fileName": "frontend/src/App.jsx", "id": "3", "sourceCode": "..." },
  { "fileName": "frontend/src/components/Header.jsx", "id": "4", "sourceCode": "..." },
  { "fileName": "README.md", "id": "5", "sourceCode": "..." }
]

And you want to transform it into this beautiful, interactive tree:

📁 backend/
  📁 src/
    📄 index.js
  📄 package.json
📁 frontend/
  📁 src/
    📄 App.jsx
    📁 components/
      📄 Header.jsx
📄 README.md

2. Understanding the Data

First, let's understand what we're working with. Our input data represents files with their full paths:

Input Data Structure

type SourceFile = {
  id: string;           // Unique identifier
  fileName: string;     // Full path: "backend/src/index.js"
  sourceCode: string;   // The actual file content
  summary: string;      // File description
  projectId: string;    // Project identifier
};

The Challenge

The main challenge is converting this flat structure into a hierarchical tree that mirrors the actual file system structure. Each fileName contains the full path, and we need to:

  1. Split paths into individual segments

  2. Create folder nodes for intermediate paths

  3. Create file nodes for the final segments

  4. Maintain proper parent-child relationships

3. Designing the Data Structures

The key to building a good file tree is designing the right data structures. We need to represent two types of nodes:

File Node

Represents an actual file (leaf node in our tree):

type FileNode = SourceFile & {
  type: "file";
  name: string;  // Just the filename, e.g., "index.js"
};

Example:

{
  type: "file",
  name: "index.js",
  id: "1",
  fileName: "backend/src/index.js",
  sourceCode: "import express from 'express'...",
  summary: "Main server entry point",
  projectId: "abc-123"
}

Folder Node

Represents a directory (branch node that can contain other nodes):

type FolderNode = {
  type: "folder";
  name: string;      // Just the folder name, e.g., "src"
  children: FileTree; // Recursive reference!
};

Example:

{
  type: "folder",
  name: "backend",
  children: {
    "src": { type: "folder", name: "src", children: {...} },
    "package.json": { type: "file", name: "package.json", ... }
  }
}

File Tree

The overall structure that holds our tree:

type FileTree = {
  [key: string]: FileNode | FolderNode;
};

Why this design works:

  1. Union Types: Each tree node can be either a file or folder

  2. Type Discrimination: The type field lets us distinguish between them

  3. Recursion: FolderNode.children is itself a FileTree

  4. Flexible Keys: Object keys represent file/folder names for easy lookup

Complete Type System

// Base input data
type SourceFile = {
  id: string;
  fileName: string;
  sourceCode: string;
  summary: string;
  projectId: string;
};

// Tree node types
type FileNode = SourceFile & {
  type: "file";
  name: string;
};

type FolderNode = {
  type: "folder";
  name: string;
  children: FileTree;
};

// The main tree structure
type FileTree = {
  [key: string]: FileNode | FolderNode;
};

4. Building the Tree Algorithm

Now for the fun part - converting our flat list into a hierarchical tree!

The Algorithm Intuition

Think of it like organizing physical files:

  1. You have a stack of documents with full addresses

  2. For each document, you follow its address step-by-step

  3. If a folder doesn't exist, you create it

  4. You place the document in the deepest folder

Step-by-Step Implementation

const buildFileTree = (files: SourceFile[]): FileTree => {
  const root: FileTree = {};

  files.forEach((file) => {
    let currentLevel = root;

    // Split the path: "backend/src/index.js" → ["backend", "src", "index.js"]
    const pathParts = file.fileName.split("/");

    pathParts.forEach((part, index) => {
      const isFile = index === pathParts.length - 1;

      if (isFile) {
        currentLevel[part] = {
          type: "file",
          name: part,
          ...file,
        };
      } else {
        if (!currentLevel[part]) {
          currentLevel[part] = {
            type: "folder",
            name: part,
            children: {},
          };
        }

        currentLevel = (currentLevel[part] as FolderNode).children;
      }
    });
  });

  return root;
};

Let's see what happens with multiple files:

const files = [
  { fileName: "backend/src/index.js", id: "1", /* ... */ },
  { fileName: "backend/package.json", id: "2", /* ... */ },
  { fileName: "frontend/src/App.jsx", id: "3", /* ... */ },
  { fileName: "README.md", id: "4", /* ... */ }
];

const tree = buildFileTree(files);

// Result:
{
  "backend": {
    type: "folder",
    name: "backend",
    children: {
      "src": {
        type: "folder",
        name: "src", 
        children: {
          "index.js": { type: "file", name: "index.js", id: "1" }
        }
      },
      "package.json": { type: "file", name: "package.json", id: "2" }
    }
  },
  "frontend": {
    type: "folder",
    name: "frontend",
    children: {
      "src": {
        type: "folder",
        name: "src",
        children: {
          "App.jsx": { type: "file", name: "App.jsx", id: "3" }
        }
      }
    }
  },
  "README.md": { type: "file", name: "README.md", id: "4" }
}

5. Rendering with Recursion

Now that we have our tree structure, we need to render it. This is where recursion shines!

The Recursive Component

interface FileTreeProps {
  tree: FileTree;
  onFileSelect: (file: FileNode) => void;
  selectedFileId: string | null;
  level?: number; // For indentation
}

const FileTreeView: React.FC<FileTreeProps> = ({
  tree,
  onFileSelect,
  selectedFileId,
  level = 0
}) => {
  const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({});

  const toggleFolder = (name: string) => {
    setOpenFolders(prev => ({
      ...prev,
      [name]: !prev[name]
    }));
  };

  // Sort entries: folders first, then files, alphabetically within each group
  const sortedEntries = Object.values(tree).sort((a, b) => {
    if (a.type === 'folder' && b.type === 'file') return -1;
    if (a.type === 'file' && b.type === 'folder') return 1;
    return a.name.localeCompare(b.name);
  });

  return (
    <div>
      {sortedEntries.map(node => {
        if (node.type === 'folder') {
          const isFolderOpen = openFolders[node.name];

          return (
            <div key={node.name} style={{ marginLeft: `${level * 16}px` }}>
              <div 
                className="flex items-center p-1 cursor-pointer rounded-md hover:bg-gray-700"
                onClick={() => toggleFolder(node.name)}
              >
                {isFolderOpen ? <ChevronDown /> : <ChevronRight />}
                <FolderIcon />
                <span className="ml-1 font-semibold">{node.name}</span>
              </div>

              {/* recursive call */}
              {isFolderOpen && (
                <FileTreeView
                  tree={node.children}
                  onFileSelect={onFileSelect}
                  selectedFileId={selectedFileId}
                  level={level + 1} // Increase indentation
                />
              )}
            </div>
          );
        } else {
          const isSelected = selectedFileId === node.id;

          return (
            <div
              key={node.id}
              style={{ marginLeft: `${level * 16}px` }}
              className={`flex items-center p-1 cursor-pointer rounded-md hover:bg-gray-700 ${
                isSelected ? 'bg-blue-600/50' : ''
              }`}
              onClick={() => onFileSelect(node)}
            >
              <FileIcon fileName={node.name} />
              <span className="ml-2">{node.name}</span>
            </div>
          );
        }
      })}
    </div>
  );
};

How Recursion Works Here

The magic happens in this part:

{isFolderOpen && (
  <FileTreeView
    tree={node.children}    // Pass the folder's children
    onFileSelect={onFileSelect}
    selectedFileId={selectedFileId} 
    level={level + 1}       // Increase nesting level
  />
)}

The recursive flow:

  1. Initial call renders root level folders and files

  2. When a folder is clicked, toggleFolder sets openFolders[folderName] = true

  3. Component re-renders, isFolderOpen becomes true

  4. Recursive call renders the folder's contents with level + 1

  5. If nested folders exist, the process repeats at deeper levels

Visual Example

For this tree structure:

📁 backend/ (level 0)
  📁 src/ (level 1)
    📄 index.js (level 2)
  📄 package.json (level 1)

The rendering calls would be:

// Initial call (level 0)
<FileTreeView tree={rootTree} level={0} />

// When "backend" is opened (level 1)
<FileTreeView tree={backend.children} level={1} />

// When "src" is opened (level 2)  
<FileTreeView tree={src.children} level={2} />

Adding Interactivity with Folder State Management

Let's add the interactive features that make our file tree feel alive:

const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({});

const toggleFolder = (name: string) => {
  setOpenFolders(prev => ({
    ...prev,
    [name]: !prev[name] // Toggle: undefined -> true, true -> false, false -> true
  }));
};

6. Performance Issues with Large Trees

// ❌ INEFFICIENT - Recreates sorted array on every render
const sortedEntries = Object.values(tree).sort((a, b) => {
  // Expensive sorting on every render
});

// ✅ OPTIMIZED - Memoize the sorted result
const sortedEntries = useMemo(() => {
  return Object.values(tree).sort((a, b) => {
    if (a.type === 'folder' && b.type === 'file') return -1;
    if (a.type === 'file' && b.type === 'folder') return 1;
    return a.name.localeCompare(b.name);
  });
}, [tree]);

7. Complete Working Example

Here's a complete, working implementation you can copy and use:

Types (types/fileTree.ts)

export type SourceFile = {
  id: string;
  fileName: string;
  sourceCode: string;
  summary: string;
  projectId: string;
};

export type FileNode = SourceFile & {
  type: "file";
  name: string;
};

export type FolderNode = {
  type: "folder";
  name: string;
  children: FileTree;
};

export type FileTree = {
  [key: string]: FileNode | FolderNode;
};

Tree Builder (utils/buildFileTree.ts)

import { SourceFile, FileTree, FolderNode } from '../types/fileTree';

export const buildFileTree = (files: SourceFile[]): FileTree => {
  const root: FileTree = {};

  files.forEach(file => {
    let currentLevel = root;
    const pathParts = file.fileName.split('/');

    pathParts.forEach((part, index) => {
      const isFile = index === pathParts.length - 1;

      if (isFile) {
        currentLevel[part] = {
          type: 'file',
          name: part,
          ...file,
        };
      } else {
        if (!currentLevel[part]) {
          currentLevel[part] = {
            type: 'folder',
            name: part,
            children: {},
          };
        }
        currentLevel = (currentLevel[part] as FolderNode).children;
      }
    });
  });

  return root;
};

File Tree Component (components/FileTreeView.tsx)

import React, { useState, useMemo } from 'react';
import { FileTree, FileNode, FolderNode } from '../types/fileTree';
import { ChevronRight, ChevronDown, Folder, FolderOpen } from 'lucide-react';

interface FileTreeProps {
  tree: FileTree;
  onFileSelect: (file: FileNode) => void;
  selectedFileId: string | null;
  level?: number;
}

const FileIcon = ({ fileName }: { fileName: string }) => {
  const extension = fileName.split('.').pop()?.toLowerCase();
  const iconClass = "w-4 h-4 mr-2";

  switch (extension) {
    case 'ts':
    case 'tsx':
      return <div className={`${iconClass} bg-yellow-400 rounded text-xs text-black font-bold flex items-center justify-center`}>JS</div>;
    default:
      return <div className={`${iconClass} bg-gray-400 rounded text-xs text-black font-bold flex items-center justify-center`}>📄</div>;
  }
};

export const FileTreeView: React.FC<FileTreeProps> = ({
  tree,
  onFileSelect,
  selectedFileId,
  level = 0
}) => {
  const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({});

  const toggleFolder = (name: string) => {
    setOpenFolders(prev => ({
      ...prev,
      [name]: !prev[name]
    }));
  };

  const sortedEntries = useMemo(() => {
    return Object.values(tree).sort((a, b) => {
      if (a.type === 'folder' && b.type === 'file') return -1;
      if (a.type === 'file' && b.type === 'folder') return 1;
      return a.name.localeCompare(b.name);
    });
  }, [tree]);

  return (
    <div className="select-none">
      {sortedEntries.map(node => {
        if (node.type === 'folder') {
          const isFolderOpen = openFolders[node.name];

          return (
            <div key={node.name}>
              <div
                style={{ paddingLeft: `${level * 12}px` }}
                className="flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-sm"
                onClick={() => toggleFolder(node.name)}
              >
                <div className="w-4 h-4 mr-1 flex items-center justify-center">
                  {isFolderOpen ? (
                    <ChevronDown className="w-3 h-3" />
                  ) : (
                    <ChevronRight className="w-3 h-3" />
                  )}
                </div>
                {isFolderOpen ? (
                  <FolderOpen className="w-4 h-4 mr-2 text-blue-500" />
                ) : (
                  <Folder className="w-4 h-4 mr-2 text-blue-600" />
                )}
                <span className="font-medium">{node.name}</span>
              </div>

              {isFolderOpen && (
                <FileTreeView
                  tree={node.children}
                  onFileSelect={onFileSelect}
                  selectedFileId={selectedFileId}
                  level={level + 1}
                />
              )}
            </div>
          );
        } else {
          const isSelected = selectedFileId === node.id;

          return (
            <div
              key={node.id}
              style={{ paddingLeft: `${level * 12 + 16}px` }}
              className={`flex items-center py-1 px-2 cursor-pointer rounded text-sm ${
                isSelected 
                  ? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100' 
                  : 'hover:bg-gray-100 dark:hover:bg-gray-800'
              }`}
              onClick={() => onFileSelect(node)}
            >
              <FileIcon fileName={node.name} />
              <span>{node.name}</span>
            </div>
          );
        }
      })}
    </div>
  );
};

Usage Example (App.tsx)

import React, { useState, useMemo } from 'react';
import { FileTreeView } from './components/FileTreeView';
import { buildFileTree } from './utils/buildFileTree';
import { SourceFile, FileNode } from './types/fileTree';

const App = () => {
  const [selectedFile, setSelectedFile] = useState<FileNode | null>(null);

  // Mock data - replace with your API call
  const mockFiles: SourceFile[] = [
    {
      id: "1",
      fileName: "backend/src/index.js",
      sourceCode: "import express from 'express';\n// Server code...",
      summary: "Main server entry point",
      projectId: "proj-1"
    },
    {
      id: "2",
      fileName: "backend/package.json",
      sourceCode: '{\n  "name": "backend",\n  "version": "1.0.0"\n}',
      summary: "Backend dependencies",
      projectId: "proj-1"
    },
    {
      id: "3",
      fileName: "frontend/src/App.jsx",
      sourceCode: "import React from 'react';\n\nfunction App() {\n  return <h1>Hello World</h1>;\n}",
      summary: "Main React component",
      projectId: "proj-1"
    },
    {
      id: "4",
      fileName: "frontend/src/components/Header.jsx",
      sourceCode: "import React from 'react';\n\nexport const Header = () => {\n  return <header>My App</header>;\n};",
      summary: "Header component",
      projectId: "proj-1"
    },
    {
      id: "5",
      fileName: "README.md",
      sourceCode: "# My Project\n\nThis is a sample project...",
      summary: "Project documentation",
      projectId: "proj-1"
    }
  ];

  const fileTree = useMemo(() => buildFileTree(mockFiles), []);

  return (
    <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
      <div className="flex">
        {/* File Tree Sidebar */}
        <div className="w-80 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 h-screen overflow-auto">
          <div className="p-4 border-b border-gray-200 dark:border-gray-700">
            <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
              Project Files
            </h2>
          </div>
          <div className="p-2">
            <FileTreeView
              tree={fileTree}
              onFileSelect={setSelectedFile}
              selectedFileId={selectedFile?.id || null}
            />
          </div>
        </div>

        {/* Content Area */}
        <div className="flex-1 p-6">
          {selectedFile ? (
            <div>
              <div className="mb-4">
                <h3 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">
                  {selectedFile.fileName}
                </h3>
                <p className="text-gray-600 dark:text-gray-400 mb-4">
                  {selectedFile.summary}
                </p>
              </div>

              {/* Code Editor */}
              <div className="bg-gray-900 rounded-lg p-4 overflow-auto">
                <pre className="text-green-400 text-sm">
                  <code>{selectedFile.sourceCode}</code>
                </pre>
              </div>
            </div>
          ) : (
            <div className="flex items-center justify-center h-full">
              <div className="text-center">
                <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
                  Welcome to the File Explorer
                </h3>
                <p className="text-gray-500 dark:text-gray-400">
                  Select a file from the sidebar to view its contents
                </p>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default App;

8. Conclusion

Building a VS Code-like file tree involves several key concepts:

  1. Smart Data Structures: Using recursive types to mirror the hierarchical nature of file systems

  2. Efficient Algorithms: Converting flat file lists to tree structures with proper state management

  3. Recursive Rendering: Using React's component model to handle arbitrary nesting levels

  4. Interactive State: Managing folder open/close states and file selection

  5. Performance: Memoizing expensive calculations and considering virtualization for large datasets

The beauty of this approach is its scalability - whether you have 10 files or 10,000 files, the same patterns and algorithms work efficiently.

What's Next?

From here, you could extend this file tree with:

  • Real-time file watching and updates

  • Multi-select functionality

  • Custom file type handling

  • Integration with code editors

  • File operations (create, rename, delete)

  • Git status indicators

  • Breadcrumb navigation

The patterns you've learned here form the foundation for building any hierarchical data visualization in React. Whether it's file trees, organization charts, or nested comment systems, these same principles apply.

Happy coding!


Found this helpful? Follow me for more in-depth React tutorials and don't forget to share this with your developer friends!