Our sales manager asked for one simple thing: the latest orders on the team’s SharePoint home page, in a clean table she could sort by total or date, without opening the list itself. A list view web part came close, but she wanted currency formatting, an order count and no list toolbar.
So we’ll build it. The finished SPFx web part reads the SPGDemo Orders list with PnPjs, shows the rows in a Fluent UI DetailsList with sortable headers, and lets an editor point it at another list from the property pane.
In this tutorial, we’ll scaffold the project with Heft, read the items with PnPjs v4, render them in a DetailsList, test it in the SPGuides workbench with heft start and package it. It’s the full path to display SharePoint list items in SPFx web part projects, plus the same query with SPHttpClient.
Here’s what we’re building:

| Tested with | Version |
|---|---|
| SharePoint Framework (SPFx) | 1.23.2 with the Heft toolchain |
| Node.js | 22 LTS |
| React / Fluent UI | 17.0.1 / @fluentui/react 8 |
| PnPjs | @pnp/sp 4.22.0 |
Before You Start
You need a working SPFx 1.23 setup (Node.js 22, Yeoman, the SharePoint generator and Heft) and edit rights on a SharePoint site. If your machine isn’t ready, follow setting up an SPFx project with the Heft toolchain first. Reading list items only needs read permission on the list, so no API approval is involved.
Our data lives in a list called SPGDemo Orders on https://spguides.sharepoint.com/sites/SPGuides. It has 210 orders and these columns:
| Column (internal name) | Type | Notes |
|---|---|---|
| Title | Single line of text | Order number, for example SO-1102 |
| CustomerName | Single line of text | Adventure Works, Fabrikam Inc., Woodgrove Bank |
| Quantity | Number | Units ordered |
| OrderTotal | Currency | USD, for example $1,890.00 |
| OrderDate | Date only | For example 1/27/2026 |
| OrderStatus | Choice | New, Processing, Shipped, Delivered |
| SalesRep | Person or Group | Needs $expand to read the name |
| Product | Lookup | Points to SPGDemo Products (Laptop Stand, Wireless Mouse, USB-C Dock, Monitor Arm) |

Use internal names, not display names, in your queries. A column renamed after it was created keeps its original internal name; binding list items to a Fluent UI dropdown runs into the same rule.
Step 1: Create the SPFx React Web Part Project
Open a terminal (I use the one inside Visual Studio Code), create a folder and run the SharePoint generator. If this is your first web part ever, building your first SPFx client-side web part explains every generated file:
md spg-orders-table
cd spg-orders-table
yo @microsoft/sharepoint
Answer the prompts like this:
- What is your solution name? spg-orders-table
- Which type of client-side component to create? WebPart
- What is your Web part name? OrdersTable
- Which template would you like to use? React
If you script your setups, the same answers fit on one line:
yo @microsoft/sharepoint --solution-name spg-orders-table --component-type webpart --component-name OrdersTable --framework react --environment spo
The generator installs the packages for you (about 5 minutes here). Older posts and projects used gulp serve; since SPFx 1.22 the toolchain is Heft, and new projects get npm run start and npm run build scripts that call it. Have an old gulp project? See migrating from gulp to Heft.
Checkpoint: package.json lists @microsoft/sp-webpart-base 1.23.2, react 17.0.1 and @fluentui/react, and the src/webparts/ordersTable folder exists.
Step 2: Install PnPjs v4
PnPjs wraps the SharePoint REST API in a fluent, typed syntax. Install the SharePoint package with an exact version, so everyone on the team builds the same bundle:
npm install @pnp/sp@4.22.0 --save-exact
Version 4 works with SPFx 1.18 and later without extra configuration. Skip the old sp-pnp-js package and the @pnp/sp/presets/all import that many older samples show; v4 uses small selective imports instead.
Then add two folders under src/webparts/ordersTable: models and services. Here’s the finished file tree in VS Code:

Step 3: Define the Order Model
Two interfaces keep the code honest: one for the raw item SharePoint sends back, and one flat shape for the table. Create src/webparts/ordersTable/models/IOrder.ts:
// One row of the SPGDemo Orders list, flattened for the table.
export interface IOrder {
id: number;
orderNumber: string;
customer: string;
product: string;
quantity: number;
orderTotal: number;
orderDate: Date | undefined;
orderDateText: string;
status: string;
salesRep: string;
}
// Raw item shape that SharePoint returns for our $select and $expand.
// Empty columns come back as null, so every column except Id and Title is optional.
export interface IOrderItem {
Id: number;
Title: string;
CustomerName?: string;
Quantity?: number;
OrderTotal?: number;
OrderDate?: string;
OrderStatus?: string;
Product?: { Title?: string };
SalesRep?: { Title?: string };
FieldValuesAsText?: { OrderDate?: string };
}
The lookup and person columns arrive as nested objects (Product.Title, SalesRep.Title). Flattening them once in the service means the table never has to care.
Step 4: Read the List Items with PnPjs
Next comes the data layer. Create src/webparts/ordersTable/services/OrdersService.ts. It also holds the SPHttpClient version we’ll use later, so here’s the whole file:
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { spfi, SPFI, SPFx } from '@pnp/sp';
import { HttpRequestError } from '@pnp/queryable';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import { IOrder, IOrderItem } from '../models/IOrder';
const SELECT: string[] = [
'Id', 'Title', 'CustomerName', 'Quantity', 'OrderTotal', 'OrderDate',
'OrderStatus', 'Product/Title', 'SalesRep/Title', 'FieldValuesAsText/OrderDate'
];
// FieldValuesAsText returns each value formatted the way the list shows it (site time zone and locale).
const EXPAND: string[] = ['Product', 'SalesRep', 'FieldValuesAsText'];
export class OrdersService {
private _sp: SPFI;
private _context: WebPartContext;
constructor(context: WebPartContext) {
this._context = context;
// One PnPjs instance per web part, wired to the SPFx context (auth + site URL).
this._sp = spfi().using(SPFx(context));
}
// PnPjs v4: newest orders first, only the columns the table needs.
public async getOrders(listTitle: string, top: number): Promise<IOrder[]> {
try {
const items: IOrderItem[] = await this._sp.web.lists
.getByTitle(listTitle)
.items.select(...SELECT)
.expand(...EXPAND)
.orderBy('OrderDate', false)
.top(top)();
return items.map(OrdersService.toOrder);
} catch (e) {
const err = e as HttpRequestError;
throw new Error(err?.isHttpRequestError ? await OrdersService.errorText(err.response) : (e as Error).message);
}
}
// The same query with SPHttpClient and the SharePoint REST API (no extra package).
public async getOrdersRest(listTitle: string, top: number): Promise<IOrder[]> {
const list: string = encodeURIComponent(listTitle.replace(/'/g, "''"));
const url: string = `${this._context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${list}')/items` +
`?$select=${SELECT.join(',')}&$expand=${EXPAND.join(',')}&$orderby=OrderDate desc&$top=${top}`;
const response: SPHttpClientResponse = await this._context.spHttpClient.get(url, SPHttpClient.configurations.v1);
if (!response.ok) {
throw new Error(await OrdersService.errorText(response));
}
const json: { value: IOrderItem[] } = await response.json();
return json.value.map(OrdersService.toOrder);
}
// SharePoint puts the readable message in odata.error.message.value.
private static async errorText(response: { status: number; json(): Promise<unknown> }): Promise<string> {
try {
const body = (await response.json()) as { 'odata.error'?: { message?: { value?: string } } };
return body['odata.error']?.message?.value || `Request failed with status ${response.status}`;
} catch {
return `Request failed with status ${response.status}`;
}
}
private static toOrder(item: IOrderItem): IOrder {
return {
id: item.Id,
orderNumber: item.Title,
customer: item.CustomerName || '',
product: item.Product?.Title || '',
quantity: item.Quantity || 0,
orderTotal: item.OrderTotal || 0,
orderDate: item.OrderDate ? new Date(item.OrderDate) : undefined,
orderDateText: item.FieldValuesAsText?.OrderDate || '',
status: item.OrderStatus || '',
salesRep: item.SalesRep?.Title || ''
};
}
}
Here’s what the PnPjs query does:
spfi().using(SPFx(context))creates one PnPjs instance that borrows the web part’s sign-in and site URL. Create it once and reuse it, never inside a click handler.select(...)asks only for the columns we show. Lookup and person columns need theColumn/Titleform plusexpand(...), or SharePoint returns only their IDs.orderBy('OrderDate', false)sorts newest first. The second argument means ascending, sofalseis descending.top(top)limits the number of rows. Without it, SharePoint returns 100 items.FieldValuesAsText/OrderDate(withexpand('FieldValuesAsText')) returns the date exactly as the list displays it, in the site’s time zone. We keep the real date for sorting and show the text.- The
catchblock turns a PnPjsHttpRequestErrorinto SharePoint’s own readable message fromodata.error.@pnp/queryableis installed with@pnp/sp, so there’s nothing extra to add.
Checkpoint: The project still compiles with no TypeScript errors. The service doesn’t render anything yet.
Step 5: Show the Items in a Fluent UI DetailsList
DetailsList is the table control behind SharePoint’s own list views, and it ships with SPFx React projects, so there’s nothing to install. For a simpler stacked layout without columns, see the Fluent UI basic list example.
First, replace the props in src/webparts/ordersTable/components/IOrdersTableProps.ts:
import { OrdersService } from '../services/OrdersService';
export interface IOrdersTableProps {
listTitle: string;
itemLimit: number;
useRest: boolean;
service: OrdersService;
}
Then replace src/webparts/ordersTable/components/OrdersTable.tsx with the component:
import * as React from 'react';
import {
DetailsList, DetailsListLayoutMode, SelectionMode, IColumn,
Spinner, SpinnerSize, MessageBar, MessageBarType
} from '@fluentui/react';
import styles from './OrdersTable.module.scss';
import type { IOrdersTableProps } from './IOrdersTableProps';
import { IOrder } from '../models/IOrder';
const usd: Intl.NumberFormat = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const baseColumns: IColumn[] = [
{ key: 'orderNumber', name: 'Order', fieldName: 'orderNumber', minWidth: 55, maxWidth: 70, isResizable: true },
{ key: 'customer', name: 'Customer', fieldName: 'customer', minWidth: 125, maxWidth: 170, isResizable: true },
{ key: 'product', name: 'Product', fieldName: 'product', minWidth: 85, maxWidth: 120, isResizable: true },
{ key: 'quantity', name: 'Qty', fieldName: 'quantity', minWidth: 25, maxWidth: 40 },
{
key: 'orderTotal', name: 'Total', fieldName: 'orderTotal', minWidth: 70, maxWidth: 100,
onRender: (item: IOrder) => usd.format(item.orderTotal)
},
{
key: 'orderDate', name: 'Date', fieldName: 'orderDate', minWidth: 80, maxWidth: 90,
// Text from FieldValuesAsText = the same date the list shows; sorting still uses the real Date.
onRender: (item: IOrder) => item.orderDateText
},
{ key: 'status', name: 'Status', fieldName: 'status', minWidth: 65, maxWidth: 90 },
{ key: 'salesRep', name: 'Sales Rep', fieldName: 'salesRep', minWidth: 70, maxWidth: 120 }
];
function sortValue(order: IOrder, key: keyof IOrder): string | number | boolean {
const value = order[key];
if (value instanceof Date) return value.getTime();
return value === undefined ? '' : value;
}
function sortOrders(items: IOrder[], key: keyof IOrder, descending: boolean): IOrder[] {
return [...items].sort((a, b) => {
const x = sortValue(a, key);
const y = sortValue(b, key);
if (x === y) return 0;
return (x > y ? 1 : -1) * (descending ? -1 : 1);
});
}
const OrdersTable: React.FC<IOrdersTableProps> = (props) => {
const { listTitle, itemLimit, useRest, service } = props;
const [items, setItems] = React.useState<IOrder[]>([]);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string>('');
const [sortKey, setSortKey] = React.useState<keyof IOrder>('orderDate');
const [descending, setDescending] = React.useState<boolean>(true);
// Reload whenever the list title, item limit or data source changes in the property pane.
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const load = useRest ? service.getOrdersRest(listTitle, itemLimit) : service.getOrders(listTitle, itemLimit);
load
.then((orders) => { if (!cancelled) { setItems(orders); setLoading(false); } })
.catch((err: Error) => { if (!cancelled) { setItems([]); setError(err.message); setLoading(false); } });
return () => { cancelled = true; };
}, [listTitle, itemLimit, useRest, service]);
const onColumnClick = (ev: React.MouseEvent<HTMLElement>, column: IColumn): void => {
const key = column.key as keyof IOrder;
setDescending(key === sortKey ? !descending : false);
setSortKey(key);
};
const columns: IColumn[] = baseColumns.map((c) => ({
...c,
isSorted: c.key === sortKey,
isSortedDescending: c.key === sortKey && descending,
onColumnClick
}));
const sorted: IOrder[] = React.useMemo(() => sortOrders(items, sortKey, descending), [items, sortKey, descending]);
return (
<section className={styles.ordersTable}>
<div className={styles.header}>
<h2 className={styles.title}>{listTitle}</h2>
{!loading && !error && (
<span className={styles.count}>{`${items.length} orders loaded with ${useRest ? 'SPHttpClient' : 'PnPjs'}`}</span>
)}
</div>
{loading && <Spinner size={SpinnerSize.large} label="Loading orders..." />}
{error && (
<MessageBar messageBarType={MessageBarType.error} isMultiline={true}>
{`Couldn't load "${listTitle}": ${error}`}
</MessageBar>
)}
{!loading && !error && (
<DetailsList
items={sorted}
columns={columns}
setKey="id"
layoutMode={DetailsListLayoutMode.justified}
selectionMode={SelectionMode.none}
compact={true}
/>
)}
</section>
);
};
export default OrdersTable;

- Columns: each
IColumnmaps a header to a field.onRenderformats the cell: totals show as $1,890.00, and dates show the list’s own text, such as 1/27/2026. - Sorting: a header click calls
onColumnClick. We flip the direction on a second click and setisSortedso the header shows the arrow. - States: a Spinner shows while the request runs, a MessageBar shows the error text if it fails, and the header shows the item count when it works.
- Reloads: the
useEffectdependencies reload the table when an editor changes the list title, item limit or data source.
Want a different date style? My guide to formatting dates in SPFx covers the options. The styles are small; replace OrdersTable.module.scss with:
.ordersTable {
color: "[theme:bodyText, default: #323130]";
color: var(--bodyText);
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.title {
font-size: 20px;
font-weight: 600;
margin: 0;
}
.count {
font-size: 13px;
color: "[theme:neutralSecondary, default: #605e5c]";
}
The [theme:...] tokens pick up the site’s theme colors, so the text stays readable on dark sections too. To read the theme in code instead, see getting the current theme in SPFx.
Step 6: Wire Up the Web Part and Its Property Pane
The web part class creates the service once in onInit and passes it to React. It also adds three property pane fields: the list title, the number of items and a PnPjs or SPHttpClient switch. Replace src/webparts/ordersTable/OrdersTableWebPart.ts:
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
type IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneSlider,
PropertyPaneToggle
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import * as strings from 'OrdersTableWebPartStrings';
import OrdersTable from './components/OrdersTable';
import { IOrdersTableProps } from './components/IOrdersTableProps';
import { OrdersService } from './services/OrdersService';
export interface IOrdersTableWebPartProps {
listTitle: string;
itemLimit: number;
useRest: boolean;
}
export default class OrdersTableWebPart extends BaseClientSideWebPart<IOrdersTableWebPartProps> {
private _service!: OrdersService;
protected async onInit(): Promise<void> {
await super.onInit();
this._service = new OrdersService(this.context);
}
public render(): void {
const element: React.ReactElement<IOrdersTableProps> = React.createElement(OrdersTable, {
listTitle: this.properties.listTitle || 'SPGDemo Orders',
itemLimit: this.properties.itemLimit || 100,
useRest: !!this.properties.useRest,
service: this._service
});
ReactDom.render(element, this.domElement);
}
// Show an Apply button so the list doesn't reload on every keystroke in the list title box.
protected get disableReactivePropertyChanges(): boolean {
return true;
}
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
if (currentTheme?.semanticColors) {
this.domElement.style.setProperty('--bodyText', currentTheme.semanticColors.bodyText || 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('listTitle', {
label: strings.ListTitleFieldLabel,
description: 'Display name of a list on this site'
}),
PropertyPaneSlider('itemLimit', {
label: strings.ItemLimitFieldLabel,
min: 10,
max: 500,
step: 10
}),
PropertyPaneToggle('useRest', {
label: strings.UseRestFieldLabel,
onText: 'SPHttpClient (REST API)',
offText: 'PnPjs'
})
]
}
]
}
]
};
}
}
disableReactivePropertyChanges adds an Apply button to the pane, so the list doesn’t reload on every keystroke while someone types a title. The SPFx property pane controls guide shows the other field types.
Update the label names in src/webparts/ordersTable/loc/mystrings.d.ts:
declare interface IOrdersTableWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
ListTitleFieldLabel: string;
ItemLimitFieldLabel: string;
UseRestFieldLabel: string;
}
declare module 'OrdersTableWebPartStrings' {
const strings: IOrdersTableWebPartStrings;
export = strings;
}
And the English strings in src/webparts/ordersTable/loc/en-us.js:
define([], function() {
return {
"PropertyPaneDescription": "Choose the list and how many orders to show.",
"BasicGroupName": "Data source",
"ListTitleFieldLabel": "List title",
"ItemLimitFieldLabel": "Number of items",
"UseRestFieldLabel": "Load data with"
}
});
Last, give the web part a friendly name and default values in OrdersTableWebPart.manifest.json. Only the preconfiguredEntries block changes; configuring preconfiguredEntries explains each field:
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Advanced" },
"title": { "default": "Orders Table" },
"description": { "default": "Shows SPGDemo Orders in a Fluent UI DetailsList" },
"officeFabricIconFontName": "Table",
"properties": {
"listTitle": "SPGDemo Orders",
"itemLimit": 100,
"useRest": false
}
}]
Checkpoint: Saving every file gives no errors, and the toolbox entry will read Orders Table with a table icon.
Step 7: Run It in the SPGuides Workbench with heft start
Before the first run, point the local server at your own site’s workbench, so the list query runs against real data. Open config/serve.json and set initialPage:
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://{tenantDomain}/sites/SPGuides/_layouts/workbench.aspx"
}
Heft swaps {tenantDomain} for the value of the SPFX_SERVE_TENANT_DOMAIN environment variable, so set it once (or type your host name straight into the file); my guide to SPFx environment variables shows how to make it permanent. Then start the local server from the project folder:
# PowerShell, current terminal session
$env:SPFX_SERVE_TENANT_DOMAIN = "spguides.sharepoint.com"
npm run start
Heft builds the project, serves it from https://localhost:4321, prints the debug query string and opens the workbench in your browser:

- In the workbench, select the + (add a new web part) button.
- Search for Orders Table and select it.
- Wait a second for the first request; the table appears below the web part toolbar.


Checkpoint: The web part shows a spinner for a moment, then 100 orders loaded with PnPjs, newest first. Leave heft start running: when you save a file, Heft rebuilds and a refresh picks up the change.
One heads-up: Microsoft retires the hosted workbench on 12/01/2026. After that, keep heft start running, open a real page such as SitePages/SPGDemo-orders-table.aspx, add ?debugManifestsFile=https%3A%2F%2Flocalhost%3A4321%2Ftemp%2Fbuild%2Fmanifests.js&debug=true&noredir=true to its URL and select Load debug scripts. Your local web part then shows up in that page’s toolbox.
Test the Web Part
With the web part in the workbench, I ran through the three things the sales manager cared about.
Sorting. Click the Total header once for the smallest totals first and again for the largest. In our list, the top order after the second click was SO-1102 for Adventure Works at $1,890.00.

Property pane. Select the web part’s Edit properties (pencil) icon. Move Number of items to 500 and select Apply; the count changes to 210 orders, the whole list.

Errors. Type a list name that doesn’t exist, such as SPGDemo Order, and select Apply. Instead of a blank web part, the MessageBar shows SharePoint’s own message:

This “does not exist at site” message is the one you’ll see most often in real projects, usually after someone renames a list.
Alternative: Get List Items with SPHttpClient and the REST API
You don’t need PnPjs to read a list. SPFx includes SPHttpClient, which calls the SharePoint REST API with the user’s sign-in already attached. The getOrdersRest method in our service sends this request:
GET https://spguides.sharepoint.com/sites/SPGuides/_api/web/lists/getbytitle('SPGDemo%20Orders')/items
?$select=Id,Title,CustomerName,Quantity,OrderTotal,OrderDate,OrderStatus,Product/Title,SalesRep/Title,FieldValuesAsText/OrderDate
&$expand=Product,SalesRep,FieldValuesAsText
&$orderby=OrderDate desc
&$top=100
The method builds that URL from pageContext.web.absoluteUrl, calls spHttpClient.get(url, SPHttpClient.configurations.v1), checks response.ok and reads the rows from json.value. The mapping to IOrder is shared, so the table can’t tell the difference.
To compare them, switch Load data with to SPHttpClient (REST API) in the property pane and select Apply. The same 210 orders come back (the slider was still at 500), and the header confirms the source:

| PnPjs v4 | SPHttpClient | |
|---|---|---|
| Extra package | @pnp/sp (adds to the bundle) | None, part of SPFx |
| Query style | Typed, chained methods | OData query string you build yourself |
| Paging and batching | Built in (async iteration, batches) | Follow odata.nextLink yourself |
| Best for | Web parts that read and write a lot | One or two simple reads |
I pick PnPjs once a web part does more than one or two calls, especially when it also writes data, as in SPFx CRUD operations with React.
Make It Production-Ready
- Large lists: queries that sort or filter on a column fail past 5,000 items unless that column is indexed. Index
OrderDatein the list settings, and page through results instead of raisingtop. The PnP pagination control pairs well with this. - Select only what you show: every extra column, especially expanded lookups, makes the response bigger and the page slower.
- Security: the web part runs as the signed-in user, so people only see items they can read. Don’t rely on hiding columns to protect data.
- Reading everything: to process all rows, use PnPjs async iteration, which fetches pages of up to 5,000 items:
// Read every item, 500 per request, without hitting the 5,000-item view threshold.
const all: IOrderItem[] = [];
for await (const page of this._sp.web.lists.getByTitle(listTitle).items.select(...SELECT).expand(...EXPAND).top(500)) {
all.push(...page);
}
Users will soon ask to narrow the table. I cover server-side filters, such as status or date range, in filtering SharePoint list items in SPFx.
Troubleshooting
- List ‘SPGDemo Order’ does not exist at site with URL ‘https://spguides.sharepoint.com/sites/SPGuides’. The list title is wrong or the list lives on another site. Check the exact name on the list’s settings page; PnPjs
getByTitleuses the display name. - The field or property ‘OrderTotl’ does not exist. A column in
selectuses the display name instead of the internal name, or a typo. Open the column’s settings and read theField=value in the URL. - The query to field ‘Product’ is not valid. You selected
Product/Titlebut forgotexpand('Product'). Lookup and person columns need both. - Orders Table isn’t in the workbench toolbox:
heft startstopped or is still compiling, or the browser blockedhttps://localhost:4321. Wait for the debug query string line in the terminal, then refresh the workbench. - The certificate isn’t trusted: run
heft trust-dev-certonce per machine, then restartheft start. - Dates look one day off: the REST API returns dates in UTC. Our SO-1002 order comes back as
2026-01-28T00:00:00Z, while the list shows 1/27/2026 in the site’s time zone. Formatting the raw value in the browser can land on either day, so the table showsFieldValuesAsTextinstead.
Package the Web Part for Production
Once the web part behaves the way you want and the fixes above are in, stop heft start with Ctrl+C and build the production package:
npm run build
The generated script runs heft test --clean --production and then heft package-solution --production, the same two stages Microsoft’s tutorial runs as separate commands. It took about a minute (81 seconds on the very first run) here and wrote sharepoint/solution/spg-orders-table.sppkg:

That file is ready to ship: upload the .sppkg to the SharePoint app catalog when you’re ready, following Microsoft Learn: Deploy your client-side web part.
Where to Take It Next
- Let users export: add a button that downloads the current rows, as in exporting data to Excel from SPFx.
- Use the PnP ListView control: if you’d rather not define columns by hand, the ListView control from
@pnp/spfx-controls-reacttakesitemsandviewFieldsand adds a search box and grouping. The older version of this post used it with the GridLayout control; the PnP controls accordion build shows how those controls install. - Add a card layout: for a visual dashboard instead of a table, the Fluent UI DocumentCard is a good start.
If you’d like this web part, and many more SPFx samples, as ready-to-run projects, they’re on the Power Platform and SPFx solutions page.
More SharePoint Framework builds that go well with this one:
Frequently Asked Questions
How do I display SharePoint list items in an SPFx web part?
Create a React web part with the SharePoint generator, read the items with PnPjs (spfi().using(SPFx(this.context)) and items.select(…)()) or SPHttpClient, keep them in React state and render them in a Fluent UI DetailsList or your own table.
Should I use PnPjs or SPHttpClient to get list items?
Both call the same SharePoint REST API. SPHttpClient needs no extra package; PnPjs gives typed, chained queries, paging and batching. The PnPjs items documentation shows every query option.
How many items can an SPFx web part show?
One request returns up to 5,000 items with top(). For bigger lists, index the columns you sort or filter on and page through results with PnPjs async iteration or a pagination control.
Can I still test SPFx web parts in the workbench?
Yes, until 12/01/2026, when Microsoft retires the hosted workbench. Set initialPage in config/serve.json to your site’s workbench for now; after that, run heft start and add the debug query string to a real SharePoint page.
Does this web part work with React 18?
SPFx 1.23.2 uses React 17.0.1. SPFx 1.24, currently in preview, moves to React 18. Build production web parts on 1.23.2 until 1.24 is generally available, then upgrade the project.

Hey! I’m Bijay Kumar, founder of SPGuides.com and a Microsoft Business Applications MVP (Power Automate, Power Apps). I launched this site in 2020 because I truly enjoy working with SharePoint, Power Platform, and SharePoint Framework (SPFx), and wanted to share that passion through step-by-step tutorials, guides, and training videos. My mission is to help you learn these technologies so you can utilize SharePoint, enhance productivity, and potentially build business solutions along the way.
Thank you so much
Please share the code in GitHub ..
const items: any[] = await web.lists.getByTitle(“FluentUIDetailsListWithCustomItemColumn”).items.select(“*”, “CustomerName/EMail”, “CustomerName/Title”).expand(“CustomerName/ID”).get();
Not Working
Thats a great tutorial. Thanks
If I wanted to get just one item, say where title = hello, how can this be achieved?