<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Build, Measure, Learn: Tech & Data]]></title><description><![CDATA[Hands-on notes on building, measuring, and learning from data-heavy products: monorepos, infrastructure as code, system design, and applied data science.]]></description><link>https://sonaliparekh.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad4b9a4c5e486968cf04a55/54f91d64-81a1-4c6e-8bac-567ad82ab684.png</url><title>Build, Measure, Learn: Tech &amp; Data</title><link>https://sonaliparekh.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 04:40:52 GMT</lastBuildDate><atom:link href="https://sonaliparekh.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Set up a monorepo you can still maintain in a year]]></title><description><![CDATA[A pnpm and Turborepo foundation with a working API, shared packages and commit checks.]]></description><link>https://sonaliparekh.hashnode.dev/typescript-monorepo-pnpm-turborepo-nestjs</link><guid isPermaLink="true">https://sonaliparekh.hashnode.dev/typescript-monorepo-pnpm-turborepo-nestjs</guid><category><![CDATA[TypeScript]]></category><category><![CDATA[monorepo]]></category><category><![CDATA[pnpm]]></category><category><![CDATA[turborepo]]></category><category><![CDATA[nestjs]]></category><dc:creator><![CDATA[Sonali Parekh]]></dc:creator><pubDate>Fri, 23 Oct 2026 17:18:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad4b9a4c5e486968cf04a55/4578dba5-3dbc-45f8-98d1-448ade237cb2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<h2>In short</h2>
<ul>
<li>We build a TypeScript monorepo around one endpoint, <code>GET /health</code>, and add what that endpoint needs to stay healthy as the repo grows.</li>
<li>You finish with a NestJS API, two shared packages, a task runner, commit checks, CI, and a GitHub template to start your own project from.</li>
<li>Every tool choice lists alternatives and what it would cost to change.</li>
<li>The code for this post is tagged <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1"><code>v0.1.1</code></a>.</li>
</ul>
<h2>How this series works</h2>
<p>This post is part 1 of a series on building a fullstack TypeScript monorepo in your own repo, layer by layer. A public template repo holds the finished result of each part, so you can compare your work against it or start from it. This part covers the monorepo and the API. Later parts add two frontends, a database, secrets, Terraform and deployment. Each part ends at a git tag, so you can check out the exact state a post describes.</p>
<p>Two habits run through every part. First, we give the reason before the step, and we link the official documentation for anything that deserves more depth than a post can give it. Second, every library that carries behavior, such as a database client or a cache, sits behind a seam. App code imports a module we own, only one adapter folder imports the library, and a lint rule enforces it. Replacing the library then means rewriting one folder instead of the app.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4b9a4c5e486968cf04a55/f60a5e6b-ce35-42e5-86a2-d07ae514ea13.png" alt="The seam pattern: your code imports a port you own. The port is implemented by an adapter folder that uses one library. A dashed box shows that another library could replace it by changing only the adapter." style="display:block;margin:0 auto" />

<p><em>The plum dashed box is the swap point. Nothing on its left side imports the library.</em></p>
<p>The foundations, here pnpm, Turborepo and NestJS, are not wrapped. They shape the whole repo, so changing one is a real piece of work. We say what that work is in the "Swap it" section near the end. The first seam arrives with the database in part 3, and the rule behind it is written down in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/docs/adr/0004-third-party-libraries-sit-behind-a-seam.md">ADR-0004</a>.</p>
<h2>What you need</h2>
<p>Before you start, install:</p>
<ul>
<li><a href="https://nodejs.org">Node.js</a> 24.15.0 or newer. The repo pins this in <code>.node-version</code>.</li>
<li><a href="https://pnpm.io/installation">pnpm</a> 12.14.0. <a href="https://github.com/nodejs/corepack">Corepack</a> can install the pinned version for you: run <code>corepack enable</code>, and the version named in <code>packageManager</code> is used.</li>
<li>git, and a GitHub account if you want to start from the template.</li>
</ul>
<h2>What you will have at the end</h2>
<p>We build one thing: a <code>GET /health</code> endpoint. It is small on purpose, because each step adds something that endpoint needs once the repo grows. It needs a place to live (a workspace), a build order (a task graph), a contract it shares with its clients (shared types), a handler (the API), proof that it works (tests) and guards against breaking it (commit checks and CI).</p>
<p>Here is the finished state. Clone the tag and run it:</p>
<pre><code class="language-bash">git clone --branch v0.1.1 https://github.com/sonalip9/fullstack-monorepo-template.git
cd fullstack-monorepo-template
pnpm install
pnpm dev
</code></pre>
<p>In a second terminal:</p>
<pre><code class="language-bash">curl localhost:3000/health
# {"status":"ok","timestamp":"2026-10-19T16:48:03.366Z"}
</code></pre>
<p>To run what CI runs before you push, use <code>pnpm check</code>. The rest of this post builds that result from an empty folder.</p>
<h2>Why a monorepo</h2>
<p>A monorepo keeps several apps and packages in one repository. That choice earns its keep in five ways:</p>
<ul>
<li><strong>One contract, one change.</strong> An API and its clients share types. When a type changes, every consumer fails its build in the same pull request.</li>
<li><strong>One toolchain.</strong> Lint rules and TypeScript settings live in one place, so every package follows the same rules.</li>
<li><strong>One install.</strong> A single lockfile and shared version ranges keep packages from drifting apart.</li>
<li><strong>Ordered, cached builds.</strong> A task runner builds packages in dependency order and skips work it has already done.</li>
<li><strong>One review.</strong> A change that touches the API and its clients is a single pull request, tested together.</li>
</ul>
<p>The cost is that you now own the tooling that holds the packages together. We keep that tooling small, and we write down why each piece is there. When a monorepo is the wrong choice is a question for a later post.</p>
<p>Apps go in <code>apps/</code>. Code that apps share goes in <code>packages/</code>:</p>
<pre><code class="language-text">fullstack-monorepo-template/
├── apps/
│   └── api/             # NestJS, GET /health
├── packages/
│   ├── config/          # @template/config: ESLint and tsconfig presets
│   └── types/           # @template/types: API contracts
├── docs/
│   ├── adr/             # architecture decisions
│   └── swapping/        # how to replace a library or tool
├── .github/workflows/ci.yml
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6ad4b9a4c5e486968cf04a55/275870a8-df11-419f-966f-0f381e1a7b6f.png" alt="Package dependencies: apps/api imports HealthResponse from packages/types and extends the lint and TypeScript config from packages/config. packages/types also extends the TypeScript config from packages/config. A dashed box marks the frontends that Post 2 adds." style="display:block;margin:0 auto" />

<p><em>Solid boxes exist at v0.1.1. The dashed box is added in Post 2.</em></p>
<h2>Step 1: Initialise the repo</h2>
<p>A monorepo starts as an ordinary repo, so we begin there. We need a folder under git and a root <code>package.json</code> that pins the tools everyone uses. Pinning matters more in a monorepo than in a single app, because one developer on a different Node or pnpm version can produce a bug nobody else can reproduce.</p>
<pre><code class="language-bash">mkdir fullstack-monorepo-template
cd fullstack-monorepo-template
git init
</code></pre>
<p>The root <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/package.json"><code>package.json</code></a> starts small. Later steps add scripts and dev dependencies:</p>
<pre><code class="language-json">{
  "name": "fullstack-monorepo-template",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@12.14.0",
  "engines": { "node": "&gt;=24.15.0" }
}
</code></pre>
<p><code>private</code> stops the root from being published by accident. <code>type: module</code> makes every package ESM by default. <code>packageManager</code> tells Corepack which pnpm to use, and <code>engines</code> warns anyone on an older Node.</p>
<p>Two more files finish the job. <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/.node-version"><code>.node-version</code></a> holds one line, <code>24.15.0</code>, which version managers read. <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/.gitignore"><code>.gitignore</code></a> keeps build output and secrets out of git:</p>
<pre><code class="language-text">node_modules
dist
.turbo
coverage
*.tsbuildinfo
.env
.env.*
!.env.example
.DS_Store
</code></pre>
<p>The repo now knows which tools to use. It does not yet know that <code>apps/</code> and <code>packages/</code> will hold packages of their own. That is the next step.</p>
<h2>Step 2: Create the workspace</h2>
<p>Without a workspace, every folder is an unrelated project, and none can depend on a sibling. <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/pnpm-workspace.yaml"><code>pnpm-workspace.yaml</code></a> fixes that, and it does three jobs:</p>
<pre><code class="language-yaml">packages:
  - 'apps/*'
  - 'packages/*'

# Dependency install scripts. pnpm fails the install for any script not
# listed here. Everything is denied until a package proves it needs one.
allowBuilds:
  # Ships prebuilt binaries as optional dependencies; the script is not needed.
  '@swc/core': false

# Shared dependency versions. Reference with "catalog:" instead of repeating
# a version range in every package.
catalog:
  '@nestjs/common': 12.2.0
  '@nestjs/core': 12.2.0
  # ...
  typescript: ~6.0.3
  vitest: 5.0.5
</code></pre>
<p>The first job is the <a href="https://pnpm.io/workspaces">workspace</a> itself. The two globs make every folder under <code>apps/</code> and <code>packages/</code> a package. Packages depend on each other with the <code>workspace:</code> protocol, for example <code>"@template/types": "workspace:*"</code> in the API, which tells pnpm to link the local folder instead of downloading a copy.</p>
<p>The second job is the <a href="https://pnpm.io/catalogs">catalog</a>. A version used by more than one package is written once, and a package refers to it with <code>"@nestjs/common": "catalog:"</code>. Upgrading NestJS then means changing one line.</p>
<p>The third job is a safety setting. Some npm packages run a script when they are installed, which is a way for a compromised package to run code on your machine. With <a href="https://pnpm.io/settings/build"><code>allowBuilds</code></a>, pnpm treats any package that is not listed as unreviewed, and the install exits with an error when one has a build script. We then decide per package. Our example is <code>@swc/core</code>: it ships its binary as an optional dependency, so the script is not needed and we set it to <code>false</code>.</p>
<p><strong>Gotcha.</strong> The first <code>pnpm install</code> after you add a package with a build script fails with an error that names the package. That is the setting working. Add the package to <code>allowBuilds</code> as <code>true</code> or <code>false</code>, then install again.</p>
<p>One more decision lives in the catalog: how tightly to pin. The lockfile fixes what is installed, but the ranges in <code>package.json</code> decide what an upgrade may pick up. The template pins exactly by default. All <code>@nestjs/*</code> packages must match each other, and tooling is exact so a CI failure is never caused by a silent upgrade. It uses <code>~</code> for <code>typescript</code> and <code>^</code> for type definitions and linters. The reasons are in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/docs/adr/0001-monorepo-tooling.md">ADR-0001</a>.</p>
<p><strong>Gotcha.</strong> Since pnpm 11, <a href="https://pnpm.io/settings/dependency-resolution"><code>minimumReleaseAge</code></a> defaults to 1440 minutes, so pnpm will not install a version published less than a day ago. That protects you from a compromised release that is pulled soon after it appears. If you need a brand-new version right away, list it in <code>minimumReleaseAgeExclude</code> and remove the entry once the version has aged. While we built this template, we needed that list for the newest NestJS release. The versions have aged past the window, so the list is gone at <code>v0.1.1</code>.</p>
<p>The workspace can now find its packages. It still does not know in what order to build them, and that is the job of the next step.</p>
<h2>Step 3: Add the task graph</h2>
<p>When one package imports another, the imported package must be built first. We could script that order by hand and keep it up to date forever, or we can let a task runner read it from the dependency graph. <a href="https://turborepo.dev/docs">Turborepo</a> runs the same script in every package that has it, in dependency order, and caches the result. The graph lives in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/turbo.json"><code>turbo.json</code></a>:</p>
<pre><code class="language-json">{
  "$schema": "https://turborepo.dev/schema.json",
  "globalDependencies": [".node-version"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["$TURBO_DEFAULT$", "!**/*.md", "!**/*.spec.ts", "!test/**"],
      "outputs": ["dist/**"]
    },
    "lint": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "!**/*.md"] },
    "typecheck": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "!**/*.md"] },
    "test": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "!**/*.md"], "outputs": [] },
    "dev": {
      "dependsOn": ["^build"],
      "cache": false,
      "passThroughEnv": ["PORT"],
      "persistent": true
    }
  }
}
</code></pre>
<p>(The real file formats each task over several lines.)</p>
<p>The key is <a href="https://turborepo.dev/docs/crafting-your-repository/configuring-tasks"><code>^build</code></a>, which means "build the packages I depend on first". Lint, typecheck and test need it too, because the API imports <code>@template/types</code> from its built <code>dist/</code> folder.</p>
<p><strong>Gotcha.</strong> Turborepo only passes a task the environment variables it declares, through <a href="https://turborepo.dev/docs/reference/configuration#passthroughenv"><code>passThroughEnv</code></a>. We checked what happens without it: with the setting removed, <code>PORT=4000 pnpm dev</code> is ignored and the API still listens on 3000. With <code>"passThroughEnv": ["PORT"]</code>, the variable reaches the API.</p>
<p>When only one app is deployed, you rarely want to build everything. The <a href="https://turborepo.dev/docs/reference/run#--filter-string"><code>--filter</code></a> flag builds one package and what it depends on: <code>pnpm turbo run build --filter=@template/api</code>.</p>
<p>The graph is ready, but there are no packages to run it on. We add the first two now.</p>
<h2>Step 4: Add shared types and config</h2>
<p>The API and its future clients must agree on what <code>/health</code> returns. If the type lived inside the API app, a client would have to import from the API and pull the whole framework in with it. So the contract gets a package of its own, <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1/packages/types"><code>packages/types</code></a>. At this stage it holds one interface, in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/packages/types/src/health.ts"><code>src/health.ts</code></a>:</p>
<pre><code class="language-ts">/** Response body of `GET /health`. */
export interface HealthResponse {
  status: 'ok';
  /** ISO 8601 timestamp of the response. */
  timestamp: string;
}
</code></pre>
<p>The package is built with <code>tsc</code> to <code>dist/</code>. Later, the Expo and Next.js apps import the same type, so a change to it fails their builds too.</p>
<p>Interfaces and type aliases disappear when TypeScript compiles, so today this package ships no runtime code. Enums and constant values are different: they are real JavaScript. If a contract needs shared constants, such as a list of allowed statuses, export an <code>as const</code> object and derive the type from it. The package already points its <code>exports</code> at <code>dist/index.js</code>, so it can carry that small amount of runtime code when the time comes.</p>
<p>The second shared package is <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1/packages/config"><code>packages/config</code></a>. It holds the ESLint flat config and two TypeScript presets, <code>tsconfig.base.json</code> and <code>tsconfig.nest.json</code>. We could keep these files at the repo root and point each package at them with a relative path. We make them a package for three reasons. A package is imported by name, so it keeps working if you move a folder. It appears in the dependency graph, so Turborepo reruns the affected tasks when a preset changes. And each package lists it as a dev dependency, so the link is visible in <code>package.json</code> instead of hidden in a path.</p>
<p>A package then extends a preset and adds only what is specific to it. This is the <code>tsconfig.json</code> of <code>apps/api</code>:</p>
<pre><code class="language-json">{
  "extends": "@template/config/tsconfig.nest.json",
  "compilerOptions": { "rootDir": ".", "noEmit": true, "types": ["node"] },
  "include": ["src", "test", "vitest.config.ts"]
}
</code></pre>
<p>The base preset uses <code>module: NodeNext</code>, so everything is <a href="https://nodejs.org/api/esm.html">ESM</a>. It also turns on <code>verbatimModuleSyntax</code>, which is why you will see <code>import type</code> for anything that is only a type.</p>
<p><strong>Gotcha.</strong> Under NodeNext, relative imports must end in <code>.js</code>, even in TypeScript files: <code>import { AppModule } from './app.module.js';</code>. It looks wrong the first time. TypeScript is describing the file that exists after compilation, so we write <code>.js</code> and the build works. The <a href="https://www.typescriptlang.org/docs/handbook/modules/reference.html#relative-file-path-resolution">TypeScript module reference</a> explains the rule.</p>
<p>With a contract and shared settings in place, the handler itself is short.</p>
<h2>Step 5: Add the API</h2>
<p><a href="https://docs.nestjs.com">NestJS</a> is a Node framework that organizes an API into modules and controllers. A <a href="https://docs.nestjs.com/controllers">controller</a> maps a route to a method. Ours maps <code>GET /health</code> to <code>check()</code>, in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/apps/api/src/health.controller.ts"><code>health.controller.ts</code></a>:</p>
<pre><code class="language-ts">import { Controller, Get } from '@nestjs/common';
import type { HealthResponse } from '@template/types';

@Controller('health')
export class HealthController {
  @Get()
  check(): HealthResponse {
    return { status: 'ok', timestamp: new Date().toISOString() };
  }
}
</code></pre>
<p>The return type comes from the shared package, so if the contract changes, this file fails to compile. <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/apps/api/src/main.ts"><code>main.ts</code></a> creates the app and reads <code>PORT</code>, defaulting to 3000. The <code>AppModule</code> in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/apps/api/src/app.module.ts"><code>app.module.ts</code></a> registers the controller.</p>
<p>Run <code>pnpm dev</code> and call the endpoint with the <code>curl</code> command from the start of this post. The typed response comes back.</p>
<p>It works when we try it by hand. Next we teach the repo to check it for us.</p>
<h2>Step 6: Test it with Vitest</h2>
<p>A <code>curl</code> proves the endpoint works once. A test proves it keeps working. <a href="https://vitest.dev/guide/">Vitest</a> runs two kinds here, following the <a href="https://docs.nestjs.com/fundamentals/testing">NestJS testing guide</a>: a unit test for the controller, in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/apps/api/src/health.controller.spec.ts"><code>health.controller.spec.ts</code></a>, and an end-to-end test in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/apps/api/test/health.e2e-spec.ts"><code>health.e2e-spec.ts</code></a> that boots the app and calls <code>GET /health</code> with <code>supertest</code>.</p>
<p>Getting Vitest to run Nest needs one non-obvious line. Nest's dependency injection reads decorator metadata at runtime. Vitest transforms TypeScript with esbuild, and esbuild does not emit that metadata. <a href="https://swc.rs">SWC</a> does, so <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/apps/api/vitest.config.ts"><code>vitest.config.ts</code></a> swaps the transform with <a href="https://github.com/unplugin/unplugin-swc"><code>unplugin-swc</code></a>:</p>
<pre><code class="language-ts">import swc from 'unplugin-swc';
import { defineConfig } from 'vitest/config';

// Nest relies on decorator metadata, which esbuild does not emit. SWC does.
export default defineConfig({
  test: {
    include: ['src/**/*.spec.ts', 'test/**/*.e2e-spec.ts'],
  },
  plugins: [swc.vite({ module: { type: 'es6' } })],
});
</code></pre>
<p><strong>Gotcha.</strong> Without the SWC plugin, the tests fail in confusing ways: injected dependencies are <code>undefined</code>, and the error does not mention metadata. If a Nest test fails on injection, check the transform first.</p>
<p>This is also why <code>@swc/core</code> is in the API's dev dependencies, and why it was our example in <code>allowBuilds</code> in Step 2. The tests pass, but only if someone runs them. The last step makes the repo run them for us.</p>
<h2>Step 7: Add the checks</h2>
<p>Three checks run so that nobody has to remember them. Each one catches a different kind of mistake at a different moment.</p>
<p><strong>Before a commit.</strong> <a href="https://typicode.github.io/husky/">husky</a> runs git hooks that live in the repo. <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/.husky/pre-commit"><code>.husky/pre-commit</code></a> runs <a href="https://github.com/lint-staged/lint-staged">lint-staged</a>, which applies ESLint and Prettier to the staged files, then a typecheck of the whole repo:</p>
<pre><code class="language-sh">pnpm exec lint-staged
pnpm typecheck
</code></pre>
<p><strong>At the commit message.</strong> <a href="https://commitlint.js.org">commitlint</a>, run from <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/.husky/commit-msg"><code>.husky/commit-msg</code></a>, enforces <a href="https://www.conventionalcommits.org">Conventional Commits</a> with a few extra rules in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/commitlint.config.js"><code>commitlint.config.js</code></a>:</p>
<pre><code class="language-js">const config = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'header-max-length': [2, 'always', 72],
    'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']],
    'subject-full-stop': [2, 'never', '.'],
  },
};
</code></pre>
<p>The history of the template follows these rules: one small, scoped commit per concern, such as <code>build(turbo): add task graph for build, lint, typecheck, test, dev</code>. Read <code>git log --oneline</code> to see how the repo was put together, step by step.</p>
<p><strong>On every pull request and push to main.</strong> A <a href="https://docs.github.com/en/actions">GitHub Actions</a> workflow, <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/.github/workflows/ci.yml"><code>.github/workflows/ci.yml</code></a>, installs with a frozen lockfile and runs lint, typecheck, test and build. <code>pnpm check</code> runs the same four tasks locally, so a green <code>pnpm check</code> means a green CI run.</p>
<p>The repo now builds, tests and guards itself. What is left is to make the choices behind it easy to find, and easy to change.</p>
<h2>Swap it</h2>
<p>Every tool in this post is a choice, and the choices come in two kinds. Some you can change by editing configuration. Others are foundations that shape the code around them, so changing one is real work. The table shows the difference, with a rough effort for each.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Used here</th>
<th>Alternatives</th>
<th>Effort to change</th>
</tr>
</thead>
<tbody><tr>
<td>Task runner</td>
<td>Turborepo</td>
<td>Nx: replace <code>turbo.json</code> with <code>nx.json</code> target defaults. No runner: <code>pnpm -r --workspace-concurrency=1 run &lt;task&gt;</code> gives dependency order without caching.</td>
<td>Under two hours</td>
</tr>
<tr>
<td>Package manager</td>
<td>pnpm workspaces</td>
<td>npm workspaces: <code>workspaces</code> globs, <code>*</code> instead of <code>workspace:*</code>, no catalogs, <code>ignore-scripts=true</code> in <code>.npmrc</code>. Yarn 4: same globs, catalogs and <code>enableScripts</code>. Bun: globs and catalogs in <code>package.json</code>, scripts denied unless listed in <code>trustedDependencies</code>.</td>
<td>Half a day (config, lockfile, CI)</td>
</tr>
<tr>
<td>API framework (a foundation)</td>
<td>NestJS</td>
<td>Fastify: closest model (routes, hooks, plugins), but you write the structure Nest gives you. Hono: small and portable across runtimes, with fewer built-in Node features. Express: familiar and minimal, with no dependency injection. Next.js route handlers: removes the separate API deployable, but couples the API to the web app.</td>
<td>A rewrite of <code>apps/api</code>. Under an hour for one endpoint, more as endpoints and dependency injection grow</td>
</tr>
</tbody></table>
<p>A framework swap rewrites <code>apps/api</code>. It does not touch <code>packages/types</code>, the config package, Turborepo or CI, and that is the value of keeping the contract in its own package. Step-by-step notes are in <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/docs/swapping/monorepo-tooling.md"><code>docs/swapping/monorepo-tooling.md</code></a> and <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/docs/swapping/api-framework.md"><code>docs/swapping/api-framework.md</code></a>.</p>
<p>The reasoning behind each choice is also written down in the repo, as short architecture decision records:</p>
<table>
<thead>
<tr>
<th>File</th>
<th>Decision</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/docs/adr/0001-monorepo-tooling.md"><code>docs/adr/0001-monorepo-tooling.md</code></a></td>
<td>pnpm workspaces, catalogs, denied install scripts, Turborepo, pin policy</td>
</tr>
<tr>
<td><a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/docs/adr/0002-shared-packages.md"><code>docs/adr/0002-shared-packages.md</code></a></td>
<td><code>@template/config</code> and <code>@template/types</code>, ESM, built <code>dist/</code></td>
</tr>
<tr>
<td><a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/docs/adr/0003-api-framework.md"><code>docs/adr/0003-api-framework.md</code></a></td>
<td>NestJS as a foundation, Vitest with SWC</td>
</tr>
<tr>
<td><a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/docs/adr/0004-third-party-libraries-sit-behind-a-seam.md"><code>docs/adr/0004-third-party-libraries-sit-behind-a-seam.md</code></a></td>
<td>the seam rule used by every later post</td>
</tr>
</tbody></table>
<h2>What I would do differently</h2>
<p>The pre-commit hook typechecks the whole repo, not only the staged files. It catches a broken contract before it reaches CI, and it costs a few seconds on every commit. With one API that is fine. With five packages we would move the full typecheck to CI and keep only lint-staged in the hook.</p>
<p>Until then, for a work-in-progress commit, skip the hooks with <code>git commit --no-verify</code>. Run <code>pnpm check</code> before you push.</p>
<h2>Try it</h2>
<p>Check out the exact state of this post:</p>
<pre><code class="language-bash">git clone --branch v0.1.1 https://github.com/sonalip9/fullstack-monorepo-template.git
</code></pre>
<p>To start your own project, click <strong>Use this template</strong> on <a href="https://github.com/sonalip9/fullstack-monorepo-template">fullstack-monorepo-template</a> (GitHub explains <a href="https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template">creating a repository from a template</a>), then rename the package scope. This replaces <code>@template/</code> everywhere and refreshes the lockfile:</p>
<pre><code class="language-bash">pnpm rename-scope @acme
pnpm check
</code></pre>
<p>In part 2, the template gets two frontends, an Expo app and a Next.js app, that share one UI package and the types you saw here, and both call <code>/health</code>.</p>
<h2>References</h2>
<p><strong>Official documentation</strong></p>
<ul>
<li>pnpm: <a href="https://pnpm.io/workspaces">workspaces</a>, <a href="https://pnpm.io/catalogs">catalogs</a>, <a href="https://pnpm.io/settings/build"><code>allowBuilds</code></a>, <a href="https://pnpm.io/settings/dependency-resolution"><code>minimumReleaseAge</code></a>, <a href="https://pnpm.io/installation">installation</a></li>
<li>Turborepo: <a href="https://turborepo.dev/docs">documentation</a>, <a href="https://turborepo.dev/docs/crafting-your-repository/configuring-tasks">configuring tasks</a>, <a href="https://turborepo.dev/docs/reference/configuration#passthroughenv"><code>passThroughEnv</code></a>, <a href="https://turborepo.dev/docs/reference/run#--filter-string"><code>--filter</code></a></li>
<li>TypeScript: <a href="https://www.typescriptlang.org/docs/handbook/modules/reference.html">module reference</a></li>
<li>NestJS: <a href="https://docs.nestjs.com">documentation</a>, <a href="https://docs.nestjs.com/controllers">controllers</a>, <a href="https://docs.nestjs.com/fundamentals/testing">testing</a></li>
<li>Testing: <a href="https://vitest.dev/guide/">Vitest</a>, <a href="https://swc.rs">SWC</a>, <a href="https://github.com/unplugin/unplugin-swc"><code>unplugin-swc</code></a></li>
<li>Commit checks: <a href="https://typicode.github.io/husky/">husky</a>, <a href="https://github.com/lint-staged/lint-staged">lint-staged</a>, <a href="https://commitlint.js.org">commitlint</a>, <a href="https://www.conventionalcommits.org">Conventional Commits</a></li>
<li>Platform: <a href="https://nodejs.org">Node.js</a>, <a href="https://github.com/nodejs/corepack">Corepack</a>, <a href="https://docs.github.com/en/actions">GitHub Actions</a></li>
</ul>
<p><strong>Template files at</strong> <code>v0.1.1</code></p>
<ul>
<li>Root: <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/package.json"><code>package.json</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/.node-version"><code>.node-version</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/.gitignore"><code>.gitignore</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/pnpm-workspace.yaml"><code>pnpm-workspace.yaml</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/turbo.json"><code>turbo.json</code></a></li>
<li>Packages: <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1/packages/types"><code>packages/types</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1/packages/config"><code>packages/config</code></a></li>
<li>API: <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1/apps/api/src"><code>apps/api/src</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1/apps/api/test"><code>apps/api/test</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/apps/api/vitest.config.ts"><code>vitest.config.ts</code></a></li>
<li>Checks: <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1/.husky"><code>.husky</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/commitlint.config.js"><code>commitlint.config.js</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/blob/v0.1.1/.github/workflows/ci.yml"><code>ci.yml</code></a></li>
<li>Decisions: <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1/docs/adr"><code>docs/adr</code></a>, <a href="https://github.com/sonalip9/fullstack-monorepo-template/tree/v0.1.1/docs/swapping"><code>docs/swapping</code></a></li>
</ul>
]]></content:encoded></item></channel></rss>