Custom Evergreen Component Library With Next and Rollup
2022-12-31
Est. 7m read
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 runnpm 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.
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.
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
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 aThemeProvider
.
// ./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.