Goatfied

architecture

Lsp Routing Strategies For Monorepos Implementation Guide: practical systems guide

Technical field guide on lsp routing strategies for monorepos implementation guide for teams building dependable AI coding workflows.

2026-07-208 min readBy Goatfied
Engineering team collaborating on ai editor architecture

# LSP Routing Strategies For Monorepos: Implementation Guide

The moment you ask your editor for "go to definition" on a symbol that exists in three different packages of your monorepo, you're hitting one of the harder problems in developer tooling: which language server answers, and what happens when they disagree? Most engineers don't think about LSP routing until their jump-to-definition lands in the wrong package or autocomplete suggests imports from a module they've never heard of.

Language servers weren't designed for monorepos. The Language Server Protocol assumes a fairly clean one-server-per-workspace model, but monorepos routinely need multiple TypeScript servers (one per package with different `tsconfig.json` settings), multiple Python environments (different virtualenvs for services with conflicting dependencies), or even polyglot setups where Rust and Go live side-by-side. Without deliberate routing, you get collisions, stale indexes, and features that work in small repos but silently degrade at scale.

Why naive LSP setups break in monorepos

Most editors initialize one language server per file extension. Open a `.ts` file, get `typescript-language-server`. Open a `.py` file, get `pyright` or `pylsp`. This works until your monorepo has:

  • **Multiple TS/JS packages** with different compiler settings (one using React JSX, another Node ESM, another Deno)
  • **Virtualenv-per-service** Python projects where `import mylib` means different things in `/services/api` vs `/services/worker`
  • **Build-generated code** that only exists after running Bazel/Buck/Nx but needs to be indexed for autocomplete

The default behavior is to start one server at the workspace root and let it discover everything. This server will either pick one `tsconfig.json` arbitrarily, merge settings incorrectly, or time out scanning hundreds of thousands of files it shouldn't care about.

Symptoms you're hitting this:

  • Jump-to-definition occasionally lands in `node_modules` instead of your source
  • Autocomplete suggests imports from unrelated packages
  • Diagnostics appear and disappear randomly as the server re-indexes
  • CPU spikes on file save as the server tries to typecheck the entire monorepo

Strategy 1: Workspace roots per package

The most straightforward fix is to treat each logical package as its own workspace. When the user opens `/services/api/src/handler.ts`, initialize a TypeScript server rooted at `/services/api` with its local `tsconfig.json`. When they open `/packages/ui/Button.tsx`, initialize a separate server rooted at `/packages/ui`.

**Implementation sketch for Neovim's `lspconfig`:**


local lspconfig = require('lspconfig')

local util = require('lspconfig.util')



lspconfig.tsserver.setup({

  root_dir = function(fname)

    -- Walk up until we find package.json + tsconfig.json

    return util.root_pattern('tsconfig.json')(fname)

      or util.root_pattern('package.json')(fname)

  end,

  -- One server instance per unique root_dir

  single_file_support = false,

})

**Tradeoffs:**

  • ✅ Each server sees only relevant files, stays fast
  • ✅ Correct project settings per package
  • ❌ Cross-package jump-to-definition breaks (lands in compiled output or fails)
  • ❌ Memory overhead: 5 TypeScript packages = 5 `tsserver` processes

For Goatfied's agent loop, this pattern matters because our plan step needs to know which packages are affected by a change. If the LSP server for `packages/ui` can't resolve imports from `packages/common`, our constraint system has to infer dependencies from package.json or Bazel rules instead of relying on the editor's symbol index.

Strategy 2: Single server with project references

TypeScript 3+ introduced [project references](https://www.typescriptlang.org/docs/handbook/project-references.html) specifically for monorepos. You define a root `tsconfig.json` that references each package:


{

  "files": [],

  "references": [

    { "path": "./services/api" },

    { "path": "./services/worker" },

    { "path": "./packages/ui" }

  ]

}

Start one `tsserver` at the repo root. It loads all projects but maintains separate type-checking contexts.

**Editor config:**


lspconfig.tsserver.setup({

  root_dir = util.root_pattern('tsconfig.base.json', '.git'),

  -- Pass explicit workspace folders to the server

  init_options = {

    preferences = {

      includePackageJsonAutoImports = 'off', -- Avoid cross-package pollution

    }

  },

})

**Tradeoffs:**

  • ✅ Cross-package jump-to-definition works
  • ✅ Single process, lower memory footprint
  • ❌ Slower initial startup (indexes entire monorepo)
  • ❌ Requires strict project reference discipline (forgotten refs = missing autocomplete)

This works well if your monorepo uses a build orchestrator like Nx or Turborepo that already maintains dependency graphs. Goatfied's validation step benefits here: after our agent edits a file in `packages/ui`, we can run `tsc --build` to check that dependent projects still compile before committing.

Strategy 3: Dynamic routing with workspace folders

LSP 3.6 added support for [workspace folders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_workspaceFolders), letting you add/remove roots dynamically. When the user opens a file, your editor logic determines the right package and registers it as a workspace folder if it's not already tracked.

**VS Code** does this automatically with multi-root workspaces. For **Neovim**, you can hook into `BufEnter`:


vim.api.nvim_create_autocmd('BufEnter', {

  pattern = '*.ts,*.tsx',

  callback = function()

    local root = find_package_root(vim.fn.expand('%:p'))

    if root and not is_workspace_registered(root) then

      vim.lsp.buf.add_workspace_folder(root)

    end

  end,

})

**Tradeoffs:**

  • ✅ Adaptive: only loads projects you're actively editing
  • ✅ Works across polyglot monorepos (separate logic per language)
  • ❌ Complex state management (which folders are loaded? when to unload?)
  • ❌ Inconsistent behavior if heuristics misfire

Handling build-generated code

Many monorepos generate TypeScript definitions from Protobuf, GraphQL schemas, or OpenAPI specs. LSP servers need to index these files, but they only exist after running a build.

**Option A: Keep generated code in-tree**

Run `npm run codegen` and commit the output to `src/__generated__`. Simple, but pollutes diffs and requires discipline (forget to regenerate = stale types).

**Option B: Out-of-tree with symlinks**

Generate to `dist/` and symlink into `src/__generated__` as a dev-only step. Update `.gitignore` and `tsconfig.json`:


{

  "compilerOptions": {

    "paths": {

      "@generated/*": ["./dist/generated/*"]

    }

  }

}

**Option C: On-demand generation hooks**

Configure your LSP client to run the generator before starting the server:


lspconfig.tsserver.setup({

  on_new_config = function(config, root_dir)

    vim.fn.system('cd ' .. root_dir .. ' && npm run codegen:types')

  end,

})

For Goatfied's constrain step, we prefer Option B: our sandbox includes generated code in the readonly build cache, and our edit step never modifies it. This keeps the agent from accidentally suggesting changes to generated files that will get overwritten on the next build.

Real-world hybrid: per-service servers with shared packages

A practical middle ground for large TypeScript monorepos:

  • Start **one LSP server per service** (`services/api`, `services/admin`, etc.)
  • Configure each server to use project references to shared packages (`packages/common`, `packages/ui`)
  • Use `tsconfig.json` path mapping so imports resolve correctly:

{

  "compilerOptions": {

    "baseUrl": ".",

    "paths": {

      "@company/common": ["../../packages/common/src"],

      "@company/ui": ["../../packages/ui/src"]

    }

  },

  "references": [

    { "path": "../../packages/common" },

    { "path": "../../packages/ui" }

  ]

}

Each service gets fast, isolated type-checking. Cross-package navigation works via references. Memory usage scales with the number of services you're actively editing, not the total package count.

  • [Goatfied vs Cursor vs GitHub Copilot: a benchmark across 50 real PR tasks](/blog/goatfied-vs-cursor-vs-github-copilot-benchmark-50-pr-tasks)
  • [The Goatfied agent loop: how we ship code that actually compiles first try](/blog/goatfied-agent-loop-compiles-first-try)

Related posts

Lsp Routing Strategies For Monorepos Implementation Guide: practical systems guide | Goatfied Blog