The Simplest Method to Create a Vue.js Component Library (with UnJS and TypeScript)

- Lire en français

TL;DR: vue-library Continue reading to grasp the process behind it and understand the reasons for my choices.

A component library is a collection of reusable pieces that can be employed in various projects. It facilitates sharing resources among different projects and teams. These components may be low-level and generic like buttons, inputs, and modals, or more business-specific modules. Ultimately, it's a method to share code across projects, a crucial skill that can significantly save time.

However, building a component library with Vue.js is harder than it seems because of the Single File Components (SFC). In a standard TypeScript project, you usually transpile to JavaScript and bundle your files with a tool like tsup or Vite.

Note

Remember to deliver package code as native as possible and let userland tools handle transpiling and optimization. It's a principle I always keep in mind when crafting libraries, streamlining the process significantly. Each case is unique, but this is a solid guideline to follow.

The use of Vue.js SFC complicates this process. Let's examine why by exploring the challenges you'll encounter when constructing a Vue.js component library.

Consider the following component:

vue
<script lang="ts" setup>
import { useUser } from '../composables'

const { user } = useUser()
</script>

<template>
  <div v-if="user">
    {{ user.name }}
  </div>
</template>

The composable is as follows:

ts
import { ref } from 'vue'

export function useUser() {
  const user = ref({ name: 'John Doe' })

  return {
    user
  }
}

The component employs a composable imported from another file. It may seem trivial, but this poses a significant issue.

All of this follows the architecture below:

src/
  components/
    User.vue
  composables/
    useUser.ts
  index.ts

The index.ts file acts as the library's entry point and it looks like this:

ts
import User from './components/User.vue'

export { User }

export { useUser } from './composables/useUser'

Plus, the package.json includes some exports:

json
{
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs"
    }
  },
  "main": "dist/index.mjs",
  "types": "dist/index.d.ts"
}

Similar to a Standard TypeScript Project

Note

A bundle is a single file encapsulating all your project's code. It optimizes project loading by minimizing the number of files to load. This bundle isn't transpiled (except TypeScript to JavaScript) or minified when creating an npm package.

Now, if we attempt to bundle this project, our dist folder will appear like this:

dist/
  index.cjs

Upon inspecting index.cjs, you'll find a surprise: the useUser composable is inlined, but the component is entirely ignored. If it functions, the bundler might throw an error like No loader is configured for ".vue" files. Essentially, tools like esbuild or tsc don't know how to handle Vue files. It's logical since Vue files aren't JavaScript files.

The problem becomes evident. We need some configuration to handle Vue files. Let's quickly browse the internet for something like vue loader, as we're utilizing Vue files and need to transpile them to JavaScript.

The top result is Vue Loader, which is a Webpack loader. No. I refuse to use Webpack. Vite is now a standard, and there's no justification for employing Webpack in this context.

But before delving further, let's revisit our mantra: deliver package code as native as possible and keep it simple, stupid! Transpiling Vue SFC files using Webpack seems contrary to this principle.

Ignoring Vue Files

To deliver .vue files without transpiling them, we instruct our bundler to disregard them. This is achieved using a tool called unbuild from the UnJS ecosystem.

Unbuild is a straightforward tool yet highly configurable since it's constructed atop rollup.

For our small project, we can directly experiment with it:

sh
npx unbuild

Unfortunately, it fails for the same reasons as before:

txt
src/components/ShowGitHubUser.vue (1:0): Expression expected (Note that you need plugins to import files that are not JavaScript)

But unbuild has the potential for much more.

To tackle this problem, let's attempt the obvious solution. In the index.ts file, remove the component export and solely export TypeScript files:

ts
export { useUser } from './composables/useUser'

Now, the command npx unbuild functions properly.

sh
 npx unbuild
 Automatically detected entries: src/index [esm] [dts]
 Building vue-library
 Cleaning dist directory: ./dist
 Build succeeded for vue-library
  dist/index.mjs (total size: 139 B, chunk size: 139 B, exports: useUser)

Σ Total dist size (byte size): 531 B

This is a good start, but our components remain in the src folder and no matter how diligently you search, they're missing from the dist folder.

Copying Vue Files

Now that .ts files are processed correctly, we can attempt to copy the .vue files to the dist folder using a simple cp command.

sh
cp -r src/components/ dist/components/

Subsequently, we add an exports field in the package.json to inform the user where to locate the components:

json
{
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs"
    },
    "./components/*": {
      "import": "./dist/components/*.vue"
    }
  },
  "main": "dist/index.mjs",
  "types": "dist/index.d.ts"
}

Looks promising, doesn't it? But it isn't. 😔

The Import Path Dilemma

In our Vue component, the useUser composable is imported like this:

vue
<script lang="ts" setup>
import { useUser } from '../composables'
</script>

Meanwhile, our dist folder is structured as follows:

dist/
  components/
    User.vue
  index.d.mts
  index.d.ts
  index.mjs

Can you identify the problem? 👀

The component is unusable and will produce an error like Cannot find module '../composables' or its corresponding type declarations, because the path leads nowhere. We've bundled all of our TypeScript files into a single file index.mjs, losing the project's structure entirely.

To Bundle or Not to Bundle?

From here, we have two choices:

  1. Bundling the Vue files using Vite, plugins, and extensive configuration.
  2. Maintaining the directory structure and using a tool like mkdist for file-to-file transpiling (bundleless build) on TypeScript files.

The decision largely hinges on your specific needs, but I'm convinced that simplicity is the best approach. So, let's explore the second option.

The goal is to transform the TypeScript files into JavaScript files, preserving the original structure of the src folder in the dist folder and ignoring the Vue files.

Note

Using a <script setup lang="ts"> block, mkdist will ignore Vue files to enable the Vue compiler to generate runtime props. If not, it will generate a .vue.d.ts file to offer types for the Vue component. Remember that the @vitejs/plugin-vue and Vite inherently understand TypeScript script blocks. For more details, refer to issue mkdist#14.

Configuring Unbuild

Yes, we will configure unbuild since it incorporates mkdist, making it exceptionally user-friendly.

First, create a build.config.ts file at the project's root. Unbuild will read this configuration file to understand how to process the project. It assumes defaults and infers many aspects from the package.json, but we need to tell it to utilize mkdist.

ts
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  entries: ['./src/'],
  declaration: true,
})

It's a very straightforward configuration file. And I adore it. The entries key informs unbuild of the initial file to begin transpiling. However, ./src/ is a directory, indicated by the / at the end. With this hint, unbuild bypasses the default bundler rollup in favor of mkdist. The declaration key prompts unbuild to create TypeScript declaration files (.d.ts).

We can also remove the exports field in the package.json since we won't be bundling files anymore and restore the component export in the index.ts file.

Let's rerun the npx unbuild command and witness the magic.

sh
 npx unbuild
 Building vue-library
 Cleaning dist directory: ./dist
 Build succeeded for vue-library
  dist (total size: 600 B)
  └─ dist/index.d.ts (108 B)
  └─ dist/index.mjs (112 B)
  └─ dist/composables/useUser.d.ts (79 B)
  └─ dist/composables/useUser.mjs (124 B)
  └─ dist/components/User.vue (177 B)
Σ Total dist size (byte size): 600 B

Now, the dist folder appears like this:

dist/
  components/
    User.vue
  composables/
    useUser.d.ts
    useUser.mjs
  index.d.ts
  index.mjs

This mirrors the structure of the src folder, and the User.vue component is now usable in the dist folder. The relative import path remains intact, and the component can be utilized in any Vue project. 🥳

Local Development

Many tutorials halt here, suggesting you're ready to publish your package to npm. But how can you create a complex library if you can't see the outcome of your work? If you can't test it while building it?

Basically, you can't.

Let's examine how to use the library in a local project, within the same repository for simplicity.

I'll demonstrate the simplest method using a pnpm workspace.

Start by creating a pnpm-workspace.yaml file at the project's root:

yaml
packages:
  - .
  - playground

Then, create a new Vite project in a playground folder:

sh
npx create-vite playground --template vue-ts

Since we're using a pnpm workspace, install the dependencies from the project's root:

sh
pnpm install

Note

This step isn't mandatory. You can create a new Vite project and install dependencies within it. Pnpm simplifies this by installing dependencies for all workspaces in the root. A simple pnpm install installs dependencies from all workspaces.

Now, we can use this playground library within a "real" project to test our library.

For instance, open the src/App.vue file and import the User component:

vue
<script setup lang="ts">
import { User } from '../../src'
</script>

<template>
  <User />
</template>

And run the project:

sh
cd playground && pnpm dev

You'll see the User component displayed in the browser. 🎉 So simple, yet so powerful. I use this method for virtually every library I build, and it works brilliantly.

Publishing to npm

This part is straightforward once you grasp the workflow.

Begin by naming your library. I'll use @barbapapazes/vue-library for this example.

Next, create an account on npm and log in using the npm login command.

Then, install changelogen to generate a changelog, update the version number following semantic versioning and commit convention, and to pre-fill the GitHub release.

sh
pnpm i -D changeloggen

Next, add two scripts to your package.json:

json
{
  "scripts": {
    "prepack": "unbuild",
    "release": "changelogen --release && npm publish --access public && git push --follow-tags"
  }
}

The prepack script runs the unbuild command before publishing the package to npm. The release script generates a changelog, publishes the package to npm, and pushes the tags to GitHub.

Note

Your project should be on GitHub for the --release option to work. If not, omit it and create the release manually on your repository.

Now, publish your package to npm using the following command:

sh
npm run release

And that's it! You've just published your first Vue.js component library to npm. 🚀

Everything is Ready

We're done for today. We've covered numerous topics:

  • How to build a Vue.js component library with TypeScript.
  • How to manage Vue files in a TypeScript project.
  • How to use unbuild to transpile TypeScript files without bundling Vue files.
  • How to leverage mkdist to maintain the project structure.
  • How to utilize a pnpm workspace to test the library in a local project.
  • How to publish the library to npm.

For a more detailed and complex example, check GitHub: vue-library but everything explained here stems from my real-world experience.

I hope you enjoyed this tutorial and that it helps you build your own Vue.js component library. If you have questions, feel free to reach out to me on Twitter.

Support my work
Follow me on