If you’ve been building SPFx solutions for a while, you’ve probably heard of PnPjs. And if you haven’t started using it yet, you’re making your life harder than it needs to be. PnPjs is an open-source collection of Fluent libraries that wraps the SharePoint REST API and parts of the Microsoft Graph API into clean, chainable, type-safe calls. No more wrestling with raw fetch requests or manually building query strings.
In this post I’ll walk you through everything you need to know to get started: which version to use, how to set it up, and the three main ways to structure your code.
Which version of PnPjs do I need?
Before writing a single line of code, you need to pick the right version. Get this wrong and you’ll waste time chasing weird errors.
Version 2 is what you need if you’re running SharePoint on-premises (2016 or 2019), or if your SPFx version is older than 1.12.1. For everything modern and cloud-based, look at v3 or v4.
Version 3 supports SPFx 1.12.1 through 1.17.4. If you’re on 1.12.1 through 1.14.0, there are some extra configuration steps required to make TypeScript 4.x work. The full instructions are at pnp.github.io/pnpjs/getting-started. From 1.15.0 onwards, v3 just works.
Version 4 is the current latest version and is what this post is written for. It requires Node.js 18 and therefore only works with SPFx 1.18.0 and later. The good news: the API is essentially identical to v3, so upgrading is straightforward.
Installation
Two packages cover most use cases:
npm install @pnp/sp @pnp/graph --save
@pnp/sp gives you access to the SharePoint REST API, and @pnp/graph covers the Microsoft Graph API. While you’re at it, also add the logging package – you’ll thank yourself during development:
npm install @pnp/logging --save
Understanding context
The biggest conceptual shift from PnPjs v2 to v3/v4 is how you set up context. In v2, there was a global sp.setup() call you’d do once and forget about. That pattern is gone.
In v3/v4, you create instances using factory interfaces: spfi for SharePoint and graphfi for Microsoft Graph. Both need the SPFx context object to know who you are, which tenant you’re in, and how to authenticate requests.
There are three ways to structure this in your project. I’ll walk through all three.
Option 1: As a local variable
The simplest approach. Define your spfi instance directly in onInit() and use it from there. This works fine for small, self-contained web parts where you don’t have a lot of files making API calls.
import { spfi, SPFx } from "@pnp/sp";import "@pnp/sp/webs";import "@pnp/sp/lists";import "@pnp/sp/items";export default class HelloWorldWebPart extends BaseClientSideWebPart<IHelloWorldWebPartProps> { protected async onInit(): Promise<void> { await super.onInit(); const sp = spfi().using(SPFx(this.context)); // Use sp directly here, or pass it to your component via props const lists = await sp.web.lists(); console.log(lists); }}
If you need both SharePoint and Graph in the same project, you have to alias the SPFx import. Both @pnp/sp and @pnp/graph export a behavior with that exact name, so without the alias the TypeScript compiler will complain:
import { spfi, SPFx as spSPFx } from "@pnp/sp";import { graphfi, SPFx as graphSPFx } from "@pnp/graph";protected async onInit(): Promise<void> { await super.onInit(); const sp = spfi().using(spSPFx(this.context)); const graph = graphfi().using(graphSPFx(this.context));}
This approach is quick to set up, but it gets messy as soon as you start passing the sp object down through multiple layers of React components via props. For anything bigger than a single-file web part, use option 2 or 3.
Option 2: Using a configuration file
This is my go-to for most projects. You create a central pnpjs-config.ts file in your src folder that handles all setup and exports a getSP() function. Any file in your project that needs to make API calls just imports and calls that function — no passing context around, no prop drilling.
Step 1: Create src/pnpjs-config.ts
import { WebPartContext } from "@microsoft/sp-webpart-base";import { spfi, SPFI, SPFx } from "@pnp/sp";import { LogLevel, PnPLogging } from "@pnp/logging";import "@pnp/sp/webs";import "@pnp/sp/lists";import "@pnp/sp/items";import "@pnp/sp/batching";var _sp: SPFI | null = null;export const getSP = (context?: WebPartContext): SPFI => { if (context != null) { _sp = spfi().using(SPFx(context)).using(PnPLogging(LogLevel.Warning)); } return _sp;};
A few things worth noting here. The selective imports, @pnp/sp/webs, @pnp/sp/lists, etc., matter a lot in v3/v4. PnPjs uses tree shaking, so only what you explicitly import gets bundled. Don’t import everything blindly. Import only what you need and keep your bundle lean.
The PnPLogging behavior adds API call logging to the browser console. Useful during development, but lower the level or remove it entirely before going to production.
Step 2: Initialize from onInit in your web part
// HelloWorldWebPart.tsimport { getSP } from "./pnpjs-config";import { SPFI } from "@pnp/sp";export default class HelloWorldWebPart extends BaseClientSideWebPart<IHelloWorldWebPartProps> { private _sp: SPFI; protected async onInit(): Promise<void> { await super.onInit(); this._sp = getSP(this.context); // Initialize once with context } public render(): void { // ... your render code }}
The pattern is simple: the first call to getSP(this.context) creates and stores the instance. Every subsequent call to getSP() without arguments returns that same stored instance.
If you also need Graph, extend the config file with a getGraph() function. Because of the naming conflict mentioned earlier, you’ll need to alias the SPFx imports:
import { WebPartContext } from "@microsoft/sp-webpart-base";import { spfi, SPFI, SPFx as spSPFx } from "@pnp/sp";import { graphfi, GraphFI, SPFx as graphSPFx } from "@pnp/graph";import { LogLevel, PnPLogging } from "@pnp/logging";import "@pnp/sp/webs";import "@pnp/sp/lists";import "@pnp/sp/items";import "@pnp/sp/batching";var _sp: SPFI | null = null;var _graph: GraphFI | null = null;export const getSP = (context?: WebPartContext): SPFI => { if (context != null) { _sp = spfi().using(spSPFx(context)).using(PnPLogging(LogLevel.Warning)); } return _sp;};export const getGraph = (context?: WebPartContext): GraphFI => { if (context != null) { _graph = graphfi().using(graphSPFx(context)).using(PnPLogging(LogLevel.Warning)); } return _graph;};
Then initialize both in onInit:
protected async onInit(): Promise<void> { await super.onInit(); getSP(this.context); getGraph(this.context);}
Option 3: Using a service class
For larger, more complex projects where you want proper dependency injection and a clean separation between your API layer and your UI layer, a service class is the right pattern.
The main difference here is that a service doesn’t have direct access to this.context from the web part. Instead, it works with ServiceScope, the SPFx dependency injection container, and consumes PageContext and AadTokenProviderFactory from it.
The service class:
// services/SampleService.tsimport { ServiceKey, ServiceScope } from "@microsoft/sp-core-library";import { PageContext } from "@microsoft/sp-page-context";import { AadTokenProviderFactory } from "@microsoft/sp-http";import { spfi, SPFI, SPFx as spSPFx } from "@pnp/sp";import { graphfi, GraphFI, SPFx as gSPFx } from "@pnp/graph";import "@pnp/sp/webs";import "@pnp/sp/lists";export interface ISampleService { getLists(): Promise<any[]>;}export class SampleService implements ISampleService { public static readonly serviceKey: ServiceKey<ISampleService> = ServiceKey.create<ISampleService>("SPFx:SampleService", SampleService); private _sp: SPFI; private _graph: GraphFI; constructor(serviceScope: ServiceScope) { serviceScope.whenFinished(() => { const pageContext = serviceScope.consume(PageContext.serviceKey); const aadTokenProviderFactory = serviceScope.consume(AadTokenProviderFactory.serviceKey); this._sp = spfi().using(spSPFx({ pageContext })); this._graph = graphfi().using(gSPFx({ aadTokenProviderFactory })); }); } public getLists(): Promise<any[]> { return this._sp.web.lists(); }}
Using it in your web part:
// HelloWorldWebPart.tsimport { SampleService, ISampleService } from "./services/SampleService";export default class HelloWorldWebPart extends BaseClientSideWebPart<IHelloWorldWebPartProps> { private _sampleService: ISampleService; protected async onInit(): Promise<void> { await super.onInit(); this._sampleService = this.context.serviceScope.consume(SampleService.serviceKey); } public render(): void { // Pass _sampleService to your component via props }}
Which option should you use?
Here’s a simple way to think about it:
- Local variable → Small web parts, quick prototypes, or when you only need to call the API in one or two places.
- Config file → Most projects. Clean, simple, no boilerplate, easy to understand.
- Service class → Larger projects that need proper layering, dependency injection, unit testing, or multiple web parts sharing the same service logic.

