Building and publishing NPM packages with typescript , multiple entry points, tailwind , tsup and npm

Building and publishing NPM packages with typescript , multiple entry points, tailwind , tsup and npm

Lessons learned from building and publishing 3 NPM packages

ยท

10 min read


In this tutorial, we will learn how to create and publish your own NPM packages using TypeScript. We will cover everything from setting up your project with TypeScript, using tsup for building/

Top publish an NPM package we need to understand 3 configurations we don't think much about in an average JavaScript project

  1. package.json: This file is used to configure your NPM package. It contains information about your package such as the name, version, description, and dependencies.
{
    "name": "my-package",
    "version": "1.0.0",
    "description": "My package description",
    "repository": {

    },
    "main": "index.js",
    "exports": {
        "*":{
            "types": "./index.d.ts",
            "require": "./index.js",
            "import": "./index.js"
        }
    },
    "scripts": {
        "build":"tsup"

    },
    "dependencies": {

    },
    "devDependencies": {

    },


}

The most important field for any NPM package are

  • name: The name of your package. This should be unique and not already taken on NPM.

  • version:` The version number of your package. This should follow semantic versioning.

  • description: A short description of your package.

  • repository: The location of your packageโ€™s source code.

  • main: The entry point for your package. This is the file that will be loaded when someone requires your package.

  • exports:This field is used to specify the files that should be included when someone imports your package. This field can be used to point to d.ts files and multiple entry points.

When you specify multiple entry points in the "exports" field, you can define a public interface for your package and encapsulate it, preventing any other entry points besides those defined in "exports". This can be useful when you want to expose only certain parts of your package to other modules.

For example, in your package.json` file, you could define multiple entry points like this:

src/index.ts

export function foo(){
return "FOO";
}

export function bar(){
return "BAR";
}

src/math/foo.ts

export function foo(){
return "FOO";
}

src/math/bar.ts

export function bar(){
return "BAR";
}
"exports": {
    ".": {
        "require": "./dist/index.js",
        "import": "./dist/index.js",
        "types": "./dist/index.d.ts"
    },
    "./foo": {
        "require": "./dist/foo.js",
        "import": "./dist/foo.js",
        "types": "./dist/foo.d.ts"
    },
    "./bar": {
        "require": "./dist/bar.js",
        "import": "./dist/bar.js",
        "types": "./dist/bar.d.ts"
    }
}

This would allow someone to import your package like this:

import { foo, bar } from 'my-package';
import foo from 'my-package/foo';
import bar from 'my-package/bar';

This can be useful when you want to expose different parts of your package as separate modules.

the exports field is very important as it tell the project that installs your package where to find specific resources

require: "./dist/index.js": This field specifies the path to the CommonJS module that should be used when someone requires your package using require()

const bar = require('my-package/bar');

import: "./dist/index.js": This field specifies the path to the ES module that should be used when someone imports your package using import

import bar from 'my-package/bar';

types: "./dist/index.d.ts": This field specifies the path to the TypeScript declaration file that should be used when someone wants to use your package with TypeScript.

  • scripts: This field is used to define scripts that can be run with NPM run.

  • dependencies: This field is used to specify the packages that your package depends on.

  • devDependencies: This field is used to specify the packages that are only needed during development.

  • peerDependencies: This field is used to specify the packages that your package depends on but expects the installer to already have ,

    you can make assumptions like this if the package will be library specific like a react/vue component library but you can leave it empty for something more generic like a JavaScript math helper . react,vue ... are common examples of peer dependencies

  1. tsconfig.json: This file is used to configure the TypeScript compiler. It contains information about how TypeScript should compile your code.

  2. tsconfig.node.json: This file is used to configure the TypeScript compiler for Node.js. It contains information about how TypeScript should compile your code for Node.js.

Project 1: Simple Tailwind Plugin for shadcn/ui

In this project, I just wanted a cleaner way to configure the shadcn/ui tailwind comfig

and the tailwind docs were super easy and straight forward all I had to do was export the plugin function with my desired initial config

src/index.ts

import plugin from 'tailwindcss/plugin'
export default plugin(
    function () { },
    {
        content: [
            './node_modules/shadcn-fe-ui/dist/**/*.{js,ts,jsx,tsx}',
        ],
        theme: {
            container: {
                center: true,
                padding: "2rem",
                screens: {
                    "2xl": "1400px",
                },
            },
        }
        }
        )

and we run tsup to build with the tsup config

import { defineConfig } from 'tsup';

export default defineConfig({
  dts: true, // Generate .d.ts files
  minify: true, // Minify output
  sourcemap: true, // Generate sourcemaps
  treeshake: true, // Remove unused code
  splitting: true, // Split output into chunks
  clean: true, // Clean output directory before building
  outDir:"dist", // Output directory
  entry: ['src/index.ts'], // Entry point(s)
  format: ['esm'], // Output format(s)
});
  • dts: This field specifies whether or not to generate d.ts files for your TypeScript code.

  • minify: This field specifies whether or not to minify the output of your build.

  • sourcemap: This field specifies whether or not to generate source maps for your build.

  • treeshake: This field specifies whether or not to remove unused code from your build.

  • splitting: This field specifies whether or not to split your output into chunks. clean: This field specifies whether or not to clean the output directory before building.

  • outDir: This field specifies the output directory for your build.

  • entry: This field specifies the entry point(s) for your build.

  • format: This field specifies the output format(s) for your build.

the tsup config can also be defined in the package.json

Since it's a simple package only exports one file , having a single entry point is just fine . after build you'll have a dist folder

tailwind plugin files`

  • index.js has the actual code

  • index.d.ts has the typescript types

  • index.js.map has the source maps

Which corresponds to what we've defined in our package.json

  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",

After publishing to NPM we can then install it and use it like

 npm i shadcn-fe-tw
 yarn add shadcn-fe-tw
 pnpm add shadcn-fe-tw
export default {
  content: [
    // vite
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}"
        // next
    "app/**/*.{ts,tsx}",
    "components/**/*.{ts,tsx}",
  ],

plugins: [
    require("tailwindcss-animate"),
    require("shadcn-fe-tw")
  ],
};

Project 2: Modified shadcn/ui CLI

This one is also similar to the first but has more files which all get imported into the entry file

CLI project structure

and then we declare our main and dts in the package.json

"type": "module",
  "exports": "./dist/index.js",
  "bin": "./dist/index.js",
    "files": [
      "dist"
    ]

The bin field in the package.json file is used to specify the location of executable files that should be installed in the PATH. It is a map of command name to local file name. When you install a package with a bin specified, NPM will symlink that file into prefix/bin for global installs, or ./node_modules/.bin/ for local installs12

Project 3: shadcn/ui components on NPM

In this project I built the shadcn/ui components into an installable NPM package

36 components in total makes using multiple entry points a good idea

first we arrange the components in folders and then import them into the src/index.ts

In my case I used the cli tool to download all the components into src/shadcn , then I used a script to organize them into folders

The expected folder structure should be src/components/[component name]

and inside the component director we'll have

  • index.ts : to export everything from

  • [compnent name].tsx : the actual component

  • [component name].stories.ts : the component story.

  • any other related files

and with that folder structure we can import it into the src/index.ts as the main entry point at this point we can declare it in the package.json

  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",

But we can optimize it further , from my tests tinkering with this setup as it stands , everything gets imported when you

import { Button } from "shadcn-fe-ui";

Some of the components on this list have hooks and other event handlers which are not allowed in next!3 appDir without adding a "use client" directive.

which means using it like this will opt us out of any gains that RSCs give us by forcing ever component to be a client component.

luckily tsup can take in multiple entry point and generate multiple files in our dist directory , helping us isolate an components with hooks so that the can be installed like

import { ClientButton } from "shadcn-fe-ui/client-button";

first we have to add our new entry points in tsup.config.ts

import { defineConfig } from 'tsup';

export default defineConfig({
  dts: true, // Generate .d.ts files
  minify: true, // Minify output
  sourcemap: true, // Generate sourcemaps
  treeshake: true, // Remove unused code
  splitting: true, // Split output into chunks
  clean: true, // Clean output directory before building
  outDir:"dist", // Output directory
  entry: ['src/index.ts', 'src/client-buttonts'], // Entry point(s)
  format: ['esm'], // Output format(s)
});

then we build the project and adjust the package.json

  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
      },
    "./client-button": {
        "import":"./dist/client-button.js",
        "types":"./dist/client-button.d.ts"
        }
  }

And now when we install we can import it like

import { ClientButton } from "shadcn-fe-ui/client-button";

repeating this process for 36 components can be tedious so I wrote a script to add the appropriate fields to the package.json

as for the tsup.config.ts we can use a patter matching regex to determine the entry points

import { defineConfig } from 'tsup';

export default defineConfig({
  dts: true, // Generate .d.ts files
  minify: true, // Minify output
  sourcemap: true, // Generate sourcemaps
  treeshake: true, // Remove unused code
  splitting: true, // Split output into chunks
  clean: true, // Clean output directory before building
  outDir:"dist", // Output directory
  entry: ['src/index.ts','src/components/**/index.ts'], // Entry point(s)
  format: ['esm'], // Output format(s)
});

I noticed a bug in this that will fail build when generating the d.ts and throw an error about worker timeout . This bug doesn't appear if you only limit the build to 5 files

but the do provide a build function that lets us programmatically build. So I used it to batch the builds 3 at a time (you can go as high as 7).

import glob from "glob"
import { build } from 'tsup'
import _ from 'lodash';

async function buildStage({ clean, entry }) {
    console.log("๐Ÿš€ ~ building entry ", entry)

    try {
        await build({
            dts: true,
            minify: true,
            sourcemap: true,
            treeshake: true,
            // splitting: true,
            outDir: 'dist',
            clean,
            entry,
            external: ['react', 'react-dom'],
            format: ['esm'],
        });
    } catch (error) {
        console.log("๐Ÿš€ ~ error while building entries :", entry);
        console.log(error);
        throw error;
    }
}

export async function buildAllStages() {

    const root_file = glob.sync('src/index.ts');
    const files = glob.sync('src/components/**/index.ts');
    const chunkSize = 3;
    const chunks = _.chunk(files, chunkSize);

        for await (const [index, chunk] of chunks.entries()) {
      console.log('๐Ÿš€ ~ chnk === ', chunk);
        await buildStage({ clean:index===0, entry: chunk });
    }
    await buildStage({ clean:false, entry: root_file });
    //    await buildStage({ clean:true, entry: root_file });


}



export function invokeBuild(){

buildAllStages().then(()=>{
    console.log("๐Ÿš€ ~ buildAllStages success");
}).catch((error)=>{
    console.log("๐Ÿš€ ~ buildAllStages error === ", error);
})
}
invokeBuild()

and added the a new script to our package.json

  "scripts": {
          "build": "node scripts/tsup-build-stages.js",
  }

and now we should get our components as an installable NPM package

Final thoughts

  • tsup is a great build tool and enables one to get really far with minimal config , they also are the only build tool with seamless d.tsgeneration with other tool asking you to use tsc directly for the d.ts outputs. notable mentions include

  • bun : great build too easy config , but no d.ts output

  • parcel : I couldn't figure out how to do multi entry points and their config has to be either in package.json or a .parcel file which don't offer code suggestion making it hard to explore the options.

  • vite : vite builds great, has a d.ts plugin but it's outputs we al little off ,they also don't have an easy was to do multiple entry points and you have to manually specify the files no regex patter matching and even using a function to generate the paths it still wasn't as easy as tsup

The one thing that took me a while to wrap my head around was the relationship between the build tool config and the package.json .

  • build tool config will:

    • specify the entry points

    • specify the output directory

    • specify the output format

    • specify the source map

    • specify minify or not

    • specify splitting or not

  • package.json will:

    • specify the project name

    • specify the project version

    • specify the output/exports location

    • specify where to find the different types (import:esm, require:cjs, ...)

    • specify the location of the type

References

shading/ui: the great project that inspired mine

a simple guide: a basic guide on creating and publishing an NPM library

npm + release-it: simple tutorial on how to publish and automate the publishing of an npm packages

ย