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.
json
1{
2"name":"my-package",
3"version":"1.0.0",
4"description":"My package description",
5"repository":{
6
7},
8"main":"index.js",
9"exports":{
10"*":{
11"types":"./index.d.ts",
12"require":"./index.js",
13"import":"./index.js"
14}
15},
16"scripts":{
17"build":"tsup"
18
19},
20"dependencies":{
21
22},
23"devDependencies":{
24
25},
26
27
28}
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
typescript
1exportfunctionfoo(){
2return"FOO";
3}
4
5exportfunctionbar(){
6return"BAR";
7}
src/math/foo.ts
typescript
1exportfunctionfoo(){
2return"FOO";
3}
src/math/bar.ts
typescript
1exportfunctionbar(){
2return"BAR";
3}
json
1"exports":{
2".":{
3"require":"./dist/index.js",
4"import":"./dist/index.js",
5"types":"./dist/index.d.ts"
6},
7"./foo":{
8"require":"./dist/foo.js",
9"import":"./dist/foo.js",
10"types":"./dist/foo.d.ts"
11},
12"./bar":{
13"require":"./dist/bar.js",
14"import":"./dist/bar.js",
15"types":"./dist/bar.d.ts"
16}
17}
This would allow someone to import your package like this:
javascript
1import{ foo, bar }from'my-package';
2importfoofrom'my-package/foo';
3importbarfrom'my-package/bar';
4
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()
typescript
1const 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
typescript
1import 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.
9 clean:true,// Clean output directory before building
10 outDir:"dist",// Output directory
11 entry:['src/index.ts'],// Entry point(s)
12 format:['esm'],// Output format(s)
13});
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
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
json
1"main":"./dist/index.js",
2"types":"./dist/index.d.ts",
After publishing to NPM we can then install it and use it like
2The `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 packagewith a bin specified,NPM will `symlink` that file into prefix/bin for global installs, or `./node_modules/.bin/ for local installs12`
3
4
5
6### Project 3:[`shadcn/ui` components on NPM](https://github.com/tigawanna/shadcn-ui-fanedition/tree/master/packages/ui)
7
8In this project I built the [`shadcn/ui` components](https://ui.shadcn.com/) into an installable NPMpackage
9
1036 components in total makes using multiple entry points a good idea
11
12
13first we arrange the components in folders and then import them into the `src/index.ts`
14
15In my caseI used the [cli tool](https://github.com/tigawanna/shadcn-ui-fanedition/tree/master/packages/ui) to download all the components into `src/shadcn`, then I used a [script](https://github.com/tigawanna/shadcn-ui-fanedition/blob/17f0b8bf6500786669ad015a95ea8c371b8035aa/packages/ui/scripts/bulk-port.js) to organize them into folders
16
17The expected folder structure should be `src/components/[component name]`
18
19and inside the component director we'll have
20-`index.ts`: to export everything from
21-`[compnent name].tsx`: the actual component
22-`[component name].stories.ts`: the component story.
23-any other related files
24
25and with that folder structure we can import it into the `src/index.ts`as the main entry point
26at this point we can declare it in the `package.json`
1But we can optimize it further , from my tests tinkering withthis setup as it stands , everything gets imported when you
2
ts import { Button } from "shadcn-fe-ui";
typescript
1Some 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.
2
3which means using it like this will opt us out ofany gains that RSCs give us by forcing ever component to be a client component.
4
5luckily `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
6
ts import { ClientButton } from "shadcn-fe-ui/client-button";
typescript
1
2first we have to add our newentry points in`tsup.config.ts`
ts import { ClientButton } from "shadcn-fe-ui/client-button";
typescript
1
2repeating this process for36 components can be tedious so I wrote a [script](https://github.com/tigawanna/shadcn-ui-fanedition/blob/17f0b8bf6500786669ad015a95ea8c371b8035aa/packages/ui/scripts/entry-points..js) to add the appropriate fields to the `package.json`
3
4asfor the `tsup.config.ts` we can use a patter matching regex to determine the entry points
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 , ...)