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
- Lazy loading - Load heavy components on demand
- Caching - Cache API responses
- Pagination - Load data in chunks
- 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.