In this guide we are going to set up a project for building and publishing a React component library with type declarations, step by step. For this purpose, we using Vite 6, React 19, TypeScript and Storybook.
Additionally, we will integrate essential code quality tools such as ESLint (updated to v9 flat config), Stylelint and Prettier.
Moreover, we are going to set up the testing environment using Vitest and React Testing Library.
You can find the template repository here.
This repository is only supporting ECMAScript modules, if you need a cjs
demo refer to this branch of the repository.
Features"undefined" anchor link
We are introducing the following features to enhance our project:
-
⚛️ React component library with TypeScript.
-
🏗️ Vite as development environment.
-
🌳 Tree shaking, for not distributing dead-code.
-
📚 Storybook for live viewing the components.
-
🎨 PostCSS for processing our CSS.
-
🖌️ CSS Modules in development, compiled CSS for production builds.
-
🧪 Testing with Vitest and React Testing Library.
-
✅ Code quality tools with ESLint, Prettier and Stylelint.
Initialize the Vite project"undefined" anchor link
Execute the appropriate command corresponding to your package manager.
# npm 7+, extra double-dash is needed:npm create vite@latest react-lib -- --template react-ts
# yarnyarn create vite react-lib --template react-ts
# pnpmpnpm create vite react-lib --template react-ts
# bunbun create vite react-lib --template react-ts
Changing the project structure"undefined" anchor link
Here’s our current structure, just after running the previous command.
📂 my-react-lib├─ 📂 src│ ├─ 📂 assets│ ├─ 📄 App.css│ ├─ 📄 index.css│ ├─ 📄 vite-env.d.ts│ ├─ 📄 App.tsx│ └─ 📄 main.tsx├─ 📄 index.html
We intend to turn this app project into a component library, so the next step involves deleting the Vite dev server-related files: App.css
, index.css
, App.tsx
, main.tsx
, and index.html
.
📂 my-react-lib├─ 📂 src│ ├─ 📂 assets│ ├─ 📄 App.css│ ├─ 📄 index.css│ ├─ 📄 vite-env.d.ts│ ├─ 📄 App.tsx│ └─ 📄 main.tsx├─ 📄 index.html
Also, we can delete the dev
and preview
scripts from the package.json
{ // ... "scripts": { "dev": "vite", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }}
And now, we are creating a /components
folder under /src
with our first component in it.
📂 my-react-lib├─ 📂 src│ ├─ 📂 assets│ ├─ 📂 components│ │ ├─ 📂 MyButton│ │ │ ├─ 📄 styles.module.css│ │ │ └─ 📄 index.tsx│ │ ├─ 📂 MyCounter│ │ │ ├─ 📄 styles.module.css│ │ │ └─ 📄 index.tsx│ │ └─ 📂 MyTitle│ │ ├─ 📄 styles.module.css│ │ └─ 📄 index.tsx│ └─ 📄 vite-env.d.ts├─ 📄 index.html
Under the component folder, in this case /components/MyButton
, we create this two files:
import styles from './styles.module.css'import clsx from 'clsx'
interface ComponentProps extends React.ComponentProps<'button'> { primary?: boolean size?: 'small' | 'medium' | 'large' label: string}
export function MyButton({ primary = false, size = 'medium', label, ...props }: ComponentProps) { const style = clsx(styles.button, { [styles['button--primary']]: primary, [styles[`button--${size}`]]: size, })
return ( <button type='button' className={style} {...props} > {label} </button> )}
.button { --bg-color: none;
background-color: var(--bg-color); border-radius: 6px;}
.button--primary { --bg-color: crimson;}
.button--small { font-size: 1rem; padding: 0.4rem 0.6rem;}
.button--medium { font-size: 1.2rem; padding: 0.6rem 0.8rem;}
.button--large { font-size: 1.4rem; padding: 0.8rem 1rem;}
We have created our first component! 🥳
Now, we need the library entry point. For this purpose, we create a new file called main.ts
under /src
.
📂 my-react-lib├─ 📂 src│ ├─ 📂 assets│ ├─ 📂 components│ │ ├─ 📂 MyButton│ │ │ ├─ 📄 styles.module.css│ │ │ └─ 📄 index.tsx│ │ ├─ 📂 MyCounter│ │ │ ├─ 📄 styles.module.css│ │ │ └─ 📄 index.tsx│ │ └─ 📂 MyTitle│ │ ├─ 📄 styles.module.css│ │ └─ 📄 index.tsx│ ├─ 📄 App.css│ ├─ 📄 index.css│ ├─ 📄 main.ts│ ├─ 📄 vite-env.d.ts│ ├─ 📄 App.tsx│ └─ 📄 main.tsx├─ 📄 index.html
export { MyButton } from './components/MyButton/'export { MyCounter } from './components/MyCounter/'export { MyTitle } from './components/MyTitle/'
At this point, we’ve organized the project files, crafted our inaugural component, and established the library’s entry point. However, Vite seems unable to locate it. Let’s address this issue!
Vite in library mode"undefined" anchor link
For bundling our library for distribution, we have to update the Vite configuration build.lib
option.
Also, we are using the triple slash TypeScript directive to add within the compiler the vite/client
types.
/// <reference types="vite/client" />import { resolve } from 'node:path'import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'
export default defineConfig({ plugins: [react()], build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'], }, },})
At this point, I suggest to install Node.js types to prevent TypeScript errors:
npm i -D @types/node
And then we should indicate Vite which dependencies are external and we don’t want to bundle into the library.
/// <reference types="vite/client" />import { resolve } from 'node:path'import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'
export default defineConfig({ plugins: [react()], build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'], }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'], output: { globals: { react: 'React', 'react-dom': 'React-dom', 'react/jsx-runtime': 'react/jsx-runtime', }, }, }, },})
At this stage, upon running the build
script, you will notice two issues:
- Declaration types are not being included in the
dist
folder. - Stylesheets are not being imported into the generated chunks. If multiple CSS files exist, they are unified into a single file.
Let’s handle this.
Adding styles"undefined" anchor link
Vite has a problem with CSS modules: when building, there is not an import CSS line in the generated code, so people who use this library have to manually import them when using the components, as exposed in this issue.
This is not the behavior we desire.
For resolving this problem, we are going to use vite-plugin-lib-inject-css
, a simple plugin that imports the processed styles into the generated chunks. So let’s install the package.
npm i -D vite-plugin-lib-inject-css
And then we need to add it as a plugin within vite.config.ts
.
/// <reference types="vite/client" />import { resolve } from 'node:path'import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({ plugins: [react(), libInjectCss()], build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'], }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'], output: { globals: { react: 'React', 'react-dom': 'React-dom', 'react/jsx-runtime': 'react/jsx-runtime', }, }, }, },})
At this point, if we run the build
script, we will find out that we are generating a single CSS file and a single JavaScript file with every component code in it.

But we resolved the first issue and styles are being imported in the built file:
import './main.css'import { jsx as l } from 'react/jsx-runtime'// ...
As we are creating a library, we would like to allow users to import just the CSS from the used components. So we are going to turn every file imported into an entry point as Rollup recommends.
Also, we are changing entryFileNames
and assetsFileNames
in order to keep the folder structure in the build.
/// <reference types="vite/client" />import path, { resolve } from 'node:path'import { fileURLToPath } from 'node:url'import { globSync } from 'glob'import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({ plugins: [react(), libInjectCss()], build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'], }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'], input: Object.fromEntries( globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => { // This remove `src/` as well as the file extension from each // file, so e.g. src/nested/foo.js becomes nested/foo const entryName = path.relative( 'src', file.slice(0, file.length - path.extname(file).length) ) // This expands the relative paths to absolute paths, so e.g. // src/nested/foo becomes /project/src/nested/foo.js const entryUrl = fileURLToPath(new URL(file, import.meta.url)) return [entryName, entryUrl] }) ), output: { entryFileNames: '[name].js', assetFileNames: 'assets/[name][extname]', globals: { react: 'React', 'react-dom': 'React-dom', 'react/jsx-runtime': 'react/jsx-runtime', }, }, }, },})
Building library types"undefined" anchor link
For solving this, we are going to use vite-plugin-dts
.
npm i -D vite-plugin-dts
Just add it in the Vite configuration plugins
option:
/// <reference types="vite/client" />import path, { resolve } from 'node:path'import { fileURLToPath } from 'node:url'import { globSync } from 'glob'import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'import dts from 'vite-plugin-dts'import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({ plugins: [ react(), libInjectCss(), dts({ tsconfigPath: 'tsconfig.app.json', }), ], build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'], }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'], input: Object.fromEntries( globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => { const entryName = path.relative( 'src', file.slice(0, file.length - path.extname(file).length) ) const entryUrl = fileURLToPath(new URL(file, import.meta.url)) return [entryName, entryUrl] }) ), output: { entryFileNames: '[name].js', assetFileNames: 'assets/[name][extname]', globals: { react: 'React', 'react-dom': 'React-dom', 'react/jsx-runtime': 'react/jsx-runtime', }, }, }, },})
Now, we are generating type declarations for our library.
ESLint"undefined" anchor link
When we init a Vite project, ESLint v9 (flat config) is included by default, so for now we don’t have to install any dependency.
The eslint.config.js
configuration file is like this. Modify it according to your preferences.
import js from '@eslint/js'import globals from 'globals'import reactHooks from 'eslint-plugin-react-hooks'import reactRefresh from 'eslint-plugin-react-refresh'import tseslint from 'typescript-eslint'
export default tseslint.config( { ignores: ['dist'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, })
Lastly, include these two scripts in the scripts section of your package.json
file:
{ // ... "scripts": { // ... "lint": "eslint . --ext ts,tsx", "lint:fix": "eslint . --ext ts,tsx --fix" }}
To enable ESLint live validation in your IDE using the ESLint VSCode extension, create a settings.json
file in the .vscode
folder.
📂 my-react-lib├─ 📂 .storybook│ ├─ 📄 main.ts│ └─ 📄 preview.ts├─ 📂 .vscode│ └─ 📄 settings.json├─ 📂 src│ ├─ 📂 assets│ ├─ 📂 components│ │ ├─ 📂 MyButton
Then, add the following content.
{ "eslint.validate": [ "javascript", "javascriptreact", "typescript", // Enable .ts "typescriptreact" // Enable .tsx ]}
Prettier"undefined" anchor link
Now let’s add Prettier.
This tool can conflict with ESLint rules, so we are going to add eslint-config-prettier
to handle this.
npm i -D prettier eslint-config-prettier
Create a .prettierrc
file with your preferred rules.
{ "jsxSingleQuote": true, "printWidth": 100, "semi": false, "singleAttributePerLine": true, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5"}
And the .prettierignore
file:
distnode_modules.github.changeset
And extend the ESLint configuration to prevent conflicts with Prettier.
import js from '@eslint/js'import globals from 'globals'import reactHooks from 'eslint-plugin-react-hooks'import reactRefresh from 'eslint-plugin-react-refresh'import tseslint from 'typescript-eslint'import eslintConfigPrettier from 'eslint-config-prettier'
export default tseslint.config( { ignores: ['dist'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, })
As a final step, incorporate these two scripts into the scripts
section of your package.json
file
{ // ... "scripts": { // ... "format": "prettier --check .", "format:fix": "prettier --write ." }}
Stylelint"undefined" anchor link
Let’s install Stylelint with the opinionated rules of stylelint-config-standard
.
npm i -D stylelint stylelint-config-standard
Remember that stylelint-config-prettier
is not anymore necessary in the latest versions of Stylelint (v15 or higher).
Create the .stylelintrc.mjs
file, with imported typings:
/** @type {import('stylelint').Config} */
export default { extends: ['stylelint-config-standard'], rules: { // Regex for BEM classes: https://gist.github.com/Potherca/f2a65491e63338659c3a0d2b07eee382 'selector-class-pattern': '^.[a-z]([a-z0-9-]+)?(__([a-z0-9]+-?)+)?(--([a-z0-9]+-?)+){0,2}$', },}
And the .stylelintignore
file:
dist
To finish up, insert these two scripts into the scripts field of your package.json
:
{ // ... "scripts": { // ... "stylelint": "stylelint **/*.css", "stylelint:fix": "stylelint **/*.css --fix" }}
To enable Stylelint live validation in your IDE using the Stylelint VSCode extension, update the previously created settings.json
file in the .vscode
folder.
{ "eslint.validate": [ "javascript", "javascriptreact", "typescript", // Enable .ts "typescriptreact" // Enable .tsx ], "stylelint.validate": ["scss", "css"]}
Storybook"undefined" anchor link
This tutorial is updated to Storybook 8.
For installing needed dependencies for Storybook we are using the Storybook CLI. Run the following command in the root of the project.
npx storybook@latest init
After the installation, we will update our ESLint configuration to ignore the .storybook
folder and integrate eslint-plugin-storybook
.
import js from '@eslint/js'import globals from 'globals'import reactHooks from 'eslint-plugin-react-hooks'import reactRefresh from 'eslint-plugin-react-refresh'import tseslint from 'typescript-eslint'import eslintConfigPrettier from 'eslint-config-prettier'import storybook from 'eslint-plugin-storybook'
export default tseslint.config( { ignores: ['dist', '.storybook'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, }, { files: ['**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)'], extends: [...storybook.configs['flat/recommended']], })
Also, two folders were created:
/src/stories
: This directory contains a handful of examples showcasing components, pages, and their stories. We will integrate our custom component stories moving forward, so let’s remove it.
📂 my-react-lib├─ 📂 .storybook│ ├─ 📄 main.ts│ └─ 📄 preview.ts├─ 📂 src│ ├─ 📂 assets│ ├─ 📂 components│ │ ├─ 📂 MyButton│ │ │ ├─ 📄 styles.module.css│ │ │ └─ 📄 index.tsx│ │ ├─ 📂 MyCounter│ │ │ ├─ 📄 styles.module.css│ │ │ └─ 📄 index.tsx│ │ └─ 📂 MyTitle│ │ ├─ 📄 styles.module.css│ │ └─ 📄 index.tsx│ ├─ 📂 stories│ │ ├─ ...│ │ ├─ ...│ │ └─ ...│ └─ 📄 vite-env.d.ts├─ 📄 index.html
/.storybook
: Situated at the project’s root, this directory contains essential Storybook configuration files.
📂 my-react-lib├─ 📂 .storybook│ ├─ 📄 main.ts│ └─ 📄 preview.ts├─ 📂 src│ ├─ 📂 assets│ ├─ 📂 components│ │ ├─ 📂 MyButton│ │ │ ├─ 📄 styles.module.css│ │ │ └─ 📄 index.tsx│ │ ├─ 📂 MyCounter│ │ │ ├─ 📄 styles.module.css│ │ │ └─ 📄 index.tsx│ │ └─ 📂 MyTitle│ │ ├─ 📄 styles.module.css│ │ └─ 📄 index.tsx│ └─ 📄 vite-env.d.ts├─ 📄 index.html
The .storybook/main.ts
file defines how a Storybook project works, such as the location of the stories, which addons are included, any special features activated, and other specific project settings.
import type { StorybookConfig } from '@storybook/react-vite'
const config: StorybookConfig = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], addons: [ '@storybook/addon-onboarding', '@storybook/addon-links', '@storybook/addon-essentials', '@chromatic-com/storybook', '@storybook/addon-interactions', ], framework: { name: '@storybook/react-vite', options: {}, }, docs: { autodocs: 'tag', },}export default config
The ./storybook/preview.ts
file defines global decorators and parameters that will be applied across all stories.
import type { Preview } from '@storybook/react'
const preview: Preview = { parameters: { controls: { matchers: { color: /(background|color)$/i, date: /Date$/i, }, }, },}
export default preview
Additionally, let’s modify the dts
configuration within vite.config.ts
to ensure that stories are excluded from the type declarations build process.
/// <reference types="vite/client" />import path, { resolve } from 'node:path'import { fileURLToPath } from 'node:url'import { globSync } from 'glob'import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'import dts from 'vite-plugin-dts'import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({ plugins: [ react(), libInjectCss(), dts({ exclude: ['**/*.stories.tsx'], tsconfigPath: 'tsconfig.app.json', }), ], build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'], }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'], input: Object.fromEntries( globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => { // This remove `src/` as well as the file extension from each // file, so e.g. src/nested/foo.js becomes nested/foo const entryName = path.relative( 'src', file.slice(0, file.length - path.extname(file).length) ) // This expands the relative paths to absolute paths, so e.g. // src/nested/foo becomes /project/src/nested/foo.js const entryUrl = fileURLToPath(new URL(file, import.meta.url)) return [entryName, entryUrl] }) ), output: { entryFileNames: '[name].js', assetFileNames: 'assets/[name][extname]', globals: { react: 'React', 'react-dom': 'React-dom', 'react/jsx-runtime': 'react/jsx-runtime', }, }, }, },})
And, finally, let’s integrate our component story. Create a file called MyButton.stories.tsx
inside the component folder, and do the same with the rest of the components.
📂 my-react-lib├─ 📂 .storybook│ ├─ 📄 main.ts│ └─ 📄 preview.ts├─ 📂 src│ ├─ 📂 assets│ ├─ 📂 components│ │ ├─ 📂 MyButton│ │ │ ├─ 📄 styles.module.css│ │ │ ├─ 📄 MyButton.stories.tsx│ │ │ └─ 📄 index.tsx│ │ ├─ 📂 MyCounter│ │ │ ├─ 📄 styles.module.css│ │ │ ├─ 📄 MyCounter.stories.tsx│ │ │ └─ 📄 index.tsx│ │ └─ 📂 MyTitle│ │ ├─ 📄 styles.module.css│ │ ├─ 📄 MyTitle.stories.tsx│ │ └─ 📄 index.tsx│ └─ 📄 vite-env.d.ts├─ 📄 index.html
Let’s start by defining different views of our MyButton
component. We’ll showcase its different variants, sizes, and functionalities. Additionally, we’ll incorporate a mock onClick
event to demonstrate interactivity.
import type { Meta, StoryObj } from '@storybook/react'import { fn } from '@storybook/test'
import { MyButton } from './'
const meta = { title: 'Components/MyButton', component: MyButton, parameters: { layout: 'centered', }, tags: ['autodocs'], args: { label: 'Button', onClick: fn(), },} satisfies Meta<typeof MyButton>
export default metatype Story = StoryObj<typeof meta>
export const Primary: Story = { args: { primary: true, },}
export const Secondary: Story = { args: { primary: false, },}
export const Large: Story = { args: { size: 'large', },}
export const Small: Story = { args: { size: 'small', },}
The Storybook CLI also added two scripts in our package.json
.
{ // ... "scripts": { // ... "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }}
With this setup completed, we’re prepared to launch the Storybook development server or to build the Storybook distribution.
PostCSS"undefined" anchor link
PostCSS allows us to process our CSS and comes integrated in Vite by default.
In this case, we are going to add Autoprefixer, a PostCSS plugin that add vendor prefixes to CSS rules.
Firstly, let’s install the necessary dependency.
npm i -D autoprefixer
Then, create a postcss.config.cjs
file in the project root and add the following code for loading the plugin:
module.exports = { plugins: [require('autoprefixer')],}
If you wish to customize the Autoprefixer browser support, you have two options:
- Define a
browserslist
field in thepackage.json
- Create a
.browserslistrc
file in the project root.
Personally, I prefer the autonomous file approach. So let’s add some configuration for demo purposes, feel free to customize as you want.
defaults> 0.1%not dead
With this settings in place, we are ready. To verify if Autoprefixer is working as expected, let’s prepare a quick example. Simply, insert the following line in styles.module.css
:
.button { --bg-color: none;
background-color: var(--bg-color); border-radius: 6px;}
.button--primary { --bg-color: crimson;
backdrop-filter: sepia(90%);}
.button--small { font-size: 1rem; padding: 0.4rem 0.6rem;}
.button--medium { font-size: 1.2rem; padding: 0.6rem 0.8rem;}
.button--large { font-size: 1.4rem; padding: 0.8rem 1rem;}
After executing the build script, navigate to the dist/assets/index2.css
. If everything works correctly, you will find the needed vendor prefix for the backdrop-filter
rule appended:
._button_31ovf_1{--bg-color: none;background-color:var(--bg-color);border-radius:6px}._button--primary_31ovf_15{--bg-color: crimson;-webkit-backdrop-filter:sepia(90%);backdrop-filter:sepia(90%)}._button--small_31ovf_27{font-size:1rem;padding:.4rem .6rem}._button--medium_31ovf_37{font-size:1.2rem;padding:.6rem .8rem}._button--large_31ovf_47{font-size:1.4rem;padding:.8rem 1rem}
Testing with Vitest"undefined" anchor link
Yes, don’t run away!
Let’s set up the testing environment. For this purpose, we are going to use Vitest and React Testing Library.
First of all, we are going to install the necessary dependencies. You can view the official Vitest example here.
npm i -D vitest jsdom @testing-library/react
Then, add the test
script to the package.json
.
{ // ... "scripts": { // ... "test": "vitest" }}
Now, let’s create a src/test/setup.ts
file. To access testing methods, it’s necessary to import @testing-library/jest-dom
in it.
import '@testing-library/jest-dom'import { afterEach } from 'vitest'import { cleanup } from '@testing-library/react'
// hooks are reset before each suiteafterEach(() => { cleanup()})
Now, we are going to create a test for our component. Begin crafting a file named MyButton.test.tsx
within src/components/MyButton/
.
This test will cover basic aspects, such as rendering, prop passing, disable property and onClick
function.
import { cleanup, fireEvent, render, screen } from '@testing-library/react'import { afterEach, describe, it, vi } from 'vitest'import { MyButton } from '.'
describe('MyButton test:', () => { afterEach(cleanup)
it('should render component', () => { render(<MyButton label='Testing' />) })
it('should render label', () => { render(<MyButton label='Testing' />) screen.getByText('Testing') })
it('should be disabled', () => { render( <MyButton label='Testing' disabled /> ) expect(screen.getByRole('button')).toBeDisabled() })
it('onClick triggers properly', async () => { const mockFn = vi.fn() render( <MyButton onClick={mockFn} label='Testing' /> ) expect(mockFn).toHaveBeenCalledTimes(0) fireEvent.click(screen.getByRole('button')) expect(mockFn).toHaveBeenCalledTimes(1) })
it('disabled prevents action', async () => { const mockFn = vi.fn() render( <MyButton onClick={mockFn} label='Testing' disabled /> ) expect(mockFn).toHaveBeenCalledTimes(0) fireEvent.click(screen.getByRole('button')) expect(mockFn).toHaveBeenCalledTimes(0) })})
Now, let’s set up Vitest.
Following Vite recommendations, we are using the same file for Vite and Vitest configuration.
For this purpose, we need to extend the test
field within vite.config.ts
to correctly setup the test environment, and dts
configuration to exclude our tests files from the type declarations from the build.
Also, as we previously did when configuring Vite, lets add a reference to Vitest types with the triple slash directive.
/// <reference types="vite/client" />/// <reference types="vitest" />import path, { resolve } from 'node:path'import { fileURLToPath } from 'node:url'import { globSync } from 'glob'import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'import dts from 'vite-plugin-dts'import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({ plugins: [ react(), libInjectCss(), dts({ exclude: ['**/*.stories.tsx', 'src/test', '**/*.test.tsx'], tsconfigPath: 'tsconfig.app.json', }), , ], build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'], }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'], input: Object.fromEntries( globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => { const entryName = path.relative( 'src', file.slice(0, file.length - path.extname(file).length) ) const entryUrl = fileURLToPath(new URL(file, import.meta.url)) return [entryName, entryUrl] }) ), output: { entryFileNames: '[name].js', assetFileNames: 'assets/[name][extname]', globals: { react: 'React', 'react-dom': 'React-dom', 'react/jsx-runtime': 'react/jsx-runtime', }, }, }, }, test: { globals: true, environment: 'jsdom', setupFiles: './src/test/setup.ts', // you might want to disable it, if you don't have tests that rely on CSS // since parsing CSS is slow css: true, },})
We are ready to run our tests with the npm run dev
command:

Adding Vitest UI and coverage"undefined" anchor link
Now we are going to install two slightly less commonly used features, but they may be of interest to certain users.
The first one is @vitest/ui
, a visual interface for viewing and interacting with tests.
The second one is @vitest/coverage
, a code coverage tool for your code. Testing coverage evaluates how much of the code base is tested, aiding in identifying untested areas and potential errors.
Firstly, install both dependencies.
npm i -D @vitest/ui @vitest/coverage-v8
Next, add a new test:ui
script in the package.json
file with the --ui
flag. Additionally, add another script called coverage
.
{ // ... "scripts": { // ... "test": "vitest", "test:ui": "vitest --ui", "coverage": "vitest run --coverage" }}
From now on, running the test:ui
script will open a dev server with a test UI.
Now, add the coverage
field in test
config within vite.config.ts
.
/// <reference types="vite/client" />/// <reference types="vitest" />import path, { resolve } from 'node:path'import { fileURLToPath } from 'node:url'import { globSync } from 'glob'import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'import dts from 'vite-plugin-dts'import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({ plugins: [ react(), libInjectCss(), dts({ exclude: ['**/*.stories.tsx'], tsconfigPath: 'tsconfig.app.json', }), ], build: { lib: { entry: resolve(__dirname, 'src/main.ts'), formats: ['es'], }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime'], input: Object.fromEntries( globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => { const entryName = path.relative( 'src', file.slice(0, file.length - path.extname(file).length) ) const entryUrl = fileURLToPath(new URL(file, import.meta.url)) return [entryName, entryUrl] }) ), output: { entryFileNames: '[name].js', assetFileNames: 'assets/[name][extname]', globals: { react: 'React', 'react-dom': 'React-dom', 'react/jsx-runtime': 'react/jsx-runtime', }, }, }, }, test: { globals: true, environment: 'jsdom', setupFiles: './src/test/setup.ts', css: true, coverage: { include: ['src/components'], exclude: ['**/*.stories.tsx'], }, },})
When we run the coverage
script, a full coverage report will appear in the console, and a /coverage
folder will be created in the project root with the results.
Publishing the package on npm"undefined" anchor link
In this section, we will address how to correctly publish a package on npm, paying special attention to the scripts and necessary fields in the package.json
file.
Defining entry points in the package.json
"undefined" anchor link
In our package.json
we have to properly set some fields:
-
type
: should be set tomodule
. With this value we are indicating we are using ES module syntax. This setting prompts Vite to generate bothjs
files (ES Modules) andcjs
files (CommonJS) if nobuild.lib.formats
is set, but we only want to . -
files
: describes the entries to be included when the package is published. We are addingdist
. -
exports
: the entry points to the library. -
module
: this is not an official feature in Node.js, but some bundlers still support it. -
types
: exposes the types declarations entry point. A must for TypeScript users.
{ // Rest of package.json "type": "module", "files": ["dist"], "exports": "./dist/main.js", "module": "./dist/main.js", "types": "./dist/main.d.ts"}
And what about
main
field?main
is used to point the cjs
entry point and we are not supporting it.
Useful publish workflow scripts"undefined" anchor link
We will enhance our workflow by incorporating a very useful script into our package.json
file: the prepublishOnly
script. This script executes before the package is prepared and packed, exclusively triggering during an npm publish operation.
Implementing this, it is guaranteed that our tests are successfully passing and that the build process is completed before we publish to the npm registry.
{ // ... "scripts": { // ... "prepublishOnly": "vitest run && npm run build" }}
Now, if we run the npm publish
command, the whole workflow will trigger if there is no error in tests and TypeScript check.

Conclusion"undefined" anchor link
We have just created a repository from scratch for building and publishing a React component library with type declarations. We have used Vite with TypeScript and PostCSS. For live viewing, we opted for Storybook. And for testing, we chose Vitest and React Testing Library. Also, we have integrated several code quality tools as ESLint, Stylelint and Prettier.
Feel free to check and utilize this template in the GitHub repository.
Furthermore, if you have any comment, doubt or issue, do not hesitate to open an issue.