SharePoint Framework (SPFx): Building Custom Solutions

SharePoint Framework (SPFx): Building Custom Solutions

Introduction

SharePoint Framework (SPFx) is the modern development model for building SharePoint customizations. This guide covers SPFx architecture, creating web parts and extensions, React integration, Microsoft Graph API calls, packaging, deployment, and best practices for enterprise solutions.

SPFx Overview

What is SPFx?

SharePoint Framework enables client-side development with:

  • JavaScript/TypeScript - Modern web technologies
  • React/Angular/Vue - Popular frameworks supported
  • Responsive design - Mobile-friendly by default
  • Microsoft Graph - Access to Microsoft 365 data
  • Tenant-wide deployment - Deploy once, use everywhere
  • No full-trust code - Secure, sandboxed execution

SPFx vs. Traditional Development

Feature SPFx Traditional (Full Trust)
Execution Client-side Server-side
Hosting CDN/SharePoint SharePoint farm
Framework React/Angular/Vue Server controls
Deployment App catalog Farm solutions
Security Sandboxed Full trust
Modern UI Yes No

Setting Up Development Environment

Prerequisites

# Node.js (LTS version 18.x recommended)
node --version  # Should show v18.x.x

# Install Yeoman and SPFx generator
npm install -g yo
npm install -g @microsoft/generator-sharepoint

# Install Gulp (build tool)
npm install -g gulp-cli

# Verify installations
yo --version
gulp --version

Create First Web Part

# Create project directory
mkdir hello-world-webpart
cd hello-world-webpart

# Run SPFx generator
yo @microsoft/sharepoint

# Answer prompts:
# - Solution name: hello-world-webpart
# - Baseline: SharePoint Online only
# - Location: Use current folder
# - Tenant-admin: No
# - Component type: WebPart
# - Name: HelloWorld
# - Description: My first SPFx web part
# - Framework: React

# Install dependencies
npm install

# Start development server
gulp serve

Building Web Parts

Basic Web Part Structure

// src/webparts/helloWorld/HelloWorldWebPart.ts
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';

import HelloWorld from './components/HelloWorld';
import { IHelloWorldProps } from './components/IHelloWorldProps';

export interface IHelloWorldWebPartProps {
  description: string;
  title: string;
}

export default class HelloWorldWebPart extends BaseClientSideWebPart<IHelloWorldWebPartProps> {
  
  public render(): void {
    const element: React.ReactElement<IHelloWorldProps> = React.createElement(
      HelloWorld,
      {
        description: this.properties.description,
        title: this.properties.title,
        context: this.context,
        isDarkTheme: this._isDarkTheme,
        environmentMessage: this._environmentMessage,
        hasTeamsContext: !!this.context.sdks.microsoftTeams,
        userDisplayName: this.context.pageContext.user.displayName
      }
    );

    ReactDom.render(element, this.domElement);
  }

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

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

  protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
    return {
      pages: [
        {
          header: {
            description: 'Configure your web part'
          },
          groups: [
            {
              groupName: 'Settings',
              groupFields: [
                PropertyPaneTextField('title', {
                  label: 'Title'
                }),
                PropertyPaneTextField('description', {
                  label: 'Description'
                })
              ]
            }
          ]
        }
      ]
    };
  }
}

React Component

// src/webparts/helloWorld/components/HelloWorld.tsx
import * as React from 'react';
import styles from './HelloWorld.module.scss';
import { IHelloWorldProps } from './IHelloWorldProps';
import { PrimaryButton } from '@fluentui/react/lib/Button';
import { TextField } from '@fluentui/react/lib/TextField';

export interface IHelloWorldState {
  name: string;
  greeting: string;
}

export default class HelloWorld extends React.Component<IHelloWorldProps, IHelloWorldState> {
  
  constructor(props: IHelloWorldProps) {
    super(props);
    
    this.state = {
      name: '',
      greeting: ''
    };
  }

  private handleNameChange = (event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string): void => {
    this.setState({ name: newValue || '' });
  }

  private handleGreet = (): void => {
    this.setState({ greeting: `Hello, ${this.state.name}!` });
  }

  public render(): React.ReactElement<IHelloWorldProps> {
    return (
      <div className={styles.helloWorld}>
        <div className={styles.container}>
          <div className={styles.row}>
            <div className={styles.column}>
              <h2>{this.props.title}</h2>
              <p>{this.props.description}</p>
              
              <TextField 
                label="Enter your name"
                value={this.state.name}
                onChange={this.handleNameChange}
              />
              
              <PrimaryButton 
                text="Greet Me" 
                onClick={this.handleGreet}
              />
              
              {this.state.greeting && (
                <div className={styles.greeting}>
                  <h3>{this.state.greeting}</h3>
                </div>
              )}
              
              <p>Current user: {this.props.userDisplayName}</p>
            </div>
          </div>
        </div>
      </div>
    );
  }
}

Styling with SCSS

// src/webparts/helloWorld/components/HelloWorld.module.scss
.helloWorld {
  .container {
    max-width: 700px;
    margin: 0px auto;
    padding: 20px;
    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
  }

  .row {
    padding: 20px;
  }

  .column {
    .greeting {
      margin-top: 20px;
      padding: 15px;
      background-color: #f3f2f1;
      border-left: 4px solid #0078d4;
      
      h3 {
        margin: 0;
        color: #0078d4;
      }
    }
  }
}

SharePoint REST API Integration

Read List Items

// src/services/SharePointService.ts
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';

export interface IListItem {
  Id: number;
  Title: string;
  Description: string;
  Modified: string;
}

export class SharePointService {
  
  public static async getListItems(context: WebPartContext, listTitle: string): Promise<IListItem[]> {
    const endpoint = `${context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${listTitle}')/items?$select=Id,Title,Description,Modified&$orderby=Modified desc&$top=100`;
    
    const response: SPHttpClientResponse = await context.spHttpClient.get(
      endpoint,
      SPHttpClient.configurations.v1
    );
    
    if (!response.ok) {
      throw new Error(`Error fetching list items: ${response.statusText}`);
    }
    
    const data = await response.json();
    return data.value;
  }
  
  public static async createListItem(context: WebPartContext, listTitle: string, item: Partial<IListItem>): Promise<IListItem> {
    const endpoint = `${context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${listTitle}')/items`;
    
    const response: SPHttpClientResponse = await context.spHttpClient.post(
      endpoint,
      SPHttpClient.configurations.v1,
      {
        headers: {
          'Accept': 'application/json;odata=nometadata',
          'Content-Type': 'application/json;odata=nometadata'
        },
        body: JSON.stringify(item)
      }
    );
    
    if (!response.ok) {
      throw new Error(`Error creating item: ${response.statusText}`);
    }
    
    return await response.json();
  }
  
  public static async updateListItem(context: WebPartContext, listTitle: string, itemId: number, updates: Partial<IListItem>): Promise<void> {
    const endpoint = `${context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${listTitle}')/items(${itemId})`;
    
    const response: SPHttpClientResponse = await context.spHttpClient.post(
      endpoint,
      SPHttpClient.configurations.v1,
      {
        headers: {
          'Accept': 'application/json;odata=nometadata',
          'Content-Type': 'application/json;odata=nometadata',
          'IF-MATCH': '*',
          'X-HTTP-Method': 'MERGE'
        },
        body: JSON.stringify(updates)
      }
    );
    
    if (!response.ok) {
      throw new Error(`Error updating item: ${response.statusText}`);
    }
  }
  
  public static async deleteListItem(context: WebPartContext, listTitle: string, itemId: number): Promise<void> {
    const endpoint = `${context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${listTitle}')/items(${itemId})`;
    
    const response: SPHttpClientResponse = await context.spHttpClient.post(
      endpoint,
      SPHttpClient.configurations.v1,
      {
        headers: {
          'Accept': 'application/json;odata=nometadata',
          'IF-MATCH': '*',
          'X-HTTP-Method': 'DELETE'
        }
      }
    );
    
    if (!response.ok) {
      throw new Error(`Error deleting item: ${response.statusText}`);
    }
  }
}

Use in Component

import * as React from 'react';
import { SharePointService, IListItem } from '../services/SharePointService';
import { IListViewProps } from './IListViewProps';
import { DetailsList, IColumn } from '@fluentui/react/lib/DetailsList';
import { Spinner, SpinnerSize } from '@fluentui/react/lib/Spinner';

export interface IListViewState {
  items: IListItem[];
  loading: boolean;
  error: string;
}

export default class ListView extends React.Component<IListViewProps, IListViewState> {
  
  private columns: IColumn[] = [
    { key: 'Title', name: 'Title', fieldName: 'Title', minWidth: 200, maxWidth: 300 },
    { key: 'Description', name: 'Description', fieldName: 'Description', minWidth: 300 },
    { key: 'Modified', name: 'Modified', fieldName: 'Modified', minWidth: 150 }
  ];
  
  constructor(props: IListViewProps) {
    super(props);
    
    this.state = {
      items: [],
      loading: true,
      error: ''
    };
  }
  
  public async componentDidMount(): Promise<void> {
    await this.loadItems();
  }
  
  private async loadItems(): Promise<void> {
    try {
      const items = await SharePointService.getListItems(this.props.context, this.props.listName);
      this.setState({ items, loading: false });
    } catch (error) {
      this.setState({ error: error.message, loading: false });
    }
  }
  
  public render(): React.ReactElement<IListViewProps> {
    const { items, loading, error } = this.state;
    
    if (loading) {
      return <Spinner size={SpinnerSize.large} label="Loading items..." />;
    }
    
    if (error) {
      return <div>Error: {error}</div>;
    }
    
    return (
      <DetailsList
        items={items}
        columns={this.columns}
        setKey="set"
        selectionMode={0}
      />
    );
  }
}

Microsoft Graph API Integration

Configure API Permissions

// config/package-solution.json
{
  "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
  "solution": {
    "name": "my-solution",
    "id": "12345678-1234-1234-1234-123456789abc",
    "version": "1.0.0.0",
    "webApiPermissionRequests": [
      {
        "resource": "Microsoft Graph",
        "scope": "User.Read.All"
      },
      {
        "resource": "Microsoft Graph",
        "scope": "Mail.Read"
      }
    ]
  }
}

Call Microsoft Graph

import { MSGraphClientV3 } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';

export interface IUser {
  displayName: string;
  mail: string;
  jobTitle: string;
  department: string;
}

export class GraphService {
  
  public static async getCurrentUser(context: WebPartContext): Promise<IUser> {
    const client: MSGraphClientV3 = await context.msGraphClientFactory.getClient('3');
    
    const user = await client
      .api('/me')
      .select('displayName,mail,jobTitle,department')
      .get();
    
    return user;
  }
  
  public static async getUsers(context: WebPartContext): Promise<IUser[]> {
    const client: MSGraphClientV3 = await context.msGraphClientFactory.getClient('3');
    
    const response = await client
      .api('/users')
      .top(50)
      .select('displayName,mail,jobTitle,department')
      .orderby('displayName')
      .get();
    
    return response.value;
  }
  
  public static async getUserPhoto(context: WebPartContext, userId: string): Promise<string> {
    const client: MSGraphClientV3 = await context.msGraphClientFactory.getClient('3');
    
    try {
      const photoBlob = await client
        .api(`/users/${userId}/photo/$value`)
        .get();
      
      const url = window.URL.createObjectURL(photoBlob);
      return url;
    } catch (error) {
      console.warn('No photo available for user');
      return '';
    }
  }
}

SPFx Extensions

Application Customizer

Header/footer customization:

// src/extensions/header/HeaderApplicationCustomizer.ts
import { override } from '@microsoft/decorators';
import { BaseApplicationCustomizer, PlaceholderContent, PlaceholderName } from '@microsoft/sp-application-base';

export default class HeaderApplicationCustomizer extends BaseApplicationCustomizer<{}> {
  
  private topPlaceholder: PlaceholderContent | undefined;
  
  @override
  public onInit(): Promise<void> {
    this.context.placeholderProvider.changedEvent.add(this, this.renderPlaceHolders);
    this.renderPlaceHolders();
    
    return Promise.resolve();
  }
  
  private renderPlaceHolders(): void {
    if (!this.topPlaceholder) {
      this.topPlaceholder = this.context.placeholderProvider.tryCreateContent(
        PlaceholderName.Top,
        { onDispose: this.onDispose }
      );
      
      if (this.topPlaceholder) {
        this.topPlaceholder.domElement.innerHTML = `
          <div style="background-color: #0078d4; color: white; padding: 10px; text-align: center;">
            <strong>Company Announcement:</strong> System maintenance on Saturday 10pm-2am
          </div>
        `;
      }
    }
  }
}

Field Customizer

Custom field rendering:

// src/extensions/statusField/StatusFieldCustomizer.ts
import { override } from '@microsoft/decorators';
import { BaseFieldCustomizer, IFieldCustomizerCellEventParameters } from '@microsoft/sp-listview-extensibility';

export default class StatusFieldCustomizer extends BaseFieldCustomizer<{}> {
  
  @override
  public onRenderCell(event: IFieldCustomizerCellEventParameters): void {
    const status = event.fieldValue as string;
    let color = '#605e5c';
    
    switch (status?.toLowerCase()) {
      case 'approved':
        color = '#107c10';
        break;
      case 'pending':
        color = '#ffa500';
        break;
      case 'rejected':
        color = '#d13438';
        break;
    }
    
    event.domElement.innerHTML = `
      <div style="
        display: inline-block;
        padding: 4px 12px;
        border-radius: 12px;
        background-color: ${color};
        color: white;
        font-weight: 600;
        font-size: 12px;
      ">
        ${status}
      </div>
    `;
  }
}

Command Set

Custom list item actions:

// src/extensions/customCommands/CustomCommandsCommandSet.ts
import { override } from '@microsoft/decorators';
import { BaseListViewCommandSet, IListViewCommandSetExecuteEventParameters } from '@microsoft/sp-listview-extensibility';

export default class CustomCommandsCommandSet extends BaseListViewCommandSet<{}> {
  
  @override
  public onExecute(event: IListViewCommandSetExecuteEventParameters): void {
    switch (event.itemId) {
      case 'ARCHIVE_ITEM':
        this.archiveItem(event.selectedRows[0].getValueByName('ID'));
        break;
      
      case 'EXPORT_ITEM':
        this.exportItem(event.selectedRows[0]);
        break;
    }
  }
  
  private async archiveItem(itemId: number): Promise<void> {
    // Archive logic
    console.log(`Archiving item ${itemId}`);
  }
  
  private exportItem(item: any): void {
    // Export logic
    console.log('Exporting item:', item);
  }
}

Packaging and Deployment

Build Package

# Update version in package-solution.json
# Increment version: 1.0.0.0 → 1.0.1.0

# Bundle solution
gulp bundle --ship

# Package solution
gulp package-solution --ship

# Output: sharepoint/solution/*.sppkg

Deploy to App Catalog

# Connect to tenant
Connect-PnPOnline -Url "https://contoso-admin.sharepoint.com" -Interactive

# Upload to app catalog
Add-PnPApp -Path ".\sharepoint\solution\my-solution.sppkg" -Scope Tenant -Overwrite -Publish

# Deploy tenant-wide (optional)
Update-PnPApp -Identity "my-solution-client-side-solution" -Scope Tenant

Add to Site

# Connect to site
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/teamsite" -Interactive

# Install app
Install-PnPApp -Identity "my-solution-client-side-solution"

Best Practices

Performance

  1. Lazy loading - Load heavy components on demand
  2. Caching - Cache API responses
  3. Pagination - Load data in chunks
  4. Bundle optimization - Tree shaking, code splitting
// Lazy load component
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

function MyComponent() {
  return (
    <React.Suspense fallback={<Spinner />}>
      <HeavyComponent />
    </React.Suspense>
  );
}

Error Handling

try {
  const items = await SharePointService.getListItems(context, 'Tasks');
  this.setState({ items });
} catch (error) {
  console.error('Error loading items:', error);
  this.setState({ 
    error: 'Failed to load items. Please try again.' 
  });
}

Accessibility

<PrimaryButton 
  text="Submit"
  ariaLabel="Submit form"
  onClick={this.handleSubmit}
/>

<TextField
  label="Name"
  ariaLabel="Enter your name"
  required
/>

Key Takeaways

  • SPFx enables modern client-side SharePoint development
  • React integration provides component-based architecture
  • SharePoint REST API and Microsoft Graph access data
  • Extensions customize SharePoint UI without code deployment
  • Tenant-wide deployment simplifies distribution
  • Follow best practices for performance and accessibility

Next Steps

  • Build custom web parts for common scenarios
  • Explore PnP SPFx controls and reusable components
  • Implement CI/CD pipeline for automated deployment
  • Create extension libraries for reusable code

Additional Resources


Build modern. Deploy seamlessly.