Back to blogs
Blog
Writing in public
Longer posts — published here first, then cross-posted to Dev.to with a canonical URL back to this site.
Blog
Longer posts — published here first, then cross-posted to Dev.to with a canonical URL back to this site.
Back to blogs
Back to blogs
Postnpm, tsup, tailwindcss, typescript
Lessons learned from building and publishing 3 NPM packages In this tutorial, we will...
Date published

slides used in the presentation building the generic component In this example we'll use...

A guide to creating Android home screen widgets using Expo modules, complete with state management,...

Discriminated unions (also known as tagged unions) are a powerful TypeScript pattern that enables...
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.1{2 "name": "my-package",3 "version": "1.0.0",4 "description": "My package description",5 "repository": {67 },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"1819 },20 "dependencies": {2122 },23 "devDependencies": {2425 },262728}
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.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
1export function foo(){2return "FOO";3}45export function bar(){6return "BAR";7}
src/math/foo.ts
1export function foo(){2return "FOO";3}
src/math/bar.ts
1export function bar(){2return "BAR";3}
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:
1import { foo, bar } from 'my-package';2import foo from 'my-package/foo';3import bar from '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()
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
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.
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 dependenciestsconfig.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.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
1import plugin from 'tailwindcss/plugin'2export default plugin(3 function () { },4 {5 content: [6 './node_modules/shadcn-fe-ui/dist/**/*.{js,ts,jsx,tsx}',7 ],8 theme: {9 container: {10 center: true,11 padding: "2rem",12 screens: {13 "2xl": "1400px",14 },15 },16 }17 }18 )19
and we run tsup to build with the tsup config
1import { defineConfig } from 'tsup';23export default defineConfig({4 dts: true, // Generate .d.ts files5 minify: true, // Minify output6 sourcemap: true, // Generate sourcemaps7 treeshake: true, // Remove unused code8 splitting: true, // Split output into chunks9 clean: true, // Clean output directory before building10 outDir:"dist", // Output directory11 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.thetsup configcan 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 codeindex.d.ts has the typescript types index.js.map has the source mapsWhich corresponds to what we've defined in our package.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
1 npm i shadcn-fe-tw2 yarn add shadcn-fe-tw3 pnpm add shadcn-fe-tw
1export default {2 content: [3 // vite4 "./index.html",5 "./src/**/*.{js,ts,jsx,tsx}"6 // next7 "app/**/*.{ts,tsx}",8 "components/**/*.{ts,tsx}",9 ],1011plugins: [12 require("tailwindcss-animate"),13 require("shadcn-fe-tw")14 ],15};16
shadcn/ui CLIThis 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
```json
"type": "module",
"exports": "./dist/index.js",
"bin": "./dist/index.js",
"files": [
"dist"
]
12The `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`3456### Project 3: [`shadcn/ui` components on NPM](https://github.com/tigawanna/shadcn-ui-fanedition/tree/master/packages/ui)78In this project I built the [`shadcn/ui` components](https://ui.shadcn.com/) into an installable NPM package91036 components in total makes using multiple entry points a good idea111213first we arrange the components in folders and then import them into the `src/index.ts`1415In my case I 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 folders1617The expected folder structure should be `src/components/[component name]`1819and inside the component director we'll have20- `index.ts` : to export everything from21- `[compnent name].tsx` : the actual component22- `[component name].stories.ts` : the component story.23- any other related files2425and with that folder structure we can import it into the `src/index.ts` as the main entry point26at this point we can declare it in the `package.json`27
json "main": "./dist/index.js", "types": "./dist/index.d.ts",
1But we can optimize it further , from my tests tinkering with this setup as it stands , everything gets imported when you2
ts import { Button } from "shadcn-fe-ui";
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.23which 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.45luckily `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 like6
ts import { ClientButton } from "shadcn-fe-ui/client-button";
12first we have to add our new entry points in `tsup.config.ts`3
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) });
1then we build the project and adjust the `package.json`2
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" } }
1And now when we install we can import it like
ts import { ClientButton } from "shadcn-fe-ui/client-button";
12repeating this process for 36 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`34as for the `tsup.config.ts` we can use a patter matching regex to determine the entry points5
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/components/**/index.ts'], // Entry point(s) format: ['esm'], // Output format(s) });
12I noticed a bug in this that will fail build when generating the `d.ts` and throw an error about worker timeout .3This bug doesn't appear if you only limit the build to 5 files45but the do provide a build function that lets us programmatically build.6So I used it to batch the builds 3 at a time (you can go as high as 7).7
js 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()
12and added the a new script to our `package.json`3
json "scripts": { "build": "node scripts/tsup-build-stages.js", }
```
and now we should get our components as an installable NPM package
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.d.ts outputpackage.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:package.json will:import:esm , require:cjs , ...)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