Building Shared UI Libraries In The Server Components Era — Chapter I
If you’re reading this, you probably love server components as much as I do. We all enjoy working on server-component-based projects. As fancy as that sounds, it simply means that we love to take full advantage of server capabilities using frameworks designed for them, like Next.js. Benefits like faster rendering, immaculate SEO, less JavaScript delivery to users, improved loading experiences, and all the other juicy features it offers.
When it comes to building UIs and using or reusing components like buttons, inputs, dialogs, etc., we already have solutions like MUI, ChakraUI, MantineUI, and who knows what other library with a random name ending in UI is going to appear. So, why create another option? Well, those can work for a single or personal project, but when your team has several projects that need to share components, a common UI language, company styles, utilities for it, etc… you have to build a UI Library for them.
Now, to build it (assuming we’re not in a monorepo situation), we’d usually have a specific repository for it where we can throw all this common UI stuff, then publish it to our company’s npm so projects can use it. From there, we can pick our poison. As I said before, we can choose a solution that provides everything, like MUI, tweak it a bit, add company styles, export everything from an index file, and call it a day. Or, if we don’t want to depend on them and prefer to have full control of the components, we can create them from scratch and enhance them with the lovely RadixUI library. Regardless of the path you choose (I’m not here to judge anyone…), the way our components are built and processed has to change for a server-friendly approach. So, let’s see how we can achieve that.
But before jumping in, a quick note: I want to pour my mind out and share my experiences coding this stuff — not just the code itself, but the “whys” behind it. To avoid boring you, I’ve broken it into chapters (guess I’m a novelist now…). This chapter focuses on some core concepts, package setup, and tsup configuration, the goal is to have the foundation for building this type of UI library. So, get ready, because s##t is about to get a bit technical.
How It Used To Be, The Good Old Days
Before the server components era, exporting our stuff from a shared library was simple. For example, you might have had a folder structure like this:
/ good-old-days-ui-lib
/ src
/ components-folder
button.tsx
text-input.tsx
dialog.tsx
/ utils
format-date.ts
parse-currency.ts
index.ts // We export all our stuff from this entry pointFrom this structure, you’d create a single entry point (typically an index.ts) that re-exports everything:
export { default as Button } from './button';
export { default as Dialog } from './dialog';
export { default as TextInput } from './text-input';
export * from './utils';
export * from './types';And a typical package.json for such a library would look like this:
{
"name": "good-old-days-ui-lib",
"version": "1.0.0",
"main": "./dist/index.js", // CJS entry point
"module": "./dist/index.mjs", // ESM entry point
"types": "./dist/index.d.ts", // TypeScript declarations
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc && rollup -c"
},
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"rollup": "^3.0.0",
"rollup-plugin-typescript2": "^0.34.0"
}
}Nothing special, that’s the way it has always been… and once devs use our library, they would consume the components like this:
import {
Button,
TextInput,
Accordion,
Carousel,
} from "@good-old-days-ui-lib"
The Server Components Era
Now, with server components, especially with Next.js from version 13 and onward, we’re dealing with a `use client` directive at the top of our files, so the app knows when a component is a server or client component.
This is why we can’t export everything from a single entry point anymore. First, if any component in our library uses useState, useMemo, or any client-side behavior without the new directive, it will cause our app to crash. Second, we don’t want to turn our server pages into client ones just to accommodate this, or even worse, create a bypass component to render them first and then use it in the project (guilty af)… And that’s without mentioning all the benefits that individual exports give us, like improved tree-shaking of unused components, better control over dependencies, flexibility for devs, and so on.
So… let’s break down what we need to do:
- Export each component individually.
- Add the `use client` directive where needed.
- Build the library without losing the `use client` directive. This one is easier said than done.
Bundler And Package Configuration
We’ll dive deeper into the rest of the stack in future chapters — spoiler alert: it’ll be Tailwind and RadixUI. But for now, let’s focus on the bundler. I’ll be using tsup for this setup. Here’s the configuration:
export default defineConfig((options: Options) => ({
treeshake: true,
splitting: true,
entry: ["src/**/*.tsx"],
tsconfig: "./tsconfig.json",
format: ["esm"],
dts: true,
minify: true,
clean: true,
outDir: 'dist/lib',
external: ["react"],
...options,
}));Now, here’s where things start to differ from regular, fully client-side libraries. In our package.json, instead of using a single entry point, we’re going to use the exports key — in this case, to expose the exported .mjs files. This key allows us to individually export our stuff by defining the package’s entry points, explicitly specifying which paths are exposed and accessible to devs:
"exports": {
"./button": {
"import": "./dist/lib/button.mjs"
},
"./text-input": {
"import": "./dist/lib/text-input.mjs"
},
"./dialog": {
"import": "./dist/lib/dialog.mjs"
},
"./carousel": {
"import": "./dist/lib/carousel.mjs"
},
}And… don’t forget about the types! Without typesVersions, TypeScript would be completely clueless about which types to associate with the individual exports.
"typesVersions": {
"*": {
"button": ["./dist/lib/button.d.ts"],
"text-input": ["./dist/lib/text-input.d.ts"],
"dialog": ["./dist/lib/dialog.d.ts"],
"carousel": ["./dist/lib/carousel.d.ts"],
}
}With this setup, we’re no longer bundling everything into a single entry point anymore. Each component is independently exported and projects can consume only the components they need like so:
import Button from '@ui-library/button';
import TextInput from '@ui-library/text-input';
import Dialog from '@ui-library/dialog';
import Carousel from '@ui-library/carousel';The “use client” Directive
Now that we’ve exposed our components individually, we need to identify which ones are hardcore-client-specific and which can remain server-side. In general, components like buttons, inputs and some other like them should stay as server components because the browser already handles their behavior natively. If we need client-specific behavior for them, it’s the project’s responsibility to manage that.
Get Liam Klyneker’s stories in your inbox
Join Medium for free to get updates from this writer.
For the sake of this example, let’s assume that components with heavy interactivity, like Dialog and Carousel, are client components so, these files will have the use client directive at the top:
// src/dialog.tsx
'use client'
export default function Dialog() {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div>
{isOpen && <div>Dialog content</div>}
<button onClick={() => setIsOpen(!isOpen)}>Toggle Dialog</button>
</div>
);
}// src/carousel.tsx
'use client'
export default function Carousel() {
const [currentSlide, setCurrentSlide] = React.useState(0);
return (
<div>
<button onClick={() => setCurrentSlide(currentSlide - 1)}>Previous</button>
<div>Slide {currentSlide}</div>
<button onClick={() => setCurrentSlide(currentSlide + 1)}>Next</button>
</div>
);
}If we build the library at this point without any additional configuration, the output files will look something like this:
It’s already a problem that the `use client` directive is missing here, but if we dive into the chunk itself, we also find this:
To ensure these components work properly when imported individually in a server situation, we need to add the `use client` directive to the exported component itself and to its respective chunks if necessary, like in this one, it’s importing a useEffect… it is a client component for sure.
After some investigation I found out that the only way to achieve it (at least at the time of writing this) was by creating a plugin for tsup. Trial and error, some questionable attempts, fate — and not gonna lie, a few sleepless nights — lead me to thevuong’s solution so, big thanks to him — I adapted his approach to fit my needs.
The logic behind the plugin is in theory simple: it reads the files, identifies the `use client` directive, and ensures that it is preserved. It also checks if any chunk generated by tsup contains hooks or events, and if so, adds the `use client` directive there as well:
import { relative } from "node:path";
import type { Options } from "tsup";
export function addUseClientDirective(): NonNullable<Options["plugins"]>[number] {
return {
name: "add-use-client-directive",
renderChunk: (code, { imports, map, path }) => {
const relativePath = relative(process.cwd(), path);
// Check if the code already contains the "use client" directive.
if (code.startsWith(USE_CLIENT_DIRECTIVE)) {
const importLength = imports ? imports.length : 0;
// Record the imports of the current chunk to avoid duplication.
for (let i = 0; i < importLength; i++) {
const currentImport = imports?.[i];
// Add the current import path to the tracked imports.
if (currentImport) {
trackedImports.add(currentImport.path);
}
}
// Return the original code if the directive is already present.
return { code, map };
}
// If the current path has not been tracked, return unchanged code.
if (!trackedImports.has(relativePath)) {
return { code, map };
}
// Remove the current path from the tracked imports.
trackedImports.delete(relativePath);
// Check if the code contains any hooks or event.
if (itContainsHooksOrEvents(code)) {
// Add the "use client" directive to the code.
return {
code: `${USE_CLIENT_DIRECTIVE};${code}`,
map,
};
}
// Return the original code if no hooks or events were found.
return { code, map };
},
};
}
function itContainsHooksOrEvents(
content: string,
): boolean {
return HOOK_OR_EVENT_REGEX.test(content);
}
const USE_CLIENT_DIRECTIVE = 'use client';
const HOOK_OR_EVENT_REGEX =
/(?<!\/\/.*)(?<!\/\*[\s\S]*?\*\/)(?<!['"])\b(?<hookOrEvent>(?<hookOrEventName>use[A-Z]\w*\s*\(.*\)|on[A-Z]\w*)\s*\(?.*\)?|import\s*{[^}]*\buse[A-Z]\w*\b[^}]*})(?!['"])/;Although the “magic” is everywhere in the process, the key part is in the itContainsHooksOrEvents function. This function evaluates whether a chunk contains hooks or events, allowing us to flag it with the `use client` directive — beautiful… You can even take this further by adding more checks, like identifying exclusively client-side external libraries, and automatically applying the directive when needed.
Then, in the tsup.config, we just:
export default defineConfig((options: Options) => ({
treeshake: true,
splitting: true,
entry: ["src/**/*.tsx"],
tsconfig: "./tsconfig.json",
format: ["esm"],
dts: true,
minify: true,
clean: true,
outDir: 'dist/lib',
external: ["react"],
plugins: [addUseClientDirective()], // new line
...options,
}));Finally, when you run the tsup command, you should see that the chunk files meant to be client-side include the `use client` directive... I was just “Ain’t no way!!!”.
Conclusion
We’ve covered how to configure tsup and package.json, along with a few more concepts. With this, we now have a nice server-friendly setup to start building components on top of it.
¡Y bueno! That’s the first part of my experience building UI libraries for my team. In the next chapter, I’ll dive deeper into the specifics of the library itself: the folder structure, the problems it solves, best practices, peculiarities, how to improve developer experience, valuable things that I learn in the process, and probably much more. For now, that’s about it. Till further notice ✋.
p.s. I know that this post is arriving in a AI era, but I’d still like to share it.









