DEV Community

Cover image for Create a Component Library Fast🚀(using Vite's library mode)
Andreas Riedmüller
Andreas Riedmüller

Posted on • Updated on

Create a Component Library Fast🚀(using Vite's library mode)

If you are managing multiple React applications and want consistency across your user interfaces, sooner or later you'll find that you need a component library.

When I first wanted to create a React component library, it took me a lot of time to find a setup that met all my requirements and wasn't too complicated.

A guide like this would've spared me a great amount of energy wrestling with this stuff myself. I hope it can help you as much as it would have helped me.

This post covers setting up and publishing a React component library, including configuring your build process and publishing your package to npm so you and/or others can use it.

I've done my best to keep all configurations simple and concise, using default settings whenever possible.

When you are done, you can install your library like any other npm package:

npm install @username/my-component-library
Enter fullscreen mode Exit fullscreen mode

And use it like:

import { Button } from `@username/my-component-library`;

function MyComponent() {
  return <Button>Click me!</Button>
}
Enter fullscreen mode Exit fullscreen mode

Before we start

Before we dig into the implementation details, I would like to elaborate on some technical details regarding the setup of the library.

🌳Fully tree shakeable

For me it was particularly important that only necessary code ends up in the final application. When you import a component, it only includes the necessary JS and CSS styles. Pretty cool, right?

🦑Compiled CSS modules

The components are styled with CSS modules. When building the library, these styles will get transformed to normal CSS style sheets. This means that the consuming application will not even be required to support CSS modules.

As a bonus compiling the CSS modules avoids a compatibility issue and the package can be consumed in both, environments that support named imports for CSS modules, and environments that don't.

(In the future I want to extend this tutorial to use vanilla-extract instead.)

😎TypeScript

While the library is written in TypeScript, it can be consumed in any "normal" JavaScript project as well. If you never used TypeScript before, give it a try. It not only forces you to write cleaner code, but also helps your AI coding assistant make better suggestions 😉

OK enough reading, now let's have some fun!

1. Setup a new Vite project

If you have never worked with Vite, think of it as a replacement for Create React App. Just a few commands and you are ready to go.

npm create vite@latest
? Project name: › my-component-library
? Select a framework: › React
? Select a variant: › TypeScript
cd my-component-library
npm i
Enter fullscreen mode Exit fullscreen mode

That's it, your new Vite/React project is ready to go.

Here are two things I recommend you to do right after installing Vite.

2. Basic build setup

You can now run npm run dev and browse to the url provided by Vite. While working on your library, this is a place where you can easily import your library and actually see your components. Think of all code inside the src folder as your demo page.

The actual library code will reside in another folder. Let's create this folder and name it lib. You could also name it differently, but lib is a solid choice.

The main entry point of your library will be a file named main.ts inside of lib. When installing the library you can import everything that is exported from this file.

 📂my-component-library
 +┣ 📂lib
 +┃ ┗ 📜main.ts
  ┣ 📂public
  ┣ 📂src
  …
Enter fullscreen mode Exit fullscreen mode

Vite Library Mode

At this time, if you build the project with npm run build Vite will transpile the code inside src to the dist folder. This is default Vite behavior.

For now you will use the demo page for development purposes only. So there is no need to transpile this part of the project yet. Instead you want to transpile and ship the code inside of lib.

This is where Vite's Library Mode comes into play. It was designed specifically for building/transpiling libraries. To activate this mode, simply specify your library entry point in vite.config.ts.

Like so:

import { defineConfig } from 'vite'
+ import { resolve } from 'path'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
+  build: {
+    lib: {
+      entry: resolve(__dirname, 'lib/main.ts'),
+      formats: ['es']
+    }
  }
})
Enter fullscreen mode Exit fullscreen mode

💡 The default formats are 'es' and 'umd'. For your component library 'es' is all you need. This also removes the necessity for adding the name property.

💡 If your Typescript linter complains about 'path' and __dirname just install the types for node: npm i @types/node -D

  • 📘 Library mode docs
  • 📘 lib mode docs

Typescript and library mode

The tsconfig.json created by Vite only includes the folder src. To enable Typescript for your newly created lib folder as well you just need to add it to the Typescript configuration file like this:

-   "include": ["src"],
+   "include": ["src", "lib"],
Enter fullscreen mode Exit fullscreen mode

Later you will import components directly from the dist folder to test your library on the demo page. And because typescript will spill an error if you import a component that has not yet been built, you need to be exclude the src folder when building the library.

One way to do this is to create a separate configuration file for building and extend tsconfig.json:

 📂my-component-library
  ┣ …
  ┣ 📜tsconfig.json
 +┣ 📜tsconfig-build.json
  …
Enter fullscreen mode Exit fullscreen mode

The only difference between the default configuration and the build configuration is that for building you only have lib included instead of lib and src.

📜tsconfig-build.json

{
  "extends": "./tsconfig.json",
  "include": ["lib"]
}
Enter fullscreen mode Exit fullscreen mode

To use tsconfig-build.json for building you need to pass the configuration file to tsc in the build script in your package.json:

  "scripts": {
    …
-   "build": "tsc && vite build",
+   "build": "tsc --p ./tsconfig-build.json && vite build",
Enter fullscreen mode Exit fullscreen mode

Finally you will also need to copy the file vite-env.d.ts from src to lib. Without this file Typescript will miss some types definitions provided by Vite when building (because we don't include src anymore).

You can now execute npm run build once more and this is what you will see in your dist folder:

 📂dist
  ┣ 📜my-component-library.js
  ┗ 📜vite.svg
Enter fullscreen mode Exit fullscreen mode

💡 The name of the output file is identical with the name property in your package.json per default. This can be changed in the Vite config (build.lib.fileName) but we will do something else about this later.

The file vite.svg is in your dist folder because Vite copies all files from the public directory to the output folder. Let's disable this behavior:

build: {
+  copyPublicDir: false,
…
}
Enter fullscreen mode Exit fullscreen mode

You can read a more detailed explanation here: Why is the file vite.svg in the dist folder?

Building the types

As this is a Typescript library you also want to ship type definitions with your package. Fortunately there is a Vite plugin that does exactly this: vite-plugin-dts

npm i vite-plugin-dts -D
Enter fullscreen mode Exit fullscreen mode

Per default dts will generate types for both src and lib because both folders are included in the project's .tsconfig. This is why we need to pass one configuration parameter: include: ['lib'].

// vite.config.ts
+import dts from 'vite-plugin-dts'
…
  plugins: [
    react(),
+   dts({ include: ['lib'] })
  ],
…
Enter fullscreen mode Exit fullscreen mode

💡 It would also work to exclude: ['src'] or use a different Typescript config file for building.

To test things out, let's add some actual code to your library. Open lib/main.ts and export something, for example:

lib/main.ts
export function helloAnything(thing: string): string {
  return `Hello ${thing}!`
}
Enter fullscreen mode Exit fullscreen mode

Then run npm run build to transpile your code. If the content of your dist folder looks like below you should be all set 🥳:

 📂dist
  ┣ 📜main.d.ts
  ┗ 📜my-component-library.js
Enter fullscreen mode Exit fullscreen mode

💡 Don't be shy, open the files and see what the program did for you!

3. What is a React component library without components?

We didn't do all of this just to export a helloAnything function. So let's add some meat 🍖 (or tofu 🌱 or both) to our library.

Let's go with three very common basic components: A button, a label, and a text input.

 📂my-component-library
  ┣ 📂lib
 +┃ ┣ 📂components
 +┃ ┃ ┣ 📂Button
 +┃ ┃ ┃ ┗ 📜index.tsx
 +┃ ┃ ┣ 📂Input
 +┃ ┃ ┃ ┗ 📜index.tsx
 +┃ ┃ ┗ 📂Label
 +┃ ┃   ┗ 📜index.tsx
  ┃ ┗ 📜main.ts
  …
Enter fullscreen mode Exit fullscreen mode

And a very basic implementation for these components:

// lib/components/Button/index.tsx
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return <button {...props} />
}
Enter fullscreen mode Exit fullscreen mode
// lib/components/Input/index.tsx
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
  return <input {...props} />
}
Enter fullscreen mode Exit fullscreen mode
// lib/components/Label/index.tsx
export function Label(props: React.LabelHTMLAttributes<HTMLLabelElement>) {
  return <label {...props} />
}
Enter fullscreen mode Exit fullscreen mode

Finally export the components from the library's main file:

// lib/main.ts
export { Button } from './components/Button'
export { Input } from './components/Input'
export { Label } from './components/Label'
Enter fullscreen mode Exit fullscreen mode

If you npm run build again you will notice that the transpiled file my-component-library.js now has 78kb 😮

The implementation of the components above contains React JSX code and therefore react (and react/jsx-runtime) gets bundled as well.

As this library will be used in projects that have React installed anyways, you can externalize this dependencies to remove the code from bundle:

//vite.config.ts
  build: {
    …
+   rollupOptions: {
+     external: ['react', 'react/jsx-runtime'],
+   }
  }
Enter fullscreen mode Exit fullscreen mode

4. Add some styles

As mentioned in the beginning, this library will use CSS modules to style the components.

CSS modules are supported by Vite per default. All you have to do is to create CSS files that end with .module.css.

 📂my-component-library
  ┣ 📂lib
  ┃ ┣ 📂components
  ┃ ┃ ┣ 📂Button
  ┃ ┃ ┃ ┣ 📜index.tsx
+ ┃ ┃ ┃ ┗ 📜styles.module.css
  ┃ ┃ ┣ 📂Input
  ┃ ┃ ┃ ┣ 📜index.tsx
+ ┃ ┃ ┃ ┗ 📜styles.module.css
  ┃ ┃ ┗ 📂Label
  ┃ ┃   ┣ 📜index.tsx
+ ┃ ┃   ┗ 📜styles.module.css
  ┃ ┗ 📜main.ts
  …
Enter fullscreen mode Exit fullscreen mode

And add some basic CSS classes:

/* lib/components/Button/styles.module.css */
.button {
    padding: 1rem;
}
Enter fullscreen mode Exit fullscreen mode
/* lib/components/Input/styles.module.css */
.input {
    padding: 1rem;
}
Enter fullscreen mode Exit fullscreen mode
/* lib/components/Label/styles.module.css */
.label {
    font-weight: bold;
}
Enter fullscreen mode Exit fullscreen mode

And import/use them inside your components eg:

import styles from './styles.module.css'

export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const { className, ...restProps } = props
  return <button className={`${className} ${styles.button}`} {...restProps} />
}
Enter fullscreen mode Exit fullscreen mode

⛴️ Ship your style

After transpiling your library you will notice that there is a new file in your distribution folder:

 📂dist
  ┣ …
  ┣ 📜my-component-library.js
+ ┗ 📜style.css
Enter fullscreen mode Exit fullscreen mode

But there are two issues with this file:

  1. You need to manually import the file in the consuming application.
  2. It is one file that contains all styles for all components.

Import the CSS

CSS files just can't easily be imported in JavaScript. Therefore, the CSS file is generated separately, allowing the library user to decide how to handle the file.

But what if we were to assume that the application using the library has a bundler configuration that can handle CSS imports?

For this to work, the transpiled JavaScript bundle must contain an import statement for the CSS file. We are going to use yet another Vite plugin (vite-plugin-lib-inject-css) that does exactly what we need with zero configuration.

npm i vite-plugin-lib-inject-css -D
Enter fullscreen mode Exit fullscreen mode
// vite.config.ts
+import { libInjectCss } from 'vite-plugin-lib-inject-css'
…
  plugins: [
    react(),
+   libInjectCss(),
    dts({ include: ['lib'] })
  ],
…
Enter fullscreen mode Exit fullscreen mode

Build the library and take a look at the top of your bundled JavaScript file (dist/my-component-library.js):

// dist/my-component-library.js
import "./main.css";
…
Enter fullscreen mode Exit fullscreen mode

💡 You may notice that the CSS filename has changed from style.css to main.css. This change occurs because the plugin generates a separate CSS file for each chunk, and in this case the name of the chunk comes from the filename of the entry file.

Split up the CSS

But there's still the second problem: when you import something from your library, main.css is also imported and all the CSS styles end up in your application bundle. Even if you only import the button.

The libInjectCSS plugin generates a separate CSS file for each chunk and includes an import statement at the beginning of each chunk's output file.

So if you split up the JavaScript code, you end up having separate CSS files that only get imported when the according JavaScript files are imported.

One way of doing this would be to turn every file into an Rollup entry point. And, it couldn't be better, there is a recommended way of doing this right in the Rollup documentation:

📘 If you want to convert a set of files to another format while maintaining the file structure and export signatures, the recommended way—instead of using output.preserveModules that may tree-shake exports as well as emit virtual files created by plugins—is to turn every file into an entry point.

So let's add this to your configuration.

First install glob as it will be required:

npm i glob -D
Enter fullscreen mode Exit fullscreen mode

Then change your Vite config to this:

// vite.config.ts
-import { resolve } from 'path'
+import { extname, relative, resolve } from 'path'
+import { fileURLToPath } from 'node:url'
+import { glob } from 'glob'
…
    rollupOptions: {
      external: ['react', 'react/jsx-runtime'],
+     input: Object.fromEntries(
+       glob.sync('lib/**/*.{ts,tsx}').map(file => [
+         // The name of the entry point
+         // lib/nested/foo.ts becomes nested/foo
+         relative(
+           'lib',
+           file.slice(0, file.length - extname(file).length)
+         ),
+         // The absolute path to the entry file
+         // lib/nested/foo.ts becomes /project/lib/nested/foo.ts
+         fileURLToPath(new URL(file, import.meta.url))
+       ])
+     )
    }
…
Enter fullscreen mode Exit fullscreen mode

💡 The glob library helps you to specify a set of filenames. In this case it selects all files ending with .ts or .tsx Glob Wikipedia

Now you end up with a bunch of JavaScript and CSS files in the root of your dist folder. It works, but it doesn't look particularly pretty, does it?

// vite.config.ts
    rollupOptions: {
…
+     output: {
+       assetFileNames: 'assets/[name][extname]',
+       entryFileNames: '[name].js',
+     }
    }
…
Enter fullscreen mode Exit fullscreen mode

Transpile the library again and all JavaScript files should now be in the same organized folder structure you have created in lib alongside with their type definitions. And the CSS files are inside a new folder called assets.

Transpile the library again and all JavaScript files should now be in the same organized folder structure that you created in lib along with their types. And the CSS files are in a new folder called "assets". 🙌

Notice that the name of the main file has changed from "my-component-library.js" to "main.js". That's great!

4. A few last steps before you can publish the package

Your build setup is now ready, there are just a few things to consider before releasing your package.

The package.json file will get published along with your package files. And you need to make sure it contains all important information about the package.

Main file

Every npm package has a primary entry point, per default this file is index.js in the root of the package.

Your library's primary entry point is now located at dist/main.js, so this needs to be set in your package.json. The same applies to the type's entry point: dist/main.d.ts

// package.json
{
  "name": "my-component-library",
  "private": true,
  "version": "0.0.0",
  "type": "module",
+ "main": "dist/main.js",
+ "types": "dist/main.d.ts",
  …
Enter fullscreen mode Exit fullscreen mode

Define the files to publish

You should also define which files should be packed into your distributed package.

// package.json
  …
  "main": "dist/main.js",
  "types": "dist/main.d.ts",
+ "files": [
+   "dist"
+ ],
  …
Enter fullscreen mode Exit fullscreen mode

💡 Certain files like package.json or README are always included, regardless of settings: Read the docs

Dependencies

Now take a look at your dependencies: right now there should be only two react and react-dom and a couple of devDependencies.

You can move those two to the devDepedencies as well. And additionally add them as peerDependencies so the consuming application is aware that it must have React installed to use this package.

// package.json
- "dependencies": {
+ "peerDependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
+   "react": "^18.2.0",
+   "react-dom": "^18.2.0",
    …
  }
Enter fullscreen mode Exit fullscreen mode

💡 See this StackOverflow answer to learn more about the different types of dependencies: Link

Side effects

To prevent the CSS files from being accidentally removed by the consumer's tree-shaking efforts, you should also specify the generated CSS as side effects:

// package.json
+ "sideEffects": [
+   "**/*.css"
+ ],
Enter fullscreen mode Exit fullscreen mode

Ensure that the package is built

You can use the special lifecycle script prepublishOnly to guarantee that your changes are always built before the package is published:

// package.json
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    …
+   "prepublishOnly": "npm run build"
  },
Enter fullscreen mode Exit fullscreen mode

5. Demo page and deployment

To just play around with your components on the demo page, you can simply import the components directly from the root of your project. This works because your package.json points to the transpiled main file dist/main.ts.

src/App.tsx
…
import { Button, Label, Input } from '../';
…
Enter fullscreen mode Exit fullscreen mode

To publish your package, you just need to run npm publish. If you want to release your package to the public, you have to set private: false in your package.json.

You can read more about publishing your package, including installing it in a local project (without publishing) in this article of mine: Publish/install your package

FAQs

Can I remove the CSS imports from the output?

Yes, you can easily remove the vite-plugin-lib-inject-css plugin (and subsequential the sideEffects from your package.json)

Having done that you will get one compiled stylesheet containing all required classes in dist/assets/style.css. Import/use this stylesheet in your application and you should be good to go.

You will of course loose the CSS treeshaking feature which is made possible by importing only the required CSS inside each component.

I published a branch demonstrating this change here: https://github.com/receter/my-component-library/tree/no-css-injection

Does this work with Next.js?

Importing CSS from external npm packages works since Next.js 13.4:
https://github.com/vercel/next.js/discussions/27953#discussioncomment-5831478

If you use an older version of Next.js you can install next-transpile-modules

Here is a Next.js demo repo: https://github.com/receter/my-nextjs-component-library-consumer

How to use Storybook for my library?

To install Storybook run npx storybook@latest init and start adding your stories.

To be able to build Storybook you need to disable the libInjectCss plugin. Otherwise you will run into an TypeError: Cannot convert undefined or null to object error when running npm run build-storybook

This plugin is not needed to build Storybook so you can remove it from the config when building Storybook.

Add this to .storybook/main.ts:

+  import { withoutVitePlugins } from "@storybook/builder-vite";

const config: StorybookConfig = {
  …
+  viteFinal: async (config) => {
+    return {
+      ...config,
+      plugins: await withoutVitePlugins(config.plugins, [
+        "vite:lib-inject-css",
+      ]),
+    };
+  },
};

Enter fullscreen mode Exit fullscreen mode

If you add stories inside the lib folder you also need to make sure to exclude all .stories.tsx files from the glob pattern so the stories don't end up in your bundle.

glob.sync('lib/**/*.{ts,tsx}', { ignore: 'lib/**/*.stories.tsx'})
Enter fullscreen mode Exit fullscreen mode

Thanks @codalf for figuring that out!

I published a branch with Storybook set up here:
https://github.com/receter/my-component-library/tree/storybook

Thanks for reading!

If you did not follow along or something wasn't that clear, you can find the full source code with working examples on my GitHub Profile:

Fingers crossed you found it helpful, and I'm all ears for any thoughts you'd like to share.

Top comments (54)

Collapse
 
merri profile image
Vesa Piittinen

Please note that this will not work with Next.js. You cannot provide CSS import in JS for SSR.

This is the issue you will face: nextjs.org/docs/messages/css-npm

For Next.js the solution would be to import the CSS on Next's side so that it can be loaded in an appropriate location. So you would need to make boilerplate file for every entry in a Next app so that the CSS would be only loaded when the component is going to be used to get the benefit of only loading what is needed.

Collapse
 
receter profile image
Andreas Riedmüller • Edited

Update
Importing CSS from external npm packages works since Next.js 13.4:
github.com/vercel/next.js/discussi...


Hi Vesa,

this is very interesting, thank you! I did not yet try to use this approach within a Next.js project.

You can also remove the inject-css plugin (and the sideEffects from the package.json)

Having done that you should be able to just import the generated css file (dist/assets/style.css) inside your Next.js project. But of course you will not have the css treeshaking advantages with this approach.

I published a branch with this approach here: github.com/receter/my-component-li...

Generally there are answers for the quetions raised in the linked issue:

Should the file be consumed as Global CSS or CSS Modules?

As Global CSS.

If Global, in what order does the file need to be injected?

The order of the individual files is determined by the order they are imported inside the libraries main file. And the order in the consuming application should not matter.

Maybe it would be possible to write a Next.js plugin enabling this.

What do you think?

Collapse
 
merri profile image
Vesa Piittinen • Edited

You are incorrect about the order in consuming application not mattering. Because people tend to write overrides. Or they may use things from multiple sources and mix their use together. And then loading order, or order of the styles as they exist in the document, does matter.

For an example, in my past experience Styled Components was notorious for becoming a true troublemaker with style issues. Because if you had multiple different components from different libraries + your own local Styled Components + SC was primarily designed for the React SPA world, but we entered the "SSR is a better idea" era, and then you would just end up having awfully hard to debug style issues. CJS to ESM transition also caused it's own share of issues.

And Styled Components is all about style injection. I think they've now got it somewhat in order with their v6 release, but we're of course on our way away from Styled Components as runtime CSS-in-JS is fundamentally bad for performance.

Then there are even issues with CSS Modules in Vite. Vite does not keep the modules on their own but instead mashes composes into the consuming output file. So this means the same class name definition can be loaded again later, and if you do any property overrides then BOOM now you have issues that flicker depending on which order the CSS is loaded. So you just have to know not to ever write property overrides when using CSS Modules.

Anyway, I think doing any sort of import './style.css' or injectStyles will always be fundamentally wrong. You must be able to control the loading order of the CSS and the only thing that can reliably do that is the consuming app / framework.

Thread Thread
 
receter profile image
Andreas Riedmüller • Edited

Yes you are absolutely right about overrides. If you assign classes to the exported components it makes a difference if these classes come before or after the classes provided by the component library.

In this case the component library styles have to be imported before to have a lower specificity. And this is only guaranteed if the library is imported before any other styles.

As you correctly mention, the consuming application is responsible for ensuring the correct order of css.

You should be able to ensure this if:

  • Your libraries CSS is imported before any other local js or css
  • You never assign a CSS class imported from a js library to a component
  • Only one component library is allowed to influence global styles
Thread Thread
 
receter profile image
Andreas Riedmüller

Then there are even issues with CSS Modules in Vite. Vite does not keep the modules on their own but instead mashes composes into the consuming output file.

Do you have more info like a github issue on this?

So you just have to know not to ever write property overrides when using CSS Modules.

Can you ellaborate on this?

Thread Thread
 
merri profile image
Vesa Piittinen

I guess the issue answers the latter question: github.com/vitejs/vite/issues/7504

Thread Thread
 
receter profile image
Andreas Riedmüller

Thanks for the link!

The reason I am working on this topic and wrote the article is that I am trying to find the best solution to build a component library. I don't like CSS in JS that much and I am convinced that a stylesheet based approach is the way I want to go. I will think about the style ordering and might publish another article on this soon.

I wrote you on LinkedIn, if you are interested in having a discussion about this topic I would be more then happy to speak/write to you.

Thread Thread
 
receter profile image
Andreas Riedmüller

The advantage of handling the style imports inside the library is (obviously) that you don't need to manually import styles. This is not a big issue if it is just one stylesheet for a library. But if you only want to import the styles for components you actually use I see no other really satisfying solution.

I do have some rough ideas though…

Thread Thread
 
merri profile image
Vesa Piittinen

Yeah, the more you want to provide benefits to the user (= well splitting code, tree shaking, only styles you need) the nastier the management comes for the component library consumer.

We are in transition to CSS Modules based component library at work and the design of that is basically a bunch of createXYZ() functions with the sole role of passing in the CSS Modules as loaded by the consuming app. It could be a little better by instead just creating the components and making them use a hook that would provide the styles from context, and then have a context provider. Although I guess then you'd end up with the issue that all the CSS would be loaded at once.

One reason for doing things like that is we also use some of the very same CSS Modules directly. It does provide benefits as you can choose to work without a component just using the styles to classes, use composes to extend in a local module, get unified breakpoints for both CSS and JS, or use the convenient components when you don't need that much power (although our main layout component and text component are both very versatile).

The problem I have is that only our team really needs the full intelligent splitting. Other teams work on what are more like in-house SPAs, so for them there is a friction in moving from Styled Components to CSS Modules. They don't like the boilerplate and they don't need to consider app boot times. So this is one reason I found your article.

Thread Thread
 
receter profile image
Andreas Riedmüller

I have the feeling that a fundamental problem is that the order of CSS is defined by the order it is imported in javascript. Which is kind of by chance because sometimes a component is imported earlier and sometimes later.

There are also not so easy to fix problems with dynamic importing github.com/vitejs/vite/issues/3924

I created a branch with a very simple example that demonstrates the order issue if anyone is interested in an example: github.com/receter/my-component-li...

What I did not expect: When importing ANY component from the library, all other imported CSS, even for components imported later on, will be at the same position.

Collapse
 
receter profile image
Andreas Riedmüller

Looks like it works since Next.js 13.4:

github.com/vercel/next.js/discussi...

Following up on this: the App Router is now stable with 13.4!
CSS files can now be imported inside any layout or page, including styles from external npm packages.
nextjs.org/blog/next-13-4

github.com/vercel/next.js/discussi...

Collapse
 
receter profile image
Andreas Riedmüller • Edited

I just created a new nextjs project and it seems to work quite fine.

Here is the my repo: github.com/receter/my-nextjs-compo...

What version of nextjs were you using? I would like to reproduce.

Also can you try if this works for you:

In next.config.js:

+module.exports = {
+  transpilePackages: ['awesome_module'],
+};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
jamestbaker profile image
James Baker

Hi, Vesa,

I'm not having this problem with Next. The doc you provided says that this issue occurs when the source files are consumed instead of the build files. Are there other circumstances that produce this problem that we should watch out for?

My steps, in a monorepo with NPM workspaces:

  1. Followed instructions here (in a directory inside packages).
  2. Installed Next 13.5.5 (in a directory inside apps).
  3. Added a very simple component from the custom library to Next's page.tsx. This file doesn't contain the 'use client' directive, so I believe it's SSR.
  4. Tested locally only — didn't deploy.

Results:

  • The admittedly simplistic component renders as expected.
  • No messages in the Terminal or the browser console.
  • (Storybook set up in the component library also renders the component as expected.)

Question:
Are there cases in which we would consume the build files and still encounter this issue?

Collapse
 
receter profile image
Andreas Riedmüller
Collapse
 
receter profile image
Andreas Riedmüller

I added a faq section about using Storybook: dev.to/receter/how-to-create-a-rea...

Collapse
 
merri profile image
Vesa Piittinen

I don't have experience of having a library as a part of a monorepo setup, but I would guess it will be handled more like a part of the local project, not as an external dependency.

The problem exists when you release the built library as a npm package and then try to import it, so it will be within node_modules.

Collapse
 
coderollercoaster profile image
Felix Turner

The guide discusses essential technical details, such as TypeScript integration, CSS modules, tree shaking, and peer dependencies. It covers these aspects while keeping the content concise and understandable.

Collapse
 
j4v4scr1pt profile image
Js4 • Edited

Great article really helpfull! 🥳
Keep up the awesome work 💪!

But my .stories files gets included in the bundle.
I tried to exluded them by extending the glob.sync:
glob.sync('stories/**/*{!(*.stories).ts,!(*.stories).tsx,!(*.stories).js,!(*.stories).jsx}')

But it did not work.. 🤔

Any idea why this is happening?

Thx!

Edit:
Fixed it, I was stupid and included the * in the begining:
glob.sync('stories/**/{!(*.stories).ts,!(*.stories).tsx,!(*.stories).js,!(*.stories).jsx}')

But it still includes the stories.d.ts... 😞

Edit 2:

Sorry I'm asking to fast 😅, working glob that exclude stories files:
'stories/**/{!(*.stories|*.stories.d).ts,!(*.stories).tsx,!(*.stories).js,!(*.stories).jsx}'

Edit 3:
I initially thought it worked. But stories.d.ts still included.. :/

Collapse
 
codalf profile image
Peter Kullmann

I was successful with this:
glob.sync('lib/**/*.{ts,tsx}', { ignore: 'lib/**/*.stories.tsx'})

Collapse
 
j4v4scr1pt profile image
Js4

Thx man!

That worked great 🤩!

Thread Thread
 
receter profile image
Andreas Riedmüller

I have added a FAQ section about using Storybook: dev.to/receter/how-to-create-a-rea...

Collapse
 
receter profile image
Andreas Riedmüller

Hi Js4,

why do you have your stories in the library? I think it would be better to place them outside the lib folder. Then you shouldn't have to exclude them.

Collapse
 
codalf profile image
Peter Kullmann • Edited

Really great tutorial! Thanks!
I'd like to add StoryBook to the component library. Is this straight-forward or is there anything special I have to consider?

I used npx storybook@latest init to install Storybook, which seemed to work fine. I can start Storybook in dev mode with npm run storybook, however npm run build-storybook fails with an error:

=> Failed to build the preview
TypeError: Cannot convert undefined or null to object
at Function.values (<anonymous>)
at configResolved (file:///C:/prj/react/cp-web-react-components/node_modules/vite-plugin-dts/dist/index.mjs:582:100)
`
I looks like it has to do with the modified vite setup...

Collapse
 
receter profile image
Andreas Riedmüller • Edited

Hi Peter,

The issue comes from using the plugin libInjectCss with Storybook. You should not need this plugin to build Storybook. You can remove it from the config when building Storybook.

Add this to .storybook/main.ts add this:

+  import { withoutVitePlugins } from "@storybook/builder-vite";

const config: StorybookConfig = {
  …
+  viteFinal: async (config) => {
+    return {
+      ...config,
+      plugins: await withoutVitePlugins(config.plugins, [
+        "vite:lib-inject-css",
+      ]),
+    };
+  },
};

Enter fullscreen mode Exit fullscreen mode
Collapse
 
codalf profile image
Peter Kullmann

Hi Andreas,
that did it! Thanks a lot!
This is really a nice setup for our company component library!

Collapse
 
receter profile image
Andreas Riedmüller

Here is the branch with Storybook: github.com/receter/my-component-li...

Collapse
 
receter profile image
Andreas Riedmüller

Hi Peter, thanks for your comment. I will try to find some time over the weekend and create a branch with storybook.

Collapse
 
bica profile image
Bica

So 3 questions.

  1. How do I handle global types that multiple components might share?
  2. So do I need to export ALL components in lib from the main.ts?
  3. I'm using SASS, so trying to find a way in which a compiled stylesheet can become a part of the dist.
Collapse
 
receter profile image
Andreas Riedmüller

Hi Bica,

Thanks for you comment!

  1. Per default ´vite-plugin-dts´ does not copy any d.ts files but only generates declaration files from source files. But you can set copyDtsFiles and all your d.ts files will get copied to the output.
  plugins: [
    react(),
    libInjectCss(),
-    dts({ include: ['lib'] })
+    dts({ include: ['lib'], copyDtsFiles: true })
  ],
Enter fullscreen mode Exit fullscreen mode

I also noticed that for each d.ts file an empty d.js file is created. This is not wanted and you can fix it by changing the glob to lib/**/!(*.d).{ts,tsx}:

    rollupOptions: {
      external: ['react', 'react/jsx-runtime'],
      input: Object.fromEntries(
-        glob.sync('lib/**/.{ts,tsx}').map(file => [
+        glob.sync('lib/**/!(*.d).{ts,tsx}').map(file => [
Enter fullscreen mode Exit fullscreen mode

Here is a branch that uses global types: github.com/receter/my-component-li...

  1. You need to export all components that you want to expose to the outside. If you have a component that is only used internally you don't have to include it in main.ts.
    But not all components will end up in you final application bundle, only the components that you import will.

  2. To use SASS you need to first install it

npm install -D sass
Enter fullscreen mode Exit fullscreen mode

And then you can just rename files to for example styles.module.sass and everything should compile fine.

If you need a global stylesheet you can create a lib/global.sass and import it in main.ts:

+import './global.sass'
export { Button } from './components/Button'
…
Enter fullscreen mode Exit fullscreen mode

Here is a branch that uses global sass for the Button and a global stylesheet: github.com/receter/my-component-li...

Does this answer your questions sufficiently?

Collapse
 
bica profile image
Bica • Edited

I had noticed my index.d.ts becoming converted to index.d.js and being empty.

On the export question, our setup differs a bit from the standard react component folder structure....i.e how I'm doing it is basically..

Components 
- input
- - toggleWhatever.tsx
- - someOtherOne.tsx
- - toggleWhatever.scss 
Enter fullscreen mode Exit fullscreen mode

So in main.ts I guess I have to do

export (ToggleWhatever as default) from 'compoments/input/togglewhatever';
Enter fullscreen mode Exit fullscreen mode

Yes? I get an error if I don't specify as default because I don't have my components as index.tsx files.

Then we are using Bootstrap. They haven't updated their sass to support @use and @forward, they still use @import so I have to be careful when pulling it in due to increased file size and duplication. What I was doing was running the sass command on build so a css was compiling and added to dist so the end developer could pull it in if they chose (from a theme perspective). However I want to play around with your solution inside the main.ts

Per the above I have to be careful adding a sass import to the top of individual components because if an input is used multiple times per page, I fear the reference to the styles will be repeated....I am not sure if that is valid in react. But if that file has to reference our bootstrap sass for their utility methods (i.e. color or fontsize) I think it will try to pull in bootstrap again due to the import issue I mentioned.

I want to have a playground locally to test the package but I don't want my local testing to be a part of our commit, just the package library.

Thread Thread
 
receter profile image
Andreas Riedmüller

For exporting your components you should be able to:

export { ToggleWhatever } from 'compoments/input/toggleWhatever';
Enter fullscreen mode Exit fullscreen mode

But inside of toggleWhatever.tsx you need to have a named export like:

export function ToggleWhatever() {}
// or
export const ToggleWhatever  = () => {}
// instead of
export default function ToggleWhatever() {}
Enter fullscreen mode Exit fullscreen mode

If you for some reason need to export the components as default you can also do it like this which is easier to read in my opinion:

import ToggleWhatever from 'compoments/input/toggleWhatever';
import SomeOtherOne from 'compoments/input/someOtherOne';

export {
  ToggleWhatever,
  SomeOtherOne
}
Enter fullscreen mode Exit fullscreen mode

Not 100% sure what you mean with the SASS, but if you import a button multiple times the styles will be included just once.

CSS duplication issues can also be handled downstream by your bundler.

If you use a global CSS library you should be fine by just adding an import in main.tsx In this case the CSS library will end up in your application as soon as one component of the library is used. You could also add an import for the library to every component that needs the library, so the CSS library is only bundled if one of these components is imported.

Collapse
 
bica profile image
Bica

Yes it does! I'll check out your fixes and if they will work with my changes.

Collapse
 
lahirutech profile image
Lanka Rathanyaka

Thank you very much for this tutorial. I am using this to build a production app in my workplace, The issue I am going through is something like this.
Think that a button components is using types which are coming from icon component also.
So in the button component there will be a import like
import iconProps from "../icons/types"
but after building the project all the t.ds files are in root level but still the button.js build file has a import import iconProps from "../icons/types" which is not resolving since there is no icon folder inside the dist folder now. Seems like even I managed to add all the d.ts files in root level the js files using them are not resolving the path properly.
I am using multiple entry points for dts plugin as well.
So I managed to add d.ts files of some components on the relevant folder without ading them to root, which is not the ideal way I guess

    dts({
      include: [
        "src/components/**/*.{ts,tsx}",
        "src/icons/**/*.{ts,tsx}",
      ],
      exclude: ["**/*.stories.tsx"],

      beforeWriteFile: (filePath, content) => {
        if (
          filePath.includes("dist-storybook") ||
          filePath.includes(".stories.d.ts") ||
          filePath.includes("icons")
        ) {
          return { filePath, content };
        }
        const [projectPath, chunkPath] = filePath.split("/dist/");
        const chunk = chunkPath?.split("/").pop();
        return { filePath: `${projectPath}/dist/${chunk}`, content };
      },
    }),
  ],
Enter fullscreen mode Exit fullscreen mode
Collapse
 
receter profile image
Andreas Riedmüller

Hi Lanka,

Thanks, glad you like the article. Could you create a repo with a minimal example to reproduce this? Can you ellaborate on why you need this config for dts? Why don't you exclude the story files in the rollup config?

And here is a branch on how I would recommend to setup storybook: github.com/receter/my-component-li...

Collapse
 
waldronmatt profile image
Matthew Waldron • Edited

Andreas, thank you for sharing such a well thought out and detailed explanation of how to set up a component library. This subject isn't easy, but you did a great job explaining everything. I took inspiration from your setup and expanded it to include support for esm and cjs bundles in addition to subpath exports setup for explicit import references for anyone interested. You can find it here.

Collapse
 
j4v4scr1pt profile image
Js4 • Edited

I have problem that the generated main.js has imports to 3rd party libs.

Generated main.js:
import { Button as i } from "./components/Button/Button.js";
import "react/jsx-runtime";
import "tailwind-variants";
import "@nextui-org/react";
export {
i as Button
};

But my main.ts looks like this:
export {Button} from './components/Button/Button'

I have all three of them in the exclude block:
external: ['react','react/jsx-runtime',"tailwind-variants","framer-motion",RegExp("^(@nextui-org/).+")],

Why do Vite add them to the final build?!

Collapse
 
j4v4scr1pt profile image
Js4

I had removed this plugin in the Vite.config:

import {libInjectCss} from 'vite-plugin-lib-inject-css'

Because I dont use css files I use Tailwind classes.

But when I add this lib back the imports are not added.. 🤯

I have no idea why.. but at least it works....

Collapse
 
receter profile image
Andreas Riedmüller

Interesting, I will investigate this. Do you have a public repo to reproduce?

Thread Thread
 
j4v4scr1pt profile image
Js4 • Edited

Hi man!

I do not have a repo, this package is for internal use only.
But I created a sandbox for you to test with :). I tested and when you remove the libInjectCss from vite.config main.js gets the imports.. you run pnpm build-sb to build it and pnpm sb to start the project.

Sandbox Project

Collapse
 
federbeije profile image
FedericoReghiniBeije

Hy thank you so much for the guide. I do have a question though.
if in my library i'd like to achieve an import like: d

import { bau } from 'mylib/assets
import {wow} from 'mylib/hooks
Enter fullscreen mode Exit fullscreen mode

how can i do it.
do i have to pass an array of entries like?

my structors is
lib/assest
lib/hooks
etc

So sorry for the dumb question

Collapse
 
receter profile image
Andreas Riedmüller • Edited

Hi Federico,

You should be able to do that with subpath exports.
First create a file assets.ts alongside main.ts and export what you want:

// lib/assets.ts
export const bau = ":-)";
Enter fullscreen mode Exit fullscreen mode

Then in package.json you can define the exports like so:

{
…
-  "main": "dist/main.js",
-  "types": "dist/main.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/main.d.ts",
+      "default": "./dist/main.js",
+    },
+    "./assets": {
+      "types": "./dist/assets.d.ts",
+      "default": "./dist/assets.js",
+    },
…
}
Enter fullscreen mode Exit fullscreen mode

The "exports" provides a modern alternative to "main" allowing multiple entry points to be defined, conditional entry resolution support between environments, and preventing any other entry points besides those defined in "exports". This encapsulation allows module authors to clearly define the public interface for their package.
⚠️ When the "exports" field is defined, all subpaths of the package are encapsulated and no longer available to importers. For example, require('pkg/subpath.js') throws an ERR_PACKAGE_PATH_NOT_EXPORTED error.

You can read more about this here: nodejs.org/api/packages.html

Let me know if this works for you.

Collapse
 
mediabuff profile image
mediabuff

thanks for excellent article and sample code. The import CSS does not get transpiled to JS script. Thus I get run time error (when I use the library in another project).

For eg. Button/index.js has the following:
import "../../assets/index4.css";
Obvisiouly, this will not work in the brower. Should'nt this get transpiled as CSS Module ?

Collapse
 
receter profile image
Andreas Riedmüller • Edited

If the consuming environment does not have a bundler setup that supports CSS imports please see this answer: dev.to/receter/comment/2a198

You can disable these CSS imports and generate a CSS file that you can import sepparately.

Collapse
 
mediabuff profile image
mediabuff

thank you. Got it!

Collapse
 
kneumei profile image
Kyle Neumeier • Edited

One question I have:

If my component depends on another npm module (say lodash), when I run vite...the dependency would be bundled into my index.js file....

So do I have lodash listed as a dependency in package.json? If so, wouldn't that mean consumers would transitively install lodash unnecessarily?

So seems like I should either:

  1. install lodash as a normal dependency, but then also put lodash in external
  2. move lodash to devDependency
Collapse
 
andreiisakov1 profile image
Andrei

Hello!
Thanks for the tutorial! It is great! Unfortunately I was not able fully finish it. After I added glob config to my vite.config.ts, the "npm run build" started to return an error:

failed to load config from ..../vite.config.ts
error during build:
file:///..../vite.config.ts.timestamp-1694619764411-22e8f4327550d.mjs:7
import glob from "file:///..../node_modules/glob/dist/mjs/index.js";
^^^^
SyntaxError: The requested module 'file:///..../node_modules/glob/dist/mjs/index.js' does not provide an export named 'default'
at ModuleJob._instantiate (node:internal/modules/esm/module_job:122:21)
at async ModuleJob.run (node:internal/modules/esm/module_job:188:5)
at async DefaultModuleLoader.import (node:internal/modules/esm/loader:228:24)
at async loadConfigFromBundledFile (file:///..../node_modules/vite/dist/node/chunks/dep-df561101.js:66235:21)
at async loadConfigFromFile (file:///..../node_modules/vite/dist/node/chunks/dep-df561101.js:66086:28)
at async resolveConfig (file:///..../node_modules/vite/dist/node/chunks/dep-df561101.js:65682:28)
at async build (file:///..../node_modules/vite/dist/node/chunks/dep-df561101.js:47852:20)
at async CAC. (file:///..../node_modules/vite/dist/node/cli.js:822:9)

I use npm version 9.8.0, nvm version 20.5.1, vite version

Best Regards,
Andrei

Collapse
 
receter profile image
Andreas Riedmüller • Edited

Hi Andrei,

thanks for your comment, I am glad you like the tutorial!

I guess you need to do a named import:

import { glob } from 'glob'
Enter fullscreen mode Exit fullscreen mode

It was a "typo" in the article, I have updated it accordingly, thanks for your feedback!

Collapse
 
andreiisakov1 profile image
Andrei • Edited

Hello Andreas! Thanks a lot for the update! now everything works just perfectly!

Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
receter profile image
Andreas Riedmüller

I would need more detailed information to give you an helpful answer. Do you have a repo were this happens? Does it also happen when you install my demo npm library? (link at the bottom of the article) What bundler setup are you using?

Collapse
 
jamestbaker profile image
James Baker

@receter , thanks so much for this. Fantastic walkthrough!

Collapse
 
kevinah95 profile image
Kevin Hernández Rostrán

Thank you