Published on

Custom Evergreen Component Library With Next and Rollup

Authors
Bundle

Recently I've been using Next.js zones for everything. They're a great way to separate your app into different repos. Unfortunately, there's no easy way to share components between zones. To prevent code duplication, there are a couple options:

  • Use a monorepo
  • Use a component library

None of my projects are big enough to warrant a monorepo, so I decided to create a custom component library. I wanted to use Next.js, Evergreen UI, and styled-components. It took a lot of time to figure out how to bundle these nicely, so I'm writing this post to help anyone else who wants to do the same.

The end-goal is to have a customized version of Evergreen UI that can be imported into any Next.js app.

Overview

Setup Project

The first step is to initialize your component library. Here is the file structure:

├── README.md
├── index.js
├── package.json
├── rollup.config.js
├── src/
└── tsconfig.json

The index.js file is the entry point for your library. It should export all of your components from the dist folder.

The package.json file is pretty standard. The only thing to note is that dependencies are listed as peerDependencies. This is because we don't want to bundle them with our library. We want the user to install them separately.

The rollup.config.js file is where the magic happens. We're using various plugins and presets to bundle our library. Here's the full config that worked for me:

// ./rollup.config.js
import peerDepsExternal from 'rollup-plugin-peer-deps-external'
import commonjs from '@rollup/plugin-commonjs'
import { babel } from '@rollup/plugin-babel'
import fs from 'fs'
import json from '@rollup/plugin-json'
import nodeResolve from '@rollup/plugin-node-resolve'
import stripPropTypes from 'rollup-plugin-strip-prop-types'
import terser from '@rollup/plugin-terser'

/**
 * @type {import('rollup').RollupOptions}
 */
const config = fs.readdirSync('src').map((component) => ({
  input: `src/${component}/index.js`,
  plugins: [
    // automatically externalize peer dependencies
    peerDepsExternal(),
    // allow node_modules resolution
    nodeResolve(),
    // allow bundling cjs modules
    commonjs({ include: /node_modules/ }),
    // transpile
    babel({
      exclude: 'node_modules/**',
      extensions: ['.js', '.jsx', '.ts', '.tsx'],
      babelHelpers: 'runtime',
      plugins: [
        '@babel/plugin-external-helpers',
        '@babel/plugin-transform-runtime',
        'babel-plugin-styled-components',
      ],
      presets: ['@babel/preset-env', ['@babel/preset-react', { runtime: 'automatic' }]],
    }),
    json(),
    // remove prop-types from bundle
    stripPropTypes({
      sourceMap: false,
    }),
    // minify
    terser(),
  ],
  // output bundle
  output: [
    {
      file: `cjs/${component}.js`,
      format: 'cjs',
    },
    {
      file: `dist/${component}.js`,
      format: 'esm',
    },
  ],
}))

export default config

The src folder contains all of our custom component code. We'll come back to that.

package.json

Here's the package.json I'm using:

{
  "name": "@my-org/my-custom-ui",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "types": "dist/dts/index.d.ts",
  "main": "dist/index.js",
  "scripts": {
    "build": "rollup --bundleConfigAsCjs -c; tsc --declaration"
  },
  "peerDependencies": {
    "evergreen-ui": "^6.12.0",
    "next": "12.2.5",
    "prop-types": "^15.8.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "styled-components": "^5.3.6"
  },
  "devDependencies": {
    "@babel/plugin-external-helpers": "^7.18.6",
    "@babel/plugin-transform-runtime": "^7.19.6",
    "@babel/preset-env": "^7.20.2",
    "@babel/preset-react": "^7.18.6",
    "@rollup/plugin-babel": "^6.0.2",
    "@rollup/plugin-commonjs": "^23.0.2",
    "@rollup/plugin-json": "^5.0.2",
    "@rollup/plugin-node-resolve": "^15.0.1",
    "@rollup/plugin-terser": "^0.2.0",
    "@types/babel__plugin-transform-runtime": "^7.9.2",
    "@types/babel__preset-env": "^7.9.2",
    "@types/node-sass": "^4.11.3",
    "@types/prop-types": "^15.7.5",
    "@types/react": "^18.0.26",
    "@types/react-dom": "^18.0.9",
    "@types/rollup-plugin-peer-deps-external": "^2.2.1",
    "@types/styled-components": "5.1.26",
    "babel-plugin-styled-components": "^2.0.7",
    "node-sass": "^7.0.3",
    "prop-types": "^15.8.1",
    "rollup": "^3.7.4",
    "rollup-plugin-peer-deps-external": "^2.2.4",
    "rollup-plugin-postcss": "^4.0.2",
    "rollup-plugin-strip-prop-types": "^1.0.3",
    "styled-components": ">= 5",
    "typescript": "^4.9.4"
  },
  "dependencies": {
    "@babel/runtime": "^7.20.6"
  }
}

Don't forget to run npm install to install all of these dependencies.

Writing Components

The next step is to write your components.

./src/SideNav/
├── SideNav.js
└── index.js

The SideNav.js file should contain your custom component. The index.js file should export the component and any other files that are needed like so:

// ./src/SideNav/index.js
export { default as SideNav } from './SideNav'
  • Note: Your exports can be default or named. export * will not be included in the bundle.
  • Important: The rollup config will only look for ./src/**/index.js files to bundle.

Within SideNav.js, you should have access to the Evergreen UI, Next.js, and styled-components libraries.

Entry Point

The index.js file at the project root (./) is the entry point for your library. This is where you should export all of the bundled components in the dist folder for consumption:

  • Note: The dist folder will be created when we run npm run build
// ./index.js
export * from './dist/SideNav'
export * from './dist/MyOtherComponent'

Adding Types

There is room for improvement in the way I have set up types. I've opted to use the tsc command to generate types for my components. By specifying tsc --declaration in the npm run build script, the tsc command will generate a dist/dts/index.d.ts file that contains all of the types for your components after rollup.

This is useful for adding auto-completion in your IDE.

Auto-completion in VSCode

You'll need the following in your tsconfig.json:

{
  // Change this to match your project
  "include": ["src/**/*"],
  "compilerOptions": {
    // Tells TypeScript to read JS files, as
    // normally they are ignored as source files
    "allowJs": true,
    // Generate d.ts files
    "declaration": true,
    // This compiler run should
    // only output d.ts files
    "emitDeclarationOnly": true,
    // Types should go into this directory.
    // Removing this would place the .d.ts files
    // next to the .js files
    "outDir": "dist/dts",
    // go to js file when using IDE functions like
    // "Go to Definition" in VSCode
    "declarationMap": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

Build & Link

Now that you have your components written, you can build them and link them to your Next.js project. This should be done locally to verify things are working before publishing your package.

Build

To build your package, run npm run build. This will run the rollup step and the tsc step.

  • Note: You can safely ignore the red at the end of the build output.

Build Output

Linking

To test your package locally, run npm link in the root of your library folder. This will setup a symlink in the global node_modules for your package.

Next, navigate to your Next.js project and run npm link @my-org/my-custom-ui.

  • Note: The symlink will stay up-to-date as you make changes to your library bundle. You'll need to relink your package if you restart your computer.

You should now be able to import your components from your Next.js project. Sometimes intellisense can be slow to load, be patient.

GitHub Demo

I've created a minimal example of this setup on GitHub. You can view the full repo here.

Preview

Repo Preview

  1. Purple Box: Are components rendered inside the library.

  2. Red Box: Are components rendered inside the Next.js app.

Publish to npmjs.com

I have yet to publish a package to npm. I will update this post when I do and have more info on how it relates to this setup.

ThemeProvider

To access the latest theme from within your library components, you'll need to wrap your components in a ThemeProvider using the theme provided in your library. I've called it UITheme in the GitHub demo. There may be a better way of handling this, but this works for now.

  • Required: If you want to access useTheme() from within your library components, you'll need to wrap them in a ThemeProvider.
// ./src/ThemeProvider/ThemeProvider.js
import { UITheme } from '../Theme'
import {
  defaultTheme,
  mergeTheme,
  ThemeProvider as EvergreenTP,
} from 'evergreen-ui'

const myTheme = mergeTheme(defaultTheme, UITheme)

export default function ThemeProvider({ children }) {
  return <EvergreenTP value={myTheme}>{children}</EvergreenTP>
}

// ./src/SideNav/SideNav.js
import { useTheme } from 'evergreen-ui'
import ThemeProvider from '../ThemeProvider'

export default function SideNav() {
  const theme = useTheme()

  return (
    <ThemeProvider>
      <div>My SideNav</div>
    </ThemeProvider>
  )
}

Open Questions

  • npm run build will need to run each time you make a change locally.
    • There's likely an easy way to automate this, but I haven't looked into it yet.
  • If you run the demo, you may notice a FOUC (flash of unstyled content) when refreshing the page.
    • This has been an ongoing issue for me and I'm not sure how to fix it. If you find a solution, let me know and I'll buy you a coffee. I know it's related to evergreen-ui + Next.js, it has nothing to do with our Rollup bundle.
  • Types for React component props are all any. Not sure how to fix.