Lessons learned from building and publishing 3 NPM packages
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
-
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 tod.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.jso
n` 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
tsconfig.json
: This file is used to configure the TypeScript compiler. It contains information about how TypeScript should compile your code.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 generated.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 thepackage.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
-
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
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 seamlessd.ts
generation with other tool asking you to usetsc
directly for thed.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 ad.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 astsup
- bun : great build too easy config , but no
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 types location
References
shadcn/ui
: the great project that inspired mine
simple guide : basic guide on create and publish an NPM library
npm
+ release-it : simple tutorial on how to publish and automate the publishing of an npm
packages
Top comments (0)