Skip to content

How to Create and Deploy a Vue Component Library to NPM

Introduction

When working across multiple Vue projects that share the same design system, it is more efficient and faster to have a component library that you can reference for all your components within the different projects. In this article, we will go through the steps needed to create and deploy a Vue component library to npm, so we can reuse them across various projects.

  • Create a vue component library
  • Register library components
  • Setup a build process
  • Test locally then publish our library to npm

Create a Vue component library

Setup our Project

Let’s start by creating our Vue project. We would be using yarn for package management.

To start, lets run

npm init

For this tutorial, we are going to create just a single component within our component library. One of the things to consider when building a component library at scale is to allow import of just single components to enable tree shaking.

We would be using this folder structure for our component library:

- src /
  - components /
    - button /
      - button.vue
      - index.ts

    - index.ts

  - styles /
    - components /
        - _button.scss

    - index.scss

- Package.json
- rollup.config.js

Create button component

Let's create a simple button component. We define the basic props our component can accept and compute the class based on the props.

Add this to the button.vue file.

<!-- src/components/button/button.vue -->
<template>
    <button
        v-bind="$attrs"
        :class="rootClasses"
        :type="type"
        :disabled="computedDisabled"
    >
      <slot></slot>
    </button>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
    name: 'DSButton',
    inheritAttrs: false,
    props: {
        /**
         * disabled status
         * @values true, false
         */
        disabled: {
            type: Boolean,
        },
        /**
        * Color of button
        * @values primary, secondary
        */
        variant: {
            type: String,
            validator: (value: string) => {
                return [
                    'primary',
                    'secondary'
                ].indexOf(value) >= 0
            }
        },
        /**
         * type of button
         * @values button, submit
         */
        type: {
            type: String,
            default: 'button',
            validator: (value: string) => {
                return [
                    'button',
                    'submit',
                    'reset'
                ].indexOf(value) >= 0
            }
        },
        /**
         * Size of button
         * @values sm, md, lg
         */
        size: {
            type: String,
            validator: (value: string) => {
                return [
                    'sm',
                    'md',
                    'lg'
                ].indexOf(value) >= 0
            }
        }
    },
    computed: {
        rootClasses() {
            return [
                'ds-button',
                'ds-button--' + this.size,
                'ds-button--' + this.variant
            ]
        },
        computedDisabled() {
            if (this.disabled) return true
            return null
        }
    }
})
</script>

Let's style the button component, added our variant and size classes based on what is specified in the button component file.

Add this to the _button.scss file.

// src/styles/components/_button.scss
$primary: '#0e34cd';
$secondary: '#b9b9b9';
$white: '#ffffff';
$black: '#000000';
$small: '.75rem';
$medium: '1.25rem';
$large: '1.5rem';

.ds-button {
    position: relative;
    display: inline-flex;
    cursor: pointer;
    text-align: center;
    white-space: nowrap;
    align-items: center;
    justify-content: center;
    vertical-align: top;
    text-decoration: none;
    outline: none;

    // variant
    &--primary {
        background-color: $primary;
        color: $white;
    }
    &--secondary {
        background-color: $secondary;
        color: $black;
    }

    // size
    &--sm {
        min-width: $small;
    }
    &--md {
        min-width: $medium;
    }
    &--lg {
        min-width: $large;
    }
}

Register library components

Next we need to register our components. To do this, we import all our components into a single file and create our install method.

// src/components/button/index.ts This file exports the install methods as default. For cases where we need to import just this button component in our other projects, it also exports Button which we will use in a bit.

// src/components/button/index.ts
import { App, Plugin } from 'vue'

import Button from './button.vue'

export default {
    install(Vue: App) {
        Vue.component(Button.name, Button)
    }
} as Plugin

export {
    Button as DSButton
}

// src/components/index.ts Lets import all the components in our components folder here. Since we have only our button component, we will imort it.

// src/components/index.ts
import Button from './button'

export {
    Button
}

// src/styles/index.scss Lets import all the components styles into index.scss in our styles folder. This helps us with having one source of export for all of our styles.

// src/styles/index.scss
@import "components/_button";

// src/index.ts Lets import all the components into index.ts in our src folder. Here, we create our install method for all the component. We export DSLibrary as default, and also export all our components.

// src/index.ts
import { App } from 'vue'

import * as components from './components'

const DSLibrary = {
    install(app: App) {
        // Auto import all components
        for (const componentKey in components) {
            app.use((components as any)[componentKey])
        }
    }
}

export default DSLibrary

// export all components as vue plugin
export * from './components'

Lets create a file called shim-vue.d.ts to help us with importing Vue files into our TypeScript files, and remove any linting errors caused by it.

// src/shim-vue.d.ts
declare module '*.vue' {
  import type { DefineComponent } from 'vue';
  const component: DefineComponent<{}, {}, any>;
  export default component;
}

Setup a build process

In building the library, we need to create a bundled and minidifed of the library that will be shared to npm, for this we would use Rollup. Rollup is a module bundler for JavaScript which compiles small pieces of code into something larger.

  • build component (vue code)
  • build styling (scss code)

Install rollup, and all of the rollup modules we would need.

$ npm i -D rollup rollup-plugin-vue rollup-plugin-terser rollup-plugin-typescript2 @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve

Build component (vue code)

we need two major build types for our component library to support various projects.

  • ES module - It has no requirements or exports
  • CommonJS module

Lets create a rollup.config.js file in the root folder and paste this there.

import { text } from './build/banner.json'
import packageInfo from './package.json'

import vue from 'rollup-plugin-vue'
import node from '@rollup/plugin-node-resolve'
import cjs from '@rollup/plugin-commonjs'
import babel from '@rollup/plugin-babel'
import { terser } from 'rollup-plugin-terser'
import typescript from 'rollup-plugin-typescript2';

import fs from 'fs'
import path from 'path'

const baseFolderPath = './src/components/'
const banner = text.replace('${version}', packageInfo.version)

const components = fs
    .readdirSync(baseFolderPath)
    .filter((f) =>
        fs.statSync(path.join(baseFolderPath, f)).isDirectory()
    )

const entries = {
    'index': './src/index.ts',
    ...components.reduce((obj, name) => {
        obj[name] = (baseFolderPath + name)
        return obj
    }, {})
}

const babelOptions = {
    babelHelpers: 'bundled'
}

const vuePluginConfig = {
    template: {
        isProduction: true,
        compilerOptions: {
            whitespace: 'condense'
        }
    }
}

const capitalize = (s) => {
    if (typeof s !== 'string') return ''
    return s.charAt(0).toUpperCase() + s.slice(1)
}

export default () => {
    let config = []

    if (process.env.MINIFY === 'true') {
        config = config.filter((c) => !!c.output.file)
        config.forEach((c) => {
            c.output.file = c.output.file.replace(/\.m?js/g, r => `.min${r}`)
            c.plugins.push(terser({
                output: {
                    comments: '/^!/'
                }
            }))
        })
    }
    return config
}

Next, we create a build folder in the root of our project, and add a file called banner.json to it. We want our builds to contain the current app version everytime we build. This file is already imported into the rollup.config.js file, and we use the package version from our package.json to update the version.

{
    "text": "/*! DS Library v${version} */\n"
}

Currently, our config is an empty array. Next, we will add the different builds we want.

entries: path to files we want rollup to bundle external: the external package needed output.format: the bundled file format output.dir: the bundled files directory output.banner: text added to the begining of the file plugins: specify methods used to customize rollup behaviour

  • First we create an esm build for each component in our library:

        config = [{
             input: entries,
             external: ['vue'],
             output: {
                 format: 'esm',
                 dir: `dist/esm`,
                 entryFileNames: '[name].mjs',
                 chunkFileNames: '[name]-[hash].mjs',
             },
             plugins: [
                 node({
                     extensions: ['.vue', '.ts']
                 }),
                 typescript({
                     typescript: require('typescript')
                 }),
                 vue(vuePluginConfig),
                 babel(babelOptions),
                 cjs()
             ]
         }],
    
  • Next we create a single esm build for all the components:

        config = [
          ...,
         {
             input: 'src/index.ts',
             external: ['vue'],
             output: {
                 format: 'esm',
                 file: 'dist/ds-library.mjs',
                 banner: banner
             },
             plugins: [
                 node({
                     extensions: ['.vue', '.ts']
                 }),
                 typescript({
                     typescript: require('typescript')
                 }),
                 vue(vuePluginConfig),
                 babel(babelOptions),
                 cjs()
             ]
         }
       ],
    
  • Then we create an cjs build for each component in our library:

        config = [
          ...,
          ...,
         {
             input: entries,
             external: ['vue'],
             output: {
                 format: 'cjs',
                 dir: 'dist/cjs',
                 exports: 'named'
             },
             plugins: [
                 node({
                     extensions: ['.vue', '.ts']
                 }),
                 typescript({
                     typescript: require('typescript')
                 }),
                 vue(vuePluginConfig),
                 babel(babelOptions),
                 cjs()
             ]
         }
       ],
    
  • After that, we create a single cjs build for each component in our library

        config = [
          ...,
          ...,
          ...,
         {
             input: 'src/index.ts',
             external: ['vue'],
             output: {
                 format: 'umd',
                 name: capitalize('ds-library'),
                 file: 'dist/ds-library.js',
                 exports: 'named',
                 banner: banner,
                 globals: {
                     vue: 'Vue'
                 }
             },
             plugins: [
                 node({
                     extensions: ['.vue', '.ts']
                 }),
                 typescript({
                     typescript: require('typescript')
                 }),
                 vue(vuePluginConfig),
                 babel(babelOptions),
                 cjs()
             ]
         }
    
       ],
    

Finally, we update our package.json with our script command. We need both rimraf (to remove our old dist folder before we create a new bundle) and clean-css (to minify our bundled css file), so lets install:

$ npm i -D rimraf clean-css-cli

now lets update our package.json script

build:vue = rollup and minify build:style = bundled our style from scss to css, add our banner text(version number like we did above) and save in dist/ds-library.css then create a minified version.

For the banner text in our css file, we need to create a print-banner.js file inside the build folder. It takes our banner text, and writes it to the file.


// build/print-banner.js
const packageInfo = require('../package.json')
const { text } = require('./banner.json')

process.stdout.write(text.replace('${version}', packageInfo.version))
process.stdin.pipe(process.stdout)

build:lib = delete dist folder, build vue and style code publish:lib = run our build lib command then publish to npm

    "scripts": {
        "build:vue": "rollup -c && rollup -c --environment MINIFY",
        "build:vue:watch": "rollup -c --watch",
        "build:style": "sass --no-charset ./src/styles/index.scss | node ./build/print-banner.js > dist/ds-library.css && cleancss -o dist/ds-library.min.css dist/ds-library.css",
        "build:lib": "rimraf dist && npm run build:vue && npm run build:style",
        "publish:lib": "npm run build:lib && npm publish"
    },
    "peerDependencies": {
        "vue": "^3.0.0"
    },

Test locally then publish our library to npm

Now that we are done, we can test locally by running npm link in the root directory of this repo and also running npm link "package name" in the root directory of our test project. After this, the package will be available for use in our test package.

Once tested, you can run npm publish:lib to build and deploy to npm.

If you have any questions or run into any trouble, feel free to reach out on Twitter or Github.