A monorepo setup with lerna

April 06, 2019

tl;dr: This is a walk-through for setting up a monorepo for react with a typescript module powered by lerna. If you don't want to follow step-by-step you can also clone following repository and explore by yourself:


git clone https://github.com/jannikbuschke/lerna-react-typescript-sample
cd lerna-react-typescript-sample
npm install
npm run bootstrap
npm run watch
npm run start (in a second terminal)

Now edit /packages/my-module/src/HelloWorld.tsx and see the update going live on http://localhost:3000


Now let's start with the walk-through.

Install and initialize lerna


mkdir lerna-test
cd lerna-test
npm install lerna -g
lerna init

This creates a git repository, an empty packages folder for our apps and modules (a lerna convention which does not need to be followed), the lerna.json that keeps track of our apps and modules and a classical package.json to specify some root dependencies.


.git/
packages/
lerna.json
package.json

Create an app

Lets create our react app with the popular create-react-app tooling:

npx create-react-app packages/my-app

Create a typescript package

Create a folder

mkdir packages/my-module

And a file package.json inside /packages/my-module/ with the following content:


{
"name": "my-module",
"version": "0.1.0",
"private": false,
"files": ["lib"],
"main": "./lib/index.js",
"types": "./lib/types.d.ts",
"scripts": {
"tsc": "tsc"
},
"dependencies": {},
"peerDependencies": {
"react": ">= 16.8.0 < 17",
"react-dom": ">= 16.8.0 < 17"
},
"devDependencies": {
"@types/jest": "24.0.11",
"@types/node": "11.13.0",
"@types/react-dom": "16.8.3",
"tslib": "1.9.3",
"@types/react": "16.8.10",
"typescript": "3.4.1",
"react": "16.8.6",
"react-dom": "16.8.6"
}
}

Create a typescript configuration file /packages/my-module/tsconfig.json with the following content:


{
"compilerOptions": {
"baseUrl": ".",
"outDir": "./lib",
"module": "esnext",
"target": "es5",
"lib": ["es6", "dom"],
"sourceMap": true,
"jsx": "react",
"skipLibCheck": true,
"moduleResolution": "node",
"rootDir": "src",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"importHelpers": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": false,
"declaration": true,
"declarationMap": true,
"allowSyntheticDefaultImports": true
},
"exclude": [
"lib",
"node_modules",
"build",
"scripts",
"acceptance-tests",
"webpack",
"jest",
"src/setupTests.ts"
]
}

Create a sample component /packages/my-module/src/HelloWorld.tsx to be used within the app:


import * as React from "react"
export const HelloWorld = () => <div>HelloWorld</div>

In order to use the module components in the app, we need to export them. Create a file /packages/my-module/src/index.ts with following content:


export * from "./HelloWorld"

We also want to provide types. Create a file /packages/my-module/src/types.ts with following content:


export * from "./index"

Reference the local module from the app

In the /packages/my-app/package.json, in the "dependencies" section add a reference to our module, as if it had been already published:


"dependencies": {
"my-module": "0.1.0",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-scripts": "2.1.8"
},

In the root folder execute

lerna bootstrap --hoist

Lerna does now resolve and install dependencies and create symlinks between our modules. The --hoist flag will move some common dependencies (i.e. if several packages have a dependency on the same library) into the root node_modules) folder (and create symlinks to them) to prevent duplicates.

Then in the root folder execute

lerna run tsc

to trigger typescript compilation of our module. In order for typescript compilation to work, you might need to install it globally with npm install typescript -g.

Executing lerna run <script> instructs lerna to execute scripts for any package that have <script> defined (as an npm script in package.json).

Using my-module components in the app

Lets rename the file extension of /packages/my-app/src/App.js to .tsx and replace the content with the following:


import * as React from "react"
import { HelloWorld } from "my-module"
function App() {
return (
<div>
<HelloWorld />
</div>
)
}
export default App

now execute

lerna run start

This executes all scripts named start in all packages. In our case the my-app package has such a script defined. Open http://localhost:3000 to see the running app, showing the HelloWorld component from our module.

Updating the module

Ideally we want edits in the module to be picked up in the app immediately. Let's add an npm script to watch for file changes and trigger recompilation. Inside /packages/my-module/package.json in the scripts section add following script:

"watch": "tsc --watch"

In the root folder in a second terminal execute lerna run watch. Now any changes to the HelloWorld component will be picked up in our app!

Adding some helper scripts

In order to improve the setup a little further let`s add some scripts in the root package.json:


{
"name": "root",
"private": true,
"devDependencies": {
"lerna": "3.13.1"
},
"scripts": {
"bootstrap": "lerna bootstrap --hoist",
"start": "lerna run start --stream",
"tsc": "lerna run tsc --stream",
"watch": "lerna run watch --stream"
}
}

The --stream flag will pipe the output of the commands to the current terminal.

Now we can execute the commands via npm in the root folder, i.e:


npm run bootstrap
npm run tsc
npm run watch
npm run start

Sample on Github

The demonstrated case is available on github: https://github.com/jannikbuschke/lerna-react-typescript-sample

Pitfalls

Sometimes this setup breaks, and the watch command must be restarted (i.e. when adding/deleting files or exporting new types in the from the module). Also editors sometimes do not pick up newly export types.

In such cases restarting compilation and your IDE might help. Also from time to time cleaning node_module folders is helpful (especially when upgrading third-party dependencies).