Overview
Project: @danieljoffe/shared-ui — a React 19 + Tailwind 4 component library published to npm and used across this portfolio, WyrdFold, and a public Storybook at ui.danieljoffe.com.
Role: Solo developer
Why it exists: I had two products, the portfolio and WyrdFold, both wanting the same Heading, Section, Button, Dropdown, Alert, and Card. The Internet Brands case study covers the first time I built a component library, for one application on one team. This is the case study for the second time: pulling components out of one Nx workspace, publishing them as an npm package, and getting a different product in a different repo (WyrdFold) to consume them.
The interesting problem isn't the components
The components were already there; what made the extraction non-trivial was the build pipeline.
My first attempt was a single bundled dist/index.js, and it worked fine locally right up until another product tried to deep-import a subpath like @danieljoffe/shared-ui/Text. The package's exports field declared a ./* subpath that resolved to per-component TypeScript sources during local development (the @danieljoffe.com/source condition for source-level imports inside the workspace), but the published artifact only had the one bundled file, so import { Text } from '@danieljoffe/shared-ui/Text' resolved to nothing for npm consumers.
The fix is multi-entry output with Rollup's preserveModules:
// libs/shared/ui/vite.config.ts
import { defineConfig } from 'vite';
import { readdirSync } from 'node:fs';
const entries = readdirSync('./src/lib')
.filter(name => /\.tsx?$/.test(name) && !/\.(spec|stories)\./.test(name))
.map(name => `src/lib/${name}`);
export default defineConfig({
build: {
lib: { entry: ['src/index.ts', ...entries], formats: ['es'] },
rollupOptions: {
output: { preserveModules: true, preserveModulesRoot: 'src' },
},
},
});Now the emitted JS layout matches the per-component .d.ts files that vite-plugin-dts was already producing: every component gets its own importable subpath, deep-imports work, and tree-shaking falls out for free.
The "use client" directive Rollup keeps stripping
This was the second non-trivial problem. Some components in the library are client-only, since they use useState, useRef, or event handlers, and the file starts with 'use client' so Next.js Server Components know to treat them as a client boundary.
The catch is that Rollup's preserveModules mode strips module-level string directives during the transform pipeline. The emitted chunk is functionally identical, but the directive is gone, so Next.js builds it as a server component, and the runtime blows up the first time the component reads from useState.
The fix is a tiny Vite plugin that grabs the directive before transforms run and re-emits it on the matching output chunk:
const DIRECTIVE_RE =
/^(?:\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/))*\s*(['"])use (client|server)\1/;
function preserveDirectives(): Plugin {
const directives = new Map<string, string>();
return {
name: 'preserve-use-directives',
enforce: 'pre',
transform(code, id) {
const match = DIRECTIVE_RE.exec(code);
if (match) directives.set(id, `'use ${match[2]}';`);
return null;
},
renderChunk(code, chunk) {
const id = chunk.facadeModuleId;
const directive = id ? directives.get(id) : undefined;
if (!directive) return null;
return { code: `${directive}\n${code}`, map: null };
},
};
}About 20 lines all told. Reading the directive in the transform hook captures it from the original source before Rollup gets a chance to strip it, and the renderChunk hook re-emits it at the top of the matching output chunk. Now both 'use client' and 'use server' survive preserveModules, and Next.js can correctly identify client component boundaries across the package.
React 19 changes the ref pattern
The library targets React 19, which makes one API decision easier than it had been for the previous decade. forwardRef is on the way out, so components can just accept ref as a regular prop:
interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
ref?: Ref<HTMLButtonElement>;
variant?: 'primary' | 'secondary' | 'ghost';
}
export function Button({
ref,
className,
variant = 'primary',
...rest
}: ButtonProps) {
return (
<button
ref={ref}
className={cn(buttonStyles({ variant }), className)}
{...rest}
/>
);
}Components that don't use hooks or state work in both server and client contexts without any 'use client' boundary, so the package's runtime cost on a server-component-heavy page is close to zero.
Tokens via Tailwind 4
Tailwind 4's @theme block plus the oklch() color space means the design system tokens live in CSS rather than a JS object. Consumers pick the tokens up through Tailwind's own pipeline; there's no separate token export, no JSON file, and no theme provider. A consumer that already uses Tailwind 4 inherits the whole design system just by importing the package's theme.css.
What ships across products
The portfolio renders almost every heading, section wrapper, and button through shared-ui, and WyrdFold's dashboards consume the same Card, Alert, and Dropdown. The published Storybook is the third consumer; it imports from the same source via the @danieljoffe.com/source condition, so hot reloads stay instant inside the monorepo while the published artifact stays stable for everyone outside it.
What this case study leaves out
The components themselves, the patterns for Dropdown, Toast, Tooltip, and focus management, are covered in Building a Design System (v1) and Expanding the Design System (v2). This one is about turning those components into a package other products can actually install.
Takeaway
The interesting engineering here wasn't picking React components or color tokens; it was making a workspace package usable from outside the workspace. That meant getting the file layout right, getting the directives to survive the build, and writing the 20-line plugin that fixes the thing Rollup quietly broke. The components are the easy part, and the publishing pipeline is where a shared design system actually lives or dies.
