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.