Today Platform Web — Dev Docs
Toolchain

tsconfig project references

Three tsconfig files per project — root / app / node — extending a single shared base. Why each one exists and what goes in it.

Every project in the monorepo ships three tsconfig.*.json files:

FilePurpose
tsconfig.jsonSolution-style root. Has no compilerOptions; just references: to the two below.
tsconfig.app.jsonThe runtime code — app/, src/, MDX, mdx-components.tsx. DOM lib. JSX preserved.
tsconfig.node.jsonBuild / config files — *.config.{ts,js}, postcss.config.js. Node lib. No DOM.

This split is mandatory for tsgo --build to work cleanly. Without it, the Node-side config files (which import node:fs, node:path) get pulled into the DOM-lib compilation and trigger spurious errors.

The shared base

@todayai-labs/tsconfig holds everything that should be consistent across the repo:

{
  "compilerOptions": {
    "composite": true,
    "incremental": true,
    "noEmitOnError": true,
    "target": "ESNext",
    "lib": ["ESNext"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "allowJs": true,
    "checkJs": true,
    "skipLibCheck": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "useUnknownInCatchVariables": true
  }
}

The two notable choices:

  • composite: true — required for project references; emits .d.ts plus a .tsbuildinfo for incremental rebuilds
  • noUncheckedIndexedAccess: true — every arr[i] and obj[key] returns T | undefined. Aggressive but correct; protects against most off-by-one and missing-key bugs. Use arr.at(i) or destructure with defaults.

Solution root (tsconfig.json)

{
  "files": [],
  "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

This file is not a typecheck input — it has no compilerOptions, no include:. Its only job is telling tsgo --build "this project has two sub-projects". The root pnpm exec tsgo --build walks every project's tsconfig.json and recurses.

App tsconfig (tsconfig.app.json)

Where the actual app or library code is checked:

{
  "extends": "@todayai-labs/tsconfig/base.json",
  "compilerOptions": {
    "noEmit": true,
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "plugins": [{ "name": "next" }],
    "rootDir": ".",
    "outDir": "dist/ts/app",
    "tsBuildInfoFile": "dist/ts/app/.tsbuildinfo",
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["app", "lib", "src", ".source/**/*.ts"]
}

noEmit: true is what makes this a typecheck-only project — the actual runtime build is Next.js (Turbopack) or Vite, not tsgo. composite inherited from the base still emits .tsbuildinfo for incremental work.

paths is per-project so each app can have its own @/* alias.

Node tsconfig (tsconfig.node.json)

For build-side files:

{
  "extends": "@todayai-labs/tsconfig/base.json",
  "compilerOptions": {
    "noEmit": true,
    "lib": ["ESNext"],
    "types": ["node"],
    "outDir": "dist/ts/node",
    "tsBuildInfoFile": "dist/ts/node/.tsbuildinfo"
  },
  "include": ["*.config.ts", "*.config.js", "postcss.config.js"],
  "exclude": ["source.config.ts"]
}

types: ["node"] is explicit — the base config sets types: [] so the DOM-side tsconfig.app.json doesn't accidentally pick up @types/node and let server-only globals leak into client code.

For Next.js docs sites, source.config.ts (the Fumadocs MDX config) is excluded from the Node tsconfig and included in the app tsconfig instead — it's read at build time but the types it references live in the Fumadocs client surface.

Root tsconfig.json registry

The repo-root tsconfig.json lists every project for tsgo --build:

{
  "files": [],
  "references": [
    { "path": "./apps/web/tsconfig.json" },
    { "path": "./apps/admin/tsconfig.json" },
    { "path": "./apps/dev-docs/tsconfig.json" },
    { "path": "./design-system/ui/tsconfig.json" }
  ]
}

When you add a new project, you must add it here. Forgetting this is the single most common reason a new package's typecheck is mysteriously skipped on CI but passes locally with pnpm --filter scoped runs.

scripts/affected.ts does not discover this file's references — it walks pnpm-workspace.yaml instead. So Moon and pnpm find your new project automatically; only the typecheck graph needs the manual entry.

On this page