Monorepo setup with TypeScript, Tailwind, NextJs, and WXT (browser extension development) with shared components
Monorepo setup with TypeScript, Tailwind, NextJs, and WXT (browser extension development) with shared components
The most-requested feature for Lighthouse is a browser extension to add articles to the library. Lighthouse has always been a monorepo to share code between the NextJs application and a couple of Lambda functions. Since there was only one application that used UI components, they were always part of the NextJs codebase. To avoid code duplication with the browser extension, the UI components had to move to a separate package.
The goal: shared styles and components, and good developer experience
The fastest and easiest way would be to copy the Tailwind config and the components the extension requires and call it a day. But as developers we know, if it serves the same purpose, duplicating code is a sin.
The monorepo has 2 workspace directories, apps and packages. Apps are deployed entities, and packages are shared code.
Until now there was a web
app, and with adding the browser extension there will now be an additional web-extension
app and ui-base
package.
├── apps/
│ ├── web/
│ └── web-extension/
└── packages/
└── ui-base/
Everything that can be shared between client applications should live in the ui-base
package. This includes the Tailwind config, UI components, and other shared code like API client.
The developer experience should be as you’d expect. Autocomplete suggesting imports, hot module reloading during development, and type checking within the IDE.
Achieving these goals was not as straightforward as I thought it would be.
Different build pipelines across frameworks
The main aspect that makes monorepos complicated to set up is that frameworks use different build tools.
NextJs uses swc, WXT uses Vite, and for the Lambda functions I use TypeScript (tsc).
NextJs doesn’t support the references
field of the tsconfig
, and WXT doesn’t support path aliases.
When working on only one application it doesn’t matter. However, when working in a monorepo with multiple apps using different frameworks you have to constantly be aware of these limitations, and how they interact with TypeScript and the TypeScript language server (which is used for autocomplete suggestions).
Aside: How Tailwind is included in the build pipeline
While expanding the setup with the WXT project, I was amazed and confused at the same time how Tailwind is included.
Adding the config files tailwind.config.js
and postcss.config.js
, and the Tailwind directives to a CSS file and importing it is enough.
@tailwind base;
@tailwind components;
@tailwind utilities;
Turns out both NextJs and Vite handle PostCSS natively, and since Tailwind is a PostCSS plugin nothing else is required.
Examining monorepo templates
After my first try of moving all relevant code to the shared package failed with incomprehensible compile errors, I started checking out other NextJs monorepo setups to see what I can learn from them.
The first stop was the Turborepo example with NextJs and Tailwind. The shared ui
package exports every component separately. Adding every component to the list is too much overhead, so it was a non-starter.
packages/ui/package.json
...
"exports": {
"./styles.css": "./dist/index.css",
"./card": "./src/card.tsx"
},
...
Another example uses path aliases.
apps/nextjs-app/tsconfig.json
...
"paths": {
"@your-org/ui-lib/*": ["../../../packages/ui-lib/src/*"],
},
...
This was more promising, and very close to the final setup.
Result
One benefit is that the ui-base
package isn’t published, which makes it possible to treat it as just a directory for code separation and reuse purposes, with config files (tsconfig.json
and tailwind.config.js
) for the editor, that the VS Code plugins pick up.
Leaving the compiling and bundling to the respective frameworks, which both support TypeScript, Tailwind, and React, ensures that there is no need to cater to the intricacies of them. The only requirement is adapting the respective config files so the shared code is picked up.
Path aliases are the perfect solution. Even though the files are in a different directory and imported via @packages/ui-base/*
, they are treated like files in the project.
NextJs can handle TypeScript’s path aliases natively, so it’s enough to add it in the tsconfig
file.
apps/web/tsconfig.json
...
"paths": {
"@packages/ui-base/*": ["../../packages/ui-base/src/*"]
}
...
WXT handles path aliases differently, they must be added in the config file wxt.config.ts
and doesn’t necessarily require it in the tsconfig
.
apps/web-extension/wxt.config.ts
...
alias: {
"@packages/ui-base": resolve("../../packages/ui-base/src"),
},
...
Developer experience
With the above setup, it’s possible to import components, e.g. a button, from @packages/ui-base
.
import { Button } from "@packages/ui-base/components/library/button";
It works for NextJs and WXT during development and for production builds, but while writing code autocomplete doesn’t suggest the components and imports. To stay with the button example, writing <Bu
doesn’t suggest importing the the Button
component, but I’d very much like it to do so.
That’s because even though the files are referenced via the alias
field in the tsconfig
, TypeScript doesn’t automatically pick up those files. They must be added to the include
paths of the tsconfig.json
.
...
"include": [
...
"../../packages/ui-base/src/**/*.ts",
"../../packages/ui-base/src/**/*.tsx"
],
...
With that it works as expected for the NextJs app.
Since the WXT docs recommend against it, I initially didn’t include the path alias in the web-extension
tsconfig
file.
Because of that omission TypeScript didn’t auto-suggest importing components from ui-base
. This surprised me. I would have expected that it still suggests the components, but with ../../packages/ui-base/src/…
as path.
Despite the WXT docs recommending against it, adding path aliases to the tsconfig
doesn’t seem to cause any issues. After adding it, TypeScript correctly suggests component imports.
apps/web-extension/tsconfig.json
...
"paths": {
"@packages/ui-base/*": ["../../packages/ui-base/src/*"]
}
...
Final configuration
The end result is that the tsconfig
files of the web
and web-extension
apps have path aliases and include paths, and the WXT config file has an alias.
tsconfig.json
{
...
"compilerOptions": {
...
"paths": {
"@packages/ui-base/*": ["../../packages/ui-base/src/*"]
}
},
"include": [
...
"../../packages/ui-base/src/**/*.ts",
"../../packages/ui-base/src/**/*.tsx"
]
}
apps/web-extension/wxt.config.js
export default defineConfig({
alias: {
"@packages/ui-base": resolve("../../packages/ui-base/src"),
},
...
});
The ui-base
package doesn’t need any special config, since its files are processed by the build pipelines of the apps.
I’m happy that the final setup is simple. However, understanding it’s configured this way helps dealing with future changes of NextJs, WXT, or TypeScript.
Final words
Now that I have a working setup, it seems easy. But getting there was more difficult than I initially expected. With so many different options (e.g. project references, building the package) I ran into more than one dead end.
It would be amazing if in the future we as the web dev community can unify around one build and bundling system. It’d make setup and configuration so much simpler.
On the other hand, the web dev community is so great because people and companies frequently experiment with new approaches.
Both are different sides of the same coin. Nonetheless, one can dream.