Build a SharePoint Folder Tree View Using SharePoint Framework (SPFx)

Have you ever had a requirement to display folders, subfolders, and nested subfolders from a SharePoint document library in a proper tree view format?

Recently, one of our clients approached us with this exact need. They were migrating files and folders from a legacy file server to SharePoint Online and wanted the same hierarchical folder experience in the new environment. For end users who are used to navigating a structured folder system, this was very important.

As you may already know, the modern SharePoint document library does not provide a true expandable tree view for folders by default. So instead of compromising on the user experience, we decided to build a custom SPFx web part to replicate the complete folder hierarchy in a clean and structured tree view layout.

The final result is shown in the image below.

Build a SharePoint Folder Tree View Using SPFx

If you are also looking to build a SharePoint folder tree view using SPFx, this article will guide you step by step. I will explain how to retrieve folders and subfolders from a document library and render them in a structured tree format.

You can also download the complete working solution using the link provided below.

Create a Folder & Subfolder Tree View in SPFx

We can also achieve this requirement using the PnP Tree View control. However, it comes with certain limitations when it comes to customization and advanced functionality. So in this section, we will build our own tree view without relying on any built-in controls.

By now, I assume you are familiar with creating an SPFx web part using the React framework. If not, you can follow the article linked below to get started.

  1. Open the Props.ts file and the following code.

import { SPFI } from "@pnp/sp";                           
import { WebPartContext } from "@microsoft/sp-webpart-base"; 

export interface IFolderHierarchyProps {
  description: string;          
  isDarkTheme: boolean;        
  environmentMessage: string;   
  hasTeamsContext: boolean;     
  userDisplayName: string;      
  sp: SPFI;                   
  context: WebPartContext;      
}
  • At begining we imported the following statements:
    • SPFI = Type for the PnP/SP API instance
    • WebPartContext = Type for the SPFx context object
  • sp: SPFI; = The PnP/SP instance, used to make SharePoint API calls (fetch folders, files)
  • context = The full SPFx context, which gives access to site URL, page info, permissions, etc.
  1. Then, replace the following code in the .ts file.
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';                 
import {
  type IPropertyPaneConfiguration,                                    
  PropertyPaneTextField                                                
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';   
import { IReadonlyTheme } from '@microsoft/sp-component-base';        


import * as strings from 'FolderHierarchyWebPartStrings';

import FolderHierarchy from './components/FolderHierarchy';
import { IFolderHierarchyProps } from './components/IFolderHierarchyProps';


import { spfi, SPFx } from "@pnp/sp";  
import { SPFI } from "@pnp/sp";          
import "@pnp/sp/webs";                  
import "@pnp/sp/folders";               
import "@pnp/sp/files";                
import "@pnp/sp/lists";                 


export interface IFolderHierarchyWebPartProps {
  description: string;
}

export default class FolderHierarchyWebPart extends BaseClientSideWebPart<IFolderHierarchyWebPartProps> {


  private _isDarkTheme: boolean = false;      
  private _environmentMessage: string = '';     
  private _sp: SPFI                       


  public render(): void {
   
    const element: React.ReactElement<IFolderHierarchyProps> = React.createElement(
      FolderHierarchy,     
      {
        
        description: this.properties.description,                       
        isDarkTheme: this._isDarkTheme,                               
        environmentMessage: this._environmentMessage,                  
        hasTeamsContext: !!this.context.sdks.microsoftTeams,           
        userDisplayName: this.context.pageContext.user.displayName,    
        sp: this._sp,                                                
        context: this.context,                                      
      }
    );
    ReactDom.render(element, this.domElement);
  }


  protected onInit(): Promise<void> {
    return this._getEnvironmentMessage().then(message => {
      this._environmentMessage = message;

     
      this._sp = spfi().using(SPFx(this.context));
    });
  }

 
  private _getEnvironmentMessage(): Promise<string> {
    
    if (!!this.context.sdks.microsoftTeams) {
      return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
        .then(context => {
          let environmentMessage: string = '';
      
          switch (context.app.host.name) {
            case 'Office':     
              environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
              break;
            case 'Outlook':      
              environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOutlook : strings.AppOutlookEnvironment;
              break;
            case 'Teams':        
            case 'TeamsModern':
              environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentTeams : strings.AppTeamsTabEnvironment;
              break;
            default:
              environmentMessage = strings.UnknownEnvironment;
          }

          return environmentMessage;
        });
    }

   
    return Promise.resolve(this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentSharePoint : strings.AppSharePointEnvironment);
  }


  protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
    if (!currentTheme) {
      return;
    }

    
    this._isDarkTheme = !!currentTheme.isInverted;
    const {
      semanticColors      
    } = currentTheme;

    if (semanticColors) {
      this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null);
      this.domElement.style.setProperty('--link', semanticColors.link || null);
      this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered || null);
    }

  }


  protected onDispose(): void {
    ReactDom.unmountComponentAtNode(this.domElement);
  }


  protected get dataVersion(): Version {
    return Version.parse('1.0');
  }

  
  protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
    return {
      pages: [
        {
          header: {
            description: strings.PropertyPaneDescription   
          },
          groups: [
            {
              groupName: strings.BasicGroupName,           
              groupFields: [
                PropertyPaneTextField('description', {
                  label: strings.DescriptionFieldLabel
                })
              ]
            }
          ]
        }
      ]
    };
  }
}
  • To fetch SharePoint document library folders and files, we are using the PnPJS library. So we imported the following PnPJS statements:
    • webs = To access site data
    • folders = To access folder data
    • files = To access file data
    • lists = To access list/library data
  • onInit() = Create the PnP/SP instance. And connects PnP to our web part’s SharePoint context.
  • render() = Sending the props that are required for the React component.
  1. Then, replace the .tsx file code with the code below.

import * as React from "react";
import styles from "./FolderHierarchy.module.scss";       
import { IFolderHierarchyProps } from "./IFolderHierarchyProps"; 
import { Nav, INavLinkGroup, INavLink } from "@fluentui/react";  
import { Icon } from "@fluentui/react/lib/Icon";                


interface IFolderNode {
  name: string;                    
  serverRelativeUrl: string;       
  isFolder: boolean;               
  children?: IFolderNode[];      
  expanded?: boolean;              
  region?: string[];               
}

export default class FolderHierarchy extends React.Component<
  IFolderHierarchyProps,
  IState
> {
  
  constructor(props: IFolderHierarchyProps) {
    super(props);

    this.state = {
      tree: [],
      loading: true,
      selectedMenu: "Dashboard",
    };
  }

  public async componentDidMount(): Promise<void> {
    const siteUrl = this.props.context.pageContext.web.serverRelativeUrl;
    const libraryUrl = `${siteUrl}/FinanceDocuments`;
    const tree = await this.getFolderTree(libraryUrl, 0);
    this.setState({
      tree,
      loading: false,
    });
  }

  private async getFolderTree(
    folderUrl: string,
    level: number
  ): Promise<IFolderNode[]> {

    const folder = this.props.sp.web.getFolderByServerRelativePath(folderUrl);
    const subFolders = await folder.folders();
    const files = await folder.files();
    const nodes: IFolderNode[] = [];

    for (const f of subFolders) {
      if (f.Name === "Forms") {
        continue; 
      }

      let regionValues: string[] | undefined;
      if (level === 0) {
        try {
          
          const folderObj =
            this.props.sp.web.getFolderByServerRelativePath(
              f.ServerRelativeUrl
            );

            const item = await folderObj.listItemAllFields
            .select("Region")();

          if (item?.Region) {
            regionValues = Array.isArray(item.Region)
              ? item.Region              
              : [item.Region];          
          }
        } catch (e) {
         
          console.warn("Region column not found for folder:", f.Name);
        }
      }

      const children = await this.getFolderTree(
        f.ServerRelativeUrl,
        level + 1             
      );

      nodes.push({
        name: f.Name,                           
        serverRelativeUrl: f.ServerRelativeUrl,  
        isFolder: true,                        
        expanded: false,                       
        children,                              
        region: regionValues,                    
      });
    }

    
    for (const file of files) {
      
      nodes.push({
        name: file.Name,                          
        serverRelativeUrl: file.ServerRelativeUrl,  
        isFolder: false,                         
      });
    }

    return nodes;
  }
  private toggleFolder = (path: string): void => {
 
    const toggle = (nodes: IFolderNode[]): IFolderNode[] =>
      nodes.map((n) => {
        if (n.serverRelativeUrl === path) {
          return { ...n, expanded: !n.expanded };
        }
        if (n.children) {
          return { ...n, children: toggle(n.children) };  
        }
        return n;
      });

    this.setState((prev) => ({
      tree: toggle(prev.tree),
    }));
  };

  
  private navGroups: INavLinkGroup[] = [
    {
      links: [
        { name: "Dashboard", key: "Dashboard", url: "#", icon: "ViewDashboard" },
        { name: "Users", key: "Users", url: "#", icon: "Contact" },
        { name: "Categories", key: "Categories", url: "#", icon: "BulletedList" },
        { name: "Documents", key: "Documents", url: "#", icon: "Folder" },
        { name: "Media", key: "Media", url: "#", icon: "Photo2" },
        { name: "Shared Files", key: "SharedFiles", url: "#", icon: "Share" },
        { name: "Reports", key: "Reports", url: "#", icon: "ReportDocument" },
      ],
    },
  ];

  
  private onNavClick = (
    ev?: React.MouseEvent<HTMLElement>,
    item?: INavLink
  ): void => {
    if (item?.key) {
      this.setState({ selectedMenu: item.key as string });
    }
  };

  
  private renderContent(): JSX.Element {
    if (this.state.selectedMenu === "Documents") {
      return (
        <div className={styles.folderHierarchy}>
          {this.state.tree.map((node) => this.renderNode(node))}
        </div>
      );
    }

    return (
      <div>
        <h3>{this.state.selectedMenu}</h3>
        <p>Content coming soon...</p>
      </div>
    );
  }

  

  private renderNode(
    node: IFolderNode,
    isLast: boolean = false
  ): JSX.Element {
    const hasChildren = node.children && node.children.length > 0;

    const nodeClass = `${styles.treeNode} ${
      isLast ? styles.lastNode : ""
    }`;

    if (node.isFolder) {
      return (
        <div className={nodeClass} key={node.serverRelativeUrl}>
          <div
            className={styles.folderHeader}
            onClick={() =>
              hasChildren && this.toggleFolder(node.serverRelativeUrl)
            }
          >
    
            {hasChildren ? (
              <span
                className={
                  node.expanded
                    ? styles.expandIconExpanded     
                    : styles.expandIconCollapsed   
                }
              >
                {node.expanded ? "▾" : "▸"}
              </span>
            ) : (
              <span className={styles.expandIconPlaceholder} />
            )}

            <span className={styles.folderIcon}>📁</span>

            <span className={styles.folderName}>
              {node.name}
              {node.region && node.region.length > 0 && (
                <span className={styles.regionText}>
                  {" "}
                  ({node.region.join(", ")})
                </span>
              )}
            </span>
          </div>

          {node.expanded && node.children && (
            <div className={styles.children}>
              {node.children.map((child, index) =>
                this.renderNode(
                  child,
                  index === node.children!.length - 1  
                )
              )}
            </div>
          )}
        </div>
      );
    }

    const ext = node.name.split(".").pop()?.toLowerCase();  

    return (
      <div className={nodeClass} key={node.serverRelativeUrl}>
        <span className={styles.fileIcon}>
          {this.getFileIcon(ext)}
        </span>

        <a
          href={node.serverRelativeUrl}
          target="_blank"                    
          rel="noopener noreferrer"         
          className={styles.fileLink}
        >
          {node.name}
        </a>
      </div>
    );
  }
  private getFileIcon(ext?: string): JSX.Element {
    let iconName = "Page";              
    let iconClass = styles.defaultFile; 

    switch (ext) {
      case "pdf":
        iconName = "PDF";
        iconClass = styles.pdf;       
        break;
      case "xls":
      case "xlsx":
        iconName = "ExcelDocument";
        iconClass = styles.excel;      
        break;
      case "doc":
      case "docx":
        iconName = "WordDocument";
        iconClass = styles.word;        
        break;
      case "ppt":
      case "pptx":
        iconName = "PowerPointDocument";
        iconClass = styles.ppt;        
        break;
      case "png":
      case "jpg":
      case "jpeg":
      case "gif":
        iconName = "Photo2";
        iconClass = styles.image;       
        break;
      case "mp4":
      case "avi":
      case "mov":
        iconName = "Video";
        iconClass = styles.video;       
        break;
    }

    return <Icon iconName={iconName} className={iconClass} />;
  }

  public render(): React.ReactElement<IFolderHierarchyProps> {
    if (this.state.loading) {
      return <div>Loading...</div>;
    }

    return (
      <div className={styles.layout}>
        <div className={styles.leftNav}>
          <Nav
            groups={this.navGroups}                  
            selectedKey={this.state.selectedMenu}   
            onLinkClick={this.onNavClick}            
          />
        </div>

        <div className={styles.contentArea}>
          {this.renderContent()}
        </div>
      </div>
    );
  }
}
  • IFolderNode = Represents a single item in the tree, could be a folder or a file. Each folder can contain additional IFolderNode items, creating a nested (recursive) structure.
    • name = Display name
    • serverRelativeUrl = SharePoint path
    • isFolder = If true, it is a folder; false means a file.
    • children = Subfolders and files inside this folder (only for folders)
    • expanded = Is this folder currently open/expanded in the UI?
    • region = Optional metadata, region tags (only for top-level folders).
  • IState = The component’s internal state, data that can change over time.
    • tree = The entire folder tree (array of root-level nodes)
    • loading = true while we’re fetching data from SharePoint
    • selectedMenu = Which left nav item is currently selected
  • constructor() = To initilize teh state with empty values.
  • componentDidMount() = A React lifecycle method. It runs ONCE, right after the component first appears on screen. This is the perfect place to fetch data from an API.
    • siteUrl =  Get the current site’s URL.
    • libraryUrl = Build the full path to the document library. Provide your library name to this variable.
    • tree = Fetch the entire folder tree recursively.
    • setState = Save the tree to the state and stop showing “Loading…”
  • getFolderTree() = It uses recursion to build the folder tree. Recursion means a function that calls itself. Parameters:
    • folderUrl = The SharePoint path of the folder to scan
    • level = How deep we are (0 = top-level, 1 = first subfolder, etc.)
    • Returns = An array of IFolderNode objects (the tree branch)
    • folder = Get a reference to this folder using PnP/SP
    • subFolders =  Fetch all subfolders inside this folder
    • files =  Fetch all files inside this folder
    • nodes = This array will hold all the nodes (folders + files) we find
    • children = This will keep going deeper until it hits a folder with no subfolders
    • nodes.push({..}) = Add this folder to our nodes array.
  • toggleFolder =  When a user clicks on a folder, we need to flip its “expanded” state (open -> closed, or closed -> open).
    • This inner function recursively searches the tree for the matching node.
    • Update the state with the modified tree (React will re-render automatically)
  • INavLinkGroup = This defines the sidebar menu items using Fluent UI’s Nav component.
    • Each item has a name, icon, and key.
    • Only “Documents” actually shows the folder tree — the rest are placeholders.
  • onNavClick = Fired when the user clicks a menu item in the left nav.
    • Updates the selectedMenu state, which controls what content is shown on the right.
  • renderContent() =  Decides what to show based on which menu item is selected. Only “Documents” renders the folder tree, everything else shows a placeholder.
  • renderNode() = This is the recursive rendering function.
    • It draws one node (folder or file) and, if it’s an expanded folder,
    • calls itself for each child, creating the nested tree effect.
    • Parameters:
      • node = The folder/file to render
      • isLast = Is this the last item in its parent? (affects the tree line styling)
  • getFileIcon() = This function gets the icons based on the file types.
  1. Then finally, add the below styles in the .SCSS file.
@import '~@fluentui/react/dist/sass/References.scss';

.folderHierarchy {
  font-family: Segoe UI;   
  font-size: 14px;        
}
.layout {
  display: flex;   
  height: 100%;   
}

.leftNav {
  width: 220px;                        
  border-right: 1px solid #e1e1e1;     
  padding: 10px;
}

.contentArea {
  flex: 1;         
  padding: 16px;
  overflow: auto;   
}

.folder {
  margin-left: 10px;
}

.treeNode {
  position: relative;  
padding-left: 22px;   
}

.treeNode::before {
  content: '';
  position: absolute;    
  top: 0;
  left: 9px;            
  width: 1px;           
  height: 100%;          
  background: #c8c8c8;
}


.treeNode::after {
  content: '';
  position: absolute;
  top: 14px;            
  left: 9px;             
  width: 12px;        
  height: 1px;          
  background: #c8c8c8;
}


.lastNode::before {
  height: 14px;  
}

.folderHeader {
  display: flex;           
  align-items: center;     
  cursor: pointer;      
  font-weight: 600;      
  padding: 4px 0;       
}


.expandIconCollapsed {
  width: 16px;             
  margin-right: 4px;
  color: #bdbdbd;          
  font-weight: normal;
}

.expandIconExpanded {
  width: 16px;
  display: inline-block;
  margin-right: 4px;
  color: #000;            
  font-weight: bold;
}

.expandIconPlaceholder {
  width: 16px;
  display: inline-block;
  margin-right: 4px;
}



.folderIcon {
  font-size: 18px;
  color: #605e5c;       
  margin-right: 6px;       
}

.folderName {
  white-space: nowrap;   
}


.children {
  margin-left: 22px;                
  padding-left: 10px;
  border-left: 1px dashed #c8c8c8;   
}


.file {
  margin-left: 36px;
  padding: 2px 0;
}

.fileIcon {
  margin-right: 6px;     
}

.fileLink {
  color: #1a73e8;         
  text-decoration: none;   
  cursor: pointer;
  font-size: 15px;
}

.fileLink:hover {
  text-decoration: underline;
}
.folderIcon,
.fileIcon {
  font-size: 18px;
  margin-right: 6px;
}

.folderName,
.fileLink {
  font-size: 15px;
}

.pdf {
  color: #d13438;
  font-size: 18px;
  margin-right: 6px;
}

.excel {
  color: #107c41;
  font-size: 18px;
  margin-right: 6px;
}

.word {
  color: #2b579a;
  font-size: 18px;
  margin-right: 6px;
}

.ppt {
  color: #c43e1c;
  font-size: 18px;
  margin-right: 6px;
}

.image {
  color: #605e5c;
  font-size: 18px;
  margin-right: 6px;
}

.video {
  color: #5c2d91;
  font-size: 18px;
  margin-right: 6px;
}

.defaultFile {
  color: #605e5c;
  font-size: 18px;
  margin-right: 6px;
}


.regionText {
  margin-left: 6px;
  font-size: 12px;        
  color: #6b6b6b;         
  font-weight: normal;    
}

That’s then save changes and the gulp serve command, test it in local workbench. You’ll able to see the folders heirarchy on SPFx web part.

Conclusion

I hope you found this article helpful.

In this article, we built a custom SPFx web part to display folders and subfolders from a SharePoint document library in a structured tree view format. Since SharePoint Online does not provide a true hierarchical tree view by default, creating a custom solution gives you full control over the structure, behavior, and user experience.

This approach is especially useful when you’re migrating content from legacy file servers and users expect to navigate folders in a familiar hierarchical format.

If you have a similar requirement in your SharePoint environment, you can follow the steps in this guide and implement your own folder tree view using SPFx and React.

Also, you mey like:

Power Apps functions free pdf

30 Power Apps Functions

This free guide walks you through the 30 most-used Power Apps functions with real business examples, exact syntax, and results you can see.

Download User registration canvas app

DOWNLOAD USER REGISTRATION POWER APPS CANVAS APP

Download a fully functional Power Apps Canvas App (with Power Automate): User Registration App