- Published on
Building a Reusable SPFx Component Library with npm
Every organisation building multiple SPFx solutions hits the same wall: the same people picker component, the same status badge, the same confirmation dialog, copied and maintained in six different web part projects. A bug fix in one project does not make it to the others. A design change requires six PRs.
The solution is a shared component library β a private npm package that all your SPFx projects depend on. This article covers the non-obvious parts: peer dependency configuration, TypeScript declaration generation, Fluent UI v9 compatibility, and the consuming project setup.
πΊοΈ The Architecture
@contoso/spfx-controls (npm package)
βββ src/
β βββ components/
β β βββ StatusBadge.tsx
β β βββ PersonCard.tsx
β β βββ ConfirmDialog.tsx
β βββ hooks/
β β βββ useSharePointList.ts
β βββ index.ts β public API
βββ dist/ β compiled output (not committed)
βββ package.json
βββ tsconfig.json
spfx-project-a (consumer)
βββ depends on @contoso/spfx-controls
spfx-project-b (consumer)
βββ depends on @contoso/spfx-controls
The library is compiled to JavaScript with TypeScript declaration files (.d.ts). Consumer projects import from it like any other npm package β no source transformation required.
π¦ Step 1 β Initialise the Library Package
mkdir spfx-controls && cd spfx-controls
npm init -y
package.json
{
"name": "@contoso/spfx-controls",
"version": "1.0.0",
"description": "Reusable SPFx components for Contoso solutions",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build:watch": "tsc --project tsconfig.build.json --watch",
"prepublishOnly": "npm run build",
"lint": "eslint src --ext .ts,.tsx"
},
"peerDependencies": {
"@fluentui/react-components": ">=9.0.0",
"@fluentui/react-icons": ">=2.0.0",
"@microsoft/sp-core-library": ">=1.18.0",
"@pnp/sp": ">=3.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
},
"peerDependenciesMeta": {
"@pnp/sp": { "optional": true }
},
"devDependencies": {
"@fluentui/react-components": "^9.0.0",
"@fluentui/react-icons": "^2.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"typescript": "^5.0.0"
}
}
Why peerDependencies? If React and Fluent UI were regular dependencies, consuming SPFx projects would bundle a second copy β causing "multiple React instances" errors, styling conflicts, and bundle bloat. Peer dependencies tell npm "the consumer must provide these" β only one copy ends up in the bundle.
βοΈ Step 2 β TypeScript Configuration
Two tsconfig files β one for IDE support, one for the build output:
tsconfig.json (IDE + type checking)
{
"compilerOptions": {
"target": "ES6",
"module": "CommonJS",
"lib": ["ES6", "DOM"],
"jsx": "react",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node"
},
"include": ["src"]
}
tsconfig.build.json (output to dist/ with declarations)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationDir": "dist",
"declarationMap": true,
"sourceMap": true,
"removeComments": false
},
"exclude": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts"]
}
The declaration: true setting generates .d.ts files alongside the compiled .js β this is what gives consuming projects full TypeScript type checking and IntelliSense on your components.
π§© Step 3 β Build Components
src/components/StatusBadge.tsx
import * as React from 'react';
import { Badge, Tooltip } from '@fluentui/react-components';
export type StatusValue = 'Active' | 'Inactive' | 'Pending' | 'Completed' | 'Blocked';
export interface IStatusBadgeProps {
status: StatusValue | string;
showTooltip?: boolean;
}
const STATUS_CONFIG: Record<string, { color: 'success' | 'warning' | 'danger' | 'informative' | 'subtle'; label: string }> = {
Active: { color: 'success', label: 'Active β currently in progress' },
Completed: { color: 'success', label: 'Completed β work is done' },
Pending: { color: 'warning', label: 'Pending β awaiting action' },
Blocked: { color: 'danger', label: 'Blocked β requires attention' },
Inactive: { color: 'subtle', label: 'Inactive β not currently active' }
};
export const StatusBadge: React.FC<IStatusBadgeProps> = ({ status, showTooltip = true }) => {
const config = STATUS_CONFIG[status] ?? { color: 'informative' as const, label: status };
const badge = (
<Badge color={config.color} appearance="filled">
{status}
</Badge>
);
if (!showTooltip) return badge;
return (
<Tooltip content={config.label} relationship="label">
{badge}
</Tooltip>
);
};
src/hooks/useSharePointList.ts
import * as React from 'react';
import { SPFI } from '@pnp/sp';
export interface IUseSharePointListResult<T> {
items: T[];
loading: boolean;
error: string | null;
refresh: () => void;
}
export function useSharePointList<T>(
sp: SPFI,
listName: string,
selectFields: string[],
filterExpr?: string
): IUseSharePointListResult<T> {
const [items, setItems] = React.useState<T[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [refreshToken, setRefreshToken] = React.useState(0);
React.useEffect(() => {
setLoading(true);
setError(null);
let query = (sp.web.lists.getByTitle(listName).items as any)
.select(...selectFields);
if (filterExpr) query = query.filter(filterExpr);
query().then((result: T[]) => {
setItems(result);
setLoading(false);
}).catch((err: Error) => {
setError(err.message);
setLoading(false);
});
}, [listName, filterExpr, refreshToken]);
return {
items,
loading,
error,
refresh: () => setRefreshToken(t => t + 1)
};
}
src/index.ts β the public API surface
// Components
export { StatusBadge } from './components/StatusBadge';
export type { IStatusBadgeProps, StatusValue } from './components/StatusBadge';
// Hooks
export { useSharePointList } from './hooks/useSharePointList';
export type { IUseSharePointListResult } from './hooks/useSharePointList';
Only export what consumers should use. Keep internal utilities unexported.
π Step 4 β Build and Publish
# Build the library
npm run build
# Verify the dist output
ls dist/
# β index.js index.d.ts components/ hooks/
Publish to a private npm registry (Azure Artifacts):
# Authenticate with Azure Artifacts
npm config set registry https://pkgs.dev.azure.com/contoso/_packaging/shared-packages/npm/registry/
# Publish
npm publish
Publish to GitHub Packages:
# .npmrc in the library root
echo "@contoso:registry=https://npm.pkg.github.com" >> .npmrc
npm publish
For internal packages not ready for a registry, use a local file: reference during development:
# In the consuming SPFx project
npm install ../spfx-controls --save
π§ Step 5 β Consuming in an SPFx Project
npm install @contoso/spfx-controls --save
In config/config.json, ensure the package is not externalised (it needs to be bundled since it is your code):
{
"externals": {
"@contoso/spfx-controls": {
"path": "",
"globalName": ""
}
}
}
Actually β do not add it to externals. Leave it to be bundled normally. Externals are for large vendor libraries served by CDN, not your own code.
Using the components:
import * as React from 'react';
import { StatusBadge, useSharePointList } from '@contoso/spfx-controls';
import { SPFI } from '@pnp/sp';
interface IProjectItem {
Id: number;
Title: string;
Status: string;
}
const ProjectList: React.FC<{ sp: SPFI }> = ({ sp }) => {
const { items, loading, error } = useSharePointList<IProjectItem>(
sp,
'Projects',
['Id', 'Title', 'Status']
);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{items.map(item => (
<li key={item.Id}>
{item.Title} <StatusBadge status={item.Status} />
</li>
))}
</ul>
);
};
π GitHub Source
View full SPFx project on GitHub:Reusable SPFx controls npm package scaffold β build config, publish, peer deps, consume in SPFx
β Summary
- Declare React, Fluent UI, and PnPjs as
peerDependenciesβ the consumer provides them, preventing duplicate bundles. - Use
tsconfig.build.jsonwithdeclaration: trueto generate.d.tsfiles alongside compiled output β gives consumers full type safety and IntelliSense. - Export only the public API from
src/index.tsβ keep internal utilities private. - Publish to Azure Artifacts or GitHub Packages for private internal packages; use
file:references for local development iteration. - Do not add your own library to SPFx
externalsβ it is your code, bundle it normally.
Happy coding!
Author
Ravichandran@Hi_Ravichandran
