logo
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

GitHub

βœ… Summary

  • Declare React, Fluent UI, and PnPjs as peerDependencies β€” the consumer provides them, preventing duplicate bundles.
  • Use tsconfig.build.json with declaration: true to generate .d.ts files 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!

Ad image