Skip to main content

Creating Shared Component Libraries in Monorepos

A shared component library in a monorepo centralizes all UI components, hooks, and utilities that multiple applications need. Instead of duplicating Button, Card, or Input across ten apps, you build it once, version it, and import it everywhere. This article shows you how to structure a component library, export components correctly, and make them available to sibling apps with zero friction.

Why Shared Components Matter

Shared components enforce UI consistency across your product. When your web app, mobile app, and admin dashboard all use the same Button component, changes to button styling ripple everywhere automatically. Shared component libraries also reduce maintenance burden: bugs get fixed once, not ten times. A well-designed library also documents your design language, making onboarding new developers faster.

Setting Up a Component Library Package

Start by creating the library package in your monorepo:

mkdir -p packages/ui-library
cd packages/ui-library
pnpm init

Edit packages/ui-library/package.json to add React as a peer dependency and configure TypeScript and build outputs:

{
"name": "@myapp/ui-library",
"version": "1.0.0",
"description": "Shared React component library",
"type": "module",
"exports": {
".": "./dist/index.js",
"./button": "./dist/components/Button.js",
"./card": "./dist/components/Card.js",
"./input": "./dist/components/Input.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"peerDependencies": {
"react": "^18.0.0"
},
"devDependencies": {
"react": "workspace:*",
"typescript": "workspace:*"
}
}

The exports field tells npm and bundlers where to find each module. Create the directory structure:

mkdir -p src/components src/hooks

Writing Reusable Components with TypeScript

Create a simple Button component with props and accessibility attributes:

// packages/ui-library/src/components/Button.tsx
import React from 'react';

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
}

export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
children,
...rest
}) => {
const baseClass = 'font-semibold rounded transition-colors';
const variantClass = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
danger: 'bg-red-600 text-white hover:bg-red-700',
}[variant];

const sizeClass = {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
}[size];

return (
<button
className={`${baseClass} ${variantClass} ${sizeClass}`}
{...rest}
>
{children}
</button>
);
};

Create a Card component that wraps content:

// packages/ui-library/src/components/Card.tsx
import React from 'react';

export interface CardProps {
children: React.ReactNode;
className?: string;
}

export const Card: React.FC<CardProps> = ({
children,
className = '',
}) => {
return (
<div
className={`rounded-lg border border-gray-200 bg-white p-4 shadow-sm ${className}`}
>
{children}
</div>
);
};

Using Barrel Exports for Clean Imports

A barrel export is an index.ts file that re-exports all public components. This lets consuming apps import cleanly: import { Button, Card } from '@myapp/ui-library' instead of import Button from '@myapp/ui-library/components/Button'.

Create packages/ui-library/src/index.ts:

// packages/ui-library/src/index.ts
export { Button, type ButtonProps } from './components/Button';
export { Card, type CardProps } from './components/Card';
export { useAsync } from './hooks/useAsync';

Exporting types alongside components (e.g., type ButtonProps) makes TypeScript autocomplete work perfectly for consumers.

Creating Shared Hooks

Component libraries are also great places to share custom hooks. Create a reusable async fetch hook:

// packages/ui-library/src/hooks/useAsync.ts
import { useEffect, useState } from 'react';

export interface UseAsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}

export function useAsync<T>(
asyncFn: () => Promise<T>,
immediate = true
): UseAsyncState<T> {
const [state, setState] = useState<UseAsyncState<T>>({
data: null,
loading: immediate,
error: null,
});

useEffect(() => {
let isMounted = true;

const execute = async () => {
try {
const response = await asyncFn();
if (isMounted) {
setState({ data: response, loading: false, error: null });
}
} catch (error) {
if (isMounted) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error : new Error(String(error)),
});
}
}
};

if (immediate) {
execute();
}

return () => {
isMounted = false;
};
}, [asyncFn]);

return state;
}

Consuming the Library in Apps

After installing the library at the monorepo root with pnpm install, any app can import from it. In apps/web/package.json:

{
"dependencies": {
"@myapp/ui-library": "workspace:*"
}
}

Then in a React component:

// apps/web/src/App.tsx
import { Button, Card, useAsync } from '@myapp/ui-library';

export function App() {
const { data, loading, error } = useAsync(
() => fetch('/api/users').then(r => r.json()),
true
);

return (
<Card>
<Button variant="primary" size="lg">
Click Me
</Button>
{loading && <p>Loading...</p>}
{error && <p>Error: {error.message}</p>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</Card>
);
}

Versioning and Publishing Your Library

When you update components, increment the version in packages/ui-library/package.json following semantic versioning: patch for bug fixes, minor for new features, major for breaking changes. Build the library:

cd packages/ui-library
pnpm build

Publish to npm when ready (covered in Article 4). During development, other apps automatically use the workspace version via workspace:*.

Key Takeaways

  • Use barrel exports (index.ts) to provide a clean public API for your component library.
  • Define TypeScript interfaces for all component props and export them alongside components.
  • Use peerDependencies in your library to declare React as a dependency without bundling it.
  • Store components in src/components/, hooks in src/hooks/, and utilities in src/utils/.
  • Share custom hooks and utilities alongside components to reduce duplication across apps.

Frequently Asked Questions

Should I version components individually or together?

Version the entire library together. If Button v1.5 exists and you release Card v2.0, publish the whole library as v2.0. This avoids dependency conflicts.

Can I use CSS modules or styled-components in a component library?

Yes, both work. CSS modules are simple and scoped; styled-components offer dynamic theming. Make sure your build process (tsconfig, esbuild, or webpack) handles them.

How do I prevent circular dependencies in component libraries?

Organize by concern: components/, hooks/, utils/. A component can depend on hooks and utils, but utils should never depend on components. Test this with turbo build regularly.

What if an app needs only one component, not the whole library?

Your barrel export already supports this. Consumers can do import { Button } from '@myapp/ui-library' and modern bundlers tree-shake unused exports. The library size at runtime depends on your build tool, not the import style.

How do I handle CSS or styling in a component library?

Tailor CSS to your design system. If your design uses Tailwind (as in the examples above), add it to the library's devDependencies. If you use CSS modules or Sass, include .css or .scss files in your exports. Make sure the consuming app has the same styling setup.

Further Reading