Skip to content

0x80/mono-ts

Repository files navigation

Mono TS

Introduction

This is a personal quest for the perfect Typescript monorepo setup.

There is an accompanying article "My quest for the perfect TS monorepo" that you might want to read for context. Note: This article is now outdated and will be updated or rewritten for 2025 to reflect the current bundler-first architecture.

It is the best I could come up with given the tooling that is available, so expect this repository to change over time as the ecosystem around Typescript evolves.

My current projects are based on Node.js, Next.js, and Firebase, so that is what I am focussing on primarily. If you use different a different stack, I believe this can still be a great reference, as the approach itself does not depend on it.

Contributions and suggestions are welcome within the scope of this example, but I doubt there ever will be a one-size-fits-all solution, so this code should be viewed as opinionated.

I ended up basing a lot of things on the Turborepo starter, and I recommend reading their monorepo handbook.

Notable Features

  • Turborepo to orchestrate the build process and dependencies, including the v2 watch task.
  • A web app based on Next.js with ShadCN and Tailwind CSS v4
  • Working IDE go-to-definition and go-to-type-definition using .d.ts.map files
  • ESM everything
  • Typescript path aliases (that get resolved in build output)
  • Unified linting and formatting with Biome
  • Shared standardized configuration for TypeScript
  • Vitest
  • Multiple isolated Firebase deployments, using firebase-tools-with-isolate
  • Firebase emulators with hot reloading
  • Clean, typed Firestore abstractions using @typed-firestore/react and @typed-firestore/server

Install

In the main branch of this repo, packages are managed with PNPM.

There is also a branch for NPM

Originally, I included branches for Yarn classic (v1), and modern (v4), but I stopped updating them as Yarn is not that commonly used anymore.

I recommend using pnpm over npm or yarn. Apart from being fast and efficient, PNPM has better support for monorepos.

You can install PNPM with corepack which is part of modern Node.js versions:

  • corepack enable (if you have not used it before)
  • corepack prepare pnpm@latest --activate

Then run pnpm install from the repository root.

Usage

To get started, execute the following 3 scripts with pnpm [script name] from the root of the monorepo:

Script Description
watch Continuously builds everything using the Turborepo watch task, except for the web app which has its own dev server
emulate Starts the Firebase emulators.
dev Starts the Next.js dev server to build the app on request.

The web app should become available on http://localhost:3000 and the emulators UI on http://localhost:4000.

You should now have a working local setup, in which code changes to any package are picked up.

Monorepo Architecture

There is an accompanying article "My quest for the perfect TS monorepo" that you might want to read for context. Note: This article is now outdated and will be updated or rewritten for 2025 to reflect the current bundler-first architecture.

Design Philosophy

This monorepo is designed around a hybrid approach that prioritizes:

  • Production-ready output: Optimized, deployable packages
  • Clear separation of concerns: Bundlers handle compilation, TypeScript handles type checking
  • Efficient caching: Build artifacts can be cached and reused
  • Modern tooling: Leveraging the best of both bundlers and TypeScript

Hybrid Approach: Project References + Bundlers

This monorepo uses a hybrid approach that combines the best of both worlds:

  1. TypeScript project references for development workflow and IDE support
  2. Bundlers (tsdown) to compile shared packages into optimized dist files (with .d.ts and source maps)
  3. Turborepo to orchestrate build dependencies and ensure proper ordering

Why this hybrid approach?

  • Project references provide excellent IDE support and eliminate the need for watch tasks during development
  • Bundlers provide better optimization, tree-shaking, and multiple module format support for production
  • Clear dependency flow makes the build process more predictable and maintainable

Evolution from TypeScript-only to Hybrid:

In the previous setup, we relied on TypeScript's tsc command to generate .d.ts files for shared packages. This approach:

  • Generated tsconfig.tsbuildinfo files for incremental compilation
  • Required running tsc --build to rebuild dependencies
  • Created compilation artifacts that needed cleanup

The current hybrid approach eliminates these issues:

  • Bundlers (tsdown) generate all build artifacts including .d.ts files
  • No tsconfig.tsbuildinfo files are generated since TypeScript isn't compiling
  • Project references are used purely for type resolution and IDE features
  • Cleaner build process with fewer artifacts to manage

Package Dependencies

@repo/common (shared utilities)
    ↓
@repo/core (server-side logic)
    ↓
@repo/web, @repo/api, @repo/fns (consumers)

Build & Type Checking Strategy

The build process follows this sequence:

  1. Shared packages build first (@repo/common, @repo/core)

    • Create optimized dist files with .d.ts and .d.ts.map
    • Enable bundler optimizations (tree-shaking, minification, etc.)
  2. Consumer packages check types (@repo/web, @repo/api, @repo/fns)

    • TypeScript reads from built dist files
    • Project references ensure proper dependency tracking and IDE support
  3. Turborepo orchestrates the entire process

    • Ensures proper dependency ordering
    • Caches build artifacts for faster subsequent runs

tsdown configuration

Each buildable package contains a tsdown.config.ts that defines entries and output behavior. Examples:

// packages/common/tsdown.config.ts
import { defineConfig } from "tsdown";

export default defineConfig({
  entry: "src/index.ts",
  outDir: "dist",
  target: "es2022",
  sourcemap: true,
  dts: true,
});
// packages/core/tsdown.config.ts
import { defineConfig } from "tsdown";

export default defineConfig({
  entry: {
    "db-refs": "src/db-refs.ts",
    firebase: "src/firebase.ts",
    "utils/index": "src/utils/index.ts",
  },
  outDir: "dist",
  target: "es2022",
  sourcemap: true,
  dts: true,
  platform: "node",
});

Key commands:

  • pnpm build - Builds all packages with proper dependency ordering using tsdown configs
  • pnpm check-types - Runs type checking after ensuring dependencies are built
  • pnpm watch - Continuously rebuilds packages as they change

Why Project References Matter for Development:

In our hybrid setup, project references serve a specific purpose: they enable the IDE to understand the relationship between packages without requiring TypeScript compilation. This is crucial because:

Without project references, developers would need to run a watch task continuously for the IDE to pick up changes to shared packages during local development. This is cumbersome because:

  • Watch tasks consume resources and can slow down the development machine
  • IDE lag - changes in shared packages aren't immediately reflected in consuming packages
  • Manual restarts - often requiring restarting dev servers or clearing caches
  • Poor developer experience - developers lose the "instant feedback" that makes modern development enjoyable

With project references:

  • Instant IDE updates - changes in shared packages are immediately reflected
  • No watch tasks needed - the IDE automatically tracks dependencies
  • Better IntelliSense - go-to-definition and autocomplete work seamlessly across packages
  • Improved developer productivity - the development workflow feels natural and responsive
  • No compilation artifacts - since TypeScript isn't compiling, no tsconfig.tsbuildinfo files are generated

Namespace

Often in a monorepo, you will never publish the shared packages to NPM or some other registry, and because of that, the namespace you use to prefix your package names does not matter. You might as well pick a standard one that you can use in every project.

At first I used @mono, and later I switched to @repo when I encountered that in the Turborepo examples. I like both, because they are equally short and clear, but I went with @repo because I expect it might become a standard sooner.

Packages

  • common Code that is shared across both front-end and back-end environments simultaneously. Built with tsdown for optimal output.
  • core Code that is only shared between server environments, like cloud functions, containing mostly "core" business logic. Depends on common.

A standard name for a package that is only shared between client-side apps is ui. Besides sharing UI components, I also use it to share other things that are solely relevant to the clients.

Apps

  • web A Next.js based web application configured to use Tailwind CSS and ShadCN components. Consumes built packages from common and core.

Services

  • fns Various Firebase functions that execute on document writes, pubsub events etc. Consumes built packages from common and core.
  • api A 2nd gen Firebase function (based on Cloud Run) serving as an API endpoint. This package also illustrates how to use secrets.

Prettier + ESLint vs Biome

I have switched from using Prettier + ESLint to Biome, because it is much faster but it seems it is not a full replacement yet.

Biome (as of v2.2) does not have the type-aware rules that typescript-eslint provides. I find those rules the most valuable, and this is holding me back from using Biome on a large codebase.

Most notably, the noFloatingPromises rule does not seem to be working reliably yet.

Things I miss from Prettier are:

  • Markdown formatting
  • JSDoc formatting (via plugin)

For this reason I have kept prettier for markdown formatting. For JSDoc formatting I have no solution yet.

If you want to see a working (shared) ESLint configuration, just hop back a few commits to before Biome was introduced.

Firebase

In their documentation for monorepos, Firebase recommends putting all configurations in the root of the monorepo. This makes it possible to deploy all packages at once, and easily start the emulators shared between all packages.

Demo Project

Throughout this repository, we use a Firebase demo project called demo-mono-ts A demo project allows you to run emulators for the different components like database without creating a Firebase projects with resources. To make this work you pass the --project flag when starting the emulator, and you need to use a name that starts with demo-.

When passing configuration to initializeApp you can use any non-empty string for the API keys as you can see in apps/web/.env.development.

Deploying

Firebase does not natively support monorepos where packages used shared code from other packages. The Firebase deploy pipeline wants to upload a self-contained package that can be treated similarly to an NPM package, so that it can run an install and execute the main entry from the manifest.

To support shared packages, this repo uses firestore-tools-with-isolate, which is a firebase-tools fork I created to integrate isolate-package. I wrote an article explaining what it does and why it is needed.

This demo can be run using only the emulators, but if you would like to see the deployment to Firebase working you can simply execute npx firebase deploy --project your-project-name the root of the monorepo.

You might notice @google-cloud/functions-framework as a dependency in the service package even though it is not being used in code imports. It is currently required for Firebase to be able to deploy a PNPM workspace. Without it you will get an error asking you to install the dependency. I don't quite understand how the two are related, but it works.

Running Emulators

With the firebase config in the root of the monorepo, you can configure and start the emulators for all packages at once with pnpm emulate.

I have stored these in .env files in the respective service packages. Normally you would want to store them in a file that is not part of the repository like .env.local but by placing them in .env I prevent having to give instructions for setting them up just for running the demo.

Secrets

The api service uses a secret for DEMO_API_KEY. To make secrets work with the emulator you currently have to add the secret to .secret.local and also a .env or .env.local file. See this issue for more info. I have placed it in .env which is part of the repo, so you don't have to set anything up, but .env.local is the proper location probably because that file is not checked into git.

Typed Firestore

This repo uses @typed-firestore/react and @typed-firestore/server to provide typed Firestore abstractions for both React and Node.js.

If you're interested here is an in-depth article of how they came about.

About

A quest for the perfect TS monorepo setup

Resources

License

Stars

Watchers

Forks

Contributors 2

  •  
  •  

Languages