Notes on converting a TypeScript project to a monorepo

I was managing frontend and server code in a single repository using directory separation, but it became difficult to work with. So I refactored it into a monorepo structure.
Here are the things I considered during that process.

Note: The refactored repository is private, so it is not public.

Development environment

I use Next.js as the frontend framework, and I implemented a BFF (Backend for Frontend) using Express as a custom Next.js server for proxying external API requests.

  • BFF: TypeScript, Node.js, Express (Next.js custom server)
  • Frontend: TypeScript, Next.js

Initial structure

The structure before refactoring looked like this. Frontend and server code were managed by directory separation.

.
├── .storybook
├── pages
├── public
├── src
│   ├── client
│   ├── common
│   ├── config
│   ├── server
│   └── types
├── stories
├── test
│   ├── src
│   │   ├── client
│   │   ├── common
│   │   └── server
│   └── tsconfig.json
├── next.config.js
├── nodemon.json
├── package.json
├── server.ts
├── tsconfig.json
├── tsconfig.common.json
└── tsconfig.server.json

Problems with the initial structure

In the initial directory structure, many files and directories existed at the root level. For a first-time reader, it was very hard to understand which files and directories related to the frontend and which to the server. Also, while src and test were split into server and client directories, the TypeScript settings were different for the frontend and server, making the tsconfig configuration complex.
Note: I simplified for explanation, but in reality there were also Docker directories and more.

Converting to monorepo

Choosing a tool

For this conversion, I only used yarn workspace.

lerna is a well-known library for monorepos, but I chose not to use it. For my case, yarn workspace alone was enough to achieve my goals. Also, lerna seemed to be designed for OSS projects (projects meant to be published publicly), and for a private repository it felt redundant. I also wanted to avoid adding extra dependencies and management cost.

I also considered Nx as a tool for managing frontend and server in a monorepo. But after a quick look, it felt very full-stack and seemed to require following Nx's directory structure, which would make migrating an existing project very costly. So I passed on it too.

For reference, there are articles like Yarn Workspaces: monorepo management without Lerna for applications and coding examples.

TypeScript project references

For the monorepo conversion, I introduced TypeScript's Project References. This feature was added in TypeScript 3.0. It lets you manage TypeScript projects independently. Since I was splitting into packages with the monorepo, I also wanted to split TypeScript builds by package.

In a monorepo environment, when @app/web depends on @app/server, if you build @app/web first, @app/server may not be built yet, causing module resolution failures. With Project References, running tsc --build will automatically build @app/server if needed when building @app/web, solving the dependency problem.

Final structure

The directory structure after converting to a monorepo looks like this. Frontend and server are now split into separate packages, and related config files and directories are organized within each package. The structure is much easier to understand.

├── workspaces
|   ├── common                Package shared across multiple packages
|   │   ├── src               Source code
|   |   ├── package.json
|   |   └── tsconfig.json     
|   ├── config                Package holding config files
|   │   ├── index.ts          Barrel file
|   │   ├── firebase.ts       
|   |   ├── package.json
|   |   └── tsconfig.json     TypeScript config file
|   ├── server                Server-side package
|   │   ├── src               Source code
|   │   ├── test              Test code
|   │   ├── types             Type definition files
|   |   ├── package.json
|   |   └── tsconfig.json     
|   ├── web
|   |   ├── .storybook        Storybook config files
|   |   ├── public            Public files such as images
|   |   ├── pages             Next.js page components
|   |   ├── src
|   |   ├── stories           Storybook story files
|   |   ├── test              Test code
|   |   ├── next.config.js    Next.js config file
|   |   ├── nodemon.json      Config for auto-starting Next.js custom server in dev
|   |   ├── package.json
|   |   ├── server.ts         Next.js custom server (implementation in server package)
|   |   ├── tsconfig.json
|   |   └── tsconfig.server.json
|   └── tsconfig.json
└── pakcage.json

Here is part of the web package's package.json and tsconfig.json.

# web/package.json

{
    "dependencies": {
        "@app/server": "*"
    }
}
# web/tsconfig.json

{
    "extends": "../tsconfig.json",
    "references": [
        { "path": "../server" }
    ]
}