Search code examples
reactjsnpmopen-source

A fool-proof tsup config for a React component library


I've never published an NPM package before. All these details to generate a package seem way too complicated to my level. The only tool, that was beginner friendly, that I could find is create-react-library which recommended to switch to tsup instead.

I'm asking here to know if there's a batteries-included, most-cases-met, setup for tsup or any other tool of your recommendation for this kind of project (and I think this is a common scenario):

  • A React Project
  • Typed with Typescript
  • Tested with Jest
  • No dependencies
  • Exports React components
  • Should be public on NPM

Solution

  • Here is an example setup.

    • First you need to bundle each component separately. You can use a glob as an entry point in Tsup. Keep in mind that options that works for Esbuild works for Tsup most of the time.
    // tsup.config.ts
    defineConfig([
      {
      clean: true,
      sourcemap: true,
      tsconfig: path.resolve(__dirname, "./tsconfig.build.json"),
      entry: ["./components/core/!(index).ts?(x)"],
      format: ["esm"],
      outDir: "dist/",
      esbuildOptions(options, context) {
        // the directory structure will be the same as the source
        options.outbase = "./";
        },
      },
    
    • Then you'll want to have a index.ts, for convenience, that expose named exports. This index is sometimes referred as a "barrel" file.
    // index.ts
    // the actual file is "Button.tsx" but we still want a ".js" here
    export { Button } from "./components/core/Button.js";
    

    Notice the .js extension. ESM expects explicit extensions so it's needed in the final build.

    Adding the .js doesn't seem to bother TypeScript, which stills correctly recognize the type of "Button" from Button.tsx. At this point I am not sure why it works, but it does.

    Transpile this index, without bundling.

    // tsup.config.ts
      {
      clean: true,
      sourcemap: true,
      tsconfig: path.resolve(__dirname, "./tsconfig.build.json"),
      entry: ["index.ts", "./components/core/index.ts"],
      bundle: false,
      format: ["esm"],
      outDir: "dist",
      esbuildOptions(options, context) {
        options.outbase = "./";
        },
      },
    ])
    
    • Finally define your package.json as usual:
      "sideEffects": false,
      "type": "module",
      "exports": {
        ".": "./dist/index.js"
      },
    

    sideEffects is a non-standard property targeting application bundlers like Webpack and Rollup. Setting it to false tells them that the package is safe for tree-shaking.

    Now import { Button } from "my-package" should work as you expect, and tree-shaking and dynamic loading at app-level become possible because "Button" is bundled as its own ES module Button.js, and the package is marked as being side-effect free.

    This is confirmed by my Webpack Bundle Analyzer in a Next app:

    Before (a single bundled index.js):

    enter image description here

    After (separate files means I can select more precisely my imports):

    enter image description here

    Final config available here (might be improved in the future)