Most tsconfig.json files I see in code review were assembled in 2021 and never touched again. They worked, so nobody opened them. This year that stopped being true. TypeScript 6.0 shipped in March with a long list of deprecations, and TypeScript 7.0 shipped on July 8 as a native Go compiler that turns those deprecations into hard errors. baseUrl is gone. moduleResolution: "node" is gone. types no longer pulls in every @types package on disk. rootDir no longer guesses.

tsconfig recipes

If you upgraded and got a wall of TS5102 errors, this post is for you. If you’re starting a project and want a config that won’t need surgery next year, same.

Every recipe below is checked against the templates the tools themselves generate today: create-vite for Vite 8, create-next-app for Next.js 16, the Node.js docs for type stripping and the tsconfig/bases presets. I then tightened them where the defaults are loose. Copy the one that matches your stack, read the “why” under it, and skip the rest.

What changed in TypeScript 6 and 7

Before the recipes, the delta. If your config predates 2026, at least one of these rows applies to you.

Option TypeScript 5 TypeScript 6.0 TypeScript 7.0
strict false true by default true by default
module commonjs esnext by default esnext by default
types every @types/* in scope [] []
rootDir inferred from inputs folder containing tsconfig.json folder containing tsconfig.json
baseUrl works deprecated (TS5101) removed (TS5102)
moduleResolution: "node" works deprecated removed
module: "amd" / "umd" / "systemjs" works removed removed
target: "es5" works deprecated removed
esModuleInterop: false works deprecated removed
noUncheckedSideEffectImports false true true

Two of these bite almost everyone. The types change means process and Buffer stop resolving until you add "types": ["node"]. The rootDir change means a project with sources in src/ suddenly emits to dist/src/ unless you set "rootDir": "./src" yourself.

TypeScript 6.0 lets you postpone the cleanup with "ignoreDeprecations": "6.0". TypeScript 7.0 does not. I covered the 6.0 changes in detail in TypeScript 6.0 breaking changes explained; this post is about what to write instead.

One more thing about 7.0: it still ships as the typescript npm package with the same tsc binary, so installing it is just npm i -D typescript@7. What it doesn’t ship yet is a JavaScript API. Tools that call ts.createProgram() need the 7.1 release, which is in beta as I write this. Next.js handles this by shelling out to the tsc CLI, which I’ll come back to.

Two decisions that drive everything else

Nearly every option in a tsconfig follows from two questions.

Who loads your output? If Node runs the files, use "module": "nodenext". It mirrors Node’s own rules: relative imports need extensions, package.json "type" decides ESM versus CommonJS, and exports maps are honoured. If a bundler runs them, use "module": "esnext" with "moduleResolution": "bundler", or the shorthand "module": "preserve", which implies the bundler resolver. Bundlers accept extensionless imports and .ts extensions, and so does this mode.

Does tsc emit JavaScript? If a bundler or Node’s type stripping handles execution, set "noEmit": true and let tsc be a type checker. If tsc is your compiler, set rootDir and outDir and skip noEmit.

Everything else is strictness, which I treat as a shared base.

The strictness base

These options are the same in every recipe, so I’ll list them once with the reason they’re there.

json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "skipLibCheck": true
  }
}

strict is the default in TypeScript 6 and 7, but writing it keeps the file honest for anyone still on 5.x.

noUncheckedIndexedAccess makes arr[0] and record[key] include undefined. It is the single highest-value flag outside strict. Expect a few dozen errors the first time you turn it on in an older codebase, and expect most of them to be real.

verbatimModuleSyntax forces import type for type-only imports. Without it, a type-only import compiles to a runtime import that Node’s type stripper executes, and you get a crash that only happens in production. Vite’s template turns it on. Node’s docs require it.

moduleDetection: "force" treats every file as a module, which ends the “cannot redeclare block-scoped variable” error in files with no imports.

skipLibCheck skips type-checking .d.ts files in node_modules. It roughly halves check time on a mid-size app and you lose nothing you can act on.

I left out noUnusedLocals, noUnusedParameters and noImplicitReturns. ESLint reports those with better messages, and having two tools fight over the same rule is a chore. The @tsconfig/strictest preset turns them on if you want the maximal version.

Recipe 1: React with Vite 8

Vite 8 went stable on March 12, 2026 with Rolldown as its only bundler. Its react-ts template generates three files: a solution-style root that only holds references, an app config for src/ and a Node config for vite.config.ts. Keep that split. The browser code and the config file need different lib and types, and one file can’t express both.

tsconfig.json:

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

tsconfig.app.json:

json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "es2023",
    "lib": ["es2023", "dom"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "types": ["vite/client"],
    "jsx": "react-jsx",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "allowArbitraryExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "erasableSyntaxOnly": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "skipLibCheck": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"]
}

tsconfig.node.json:

json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "es2023",
    "lib": ["es2023"],
    "module": "nodenext",
    "types": ["node"],
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["vite.config.ts"]
}

Why these options. types: ["vite/client"] is what makes import.meta.env and import logo from './logo.svg' type-check. It used to arrive for free through the wildcard types default; now you have to name it. jsx: "react-jsx" uses the automatic runtime, so no import React at the top of every file. erasableSyntaxOnly bans enums, namespaces with values and constructor parameter properties. Vite’s template enables it so the same code also runs under Node’s type stripping, and verbatimModuleSyntax already implies isolatedModules, which covers the cross-file cases. lib: ["es2023", "dom"] is enough; since TypeScript 6.0, dom.iterable is folded into dom and the old entry is an empty file.

The alias trap. paths only tells the type checker about @/. Rolldown doesn’t read tsconfig. Mirror the alias in vite.config.ts or install vite-tsconfig-paths:

ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
})

Note there is no baseUrl. Path entries are relative to the tsconfig file now, which is why they start with ./src.

Check it. tsc -b walks both references. Put it in package.json as "typecheck": "tsc -b" and run it before vite build. If you’ve been fighting re-renders in the same app, the React hooks anti-patterns guide covers the other half of the problem.

Recipe 2: Next.js 16 App Router

Next.js writes its own tsconfig the first time you run next dev, and it checks a handful of keys on every start and puts them back if they’re wrong. Fight it and you lose. Start from what create-next-app generates, then tighten only the parts Next doesn’t own.

json
{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["dom", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "noEmit": true,
    "incremental": true,
    "isolatedModules": true,
    "allowJs": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "plugins": [{ "name": "next" }],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "skipLibCheck": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    "**/*.mts",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts"
  ],
  "exclude": ["node_modules"]
}

Why these options. plugins: [{ "name": "next" }] loads the editor plugin that flags useState inside a Server Component and validates route segment config. incremental writes a .tsbuildinfo so next build only rechecks changed files. The two .next/types globs pull in generated route types; PageProps, LayoutProps and RouteContext come from there, and so does typedRoutes if you enable it. allowJs stays because most Next projects have at least one .js config file. The generated template sets target to es2017; raising it to es2022 is safe because SWC handles downlevelling and tsc never emits here.

Things Next owns. next-env.d.ts is regenerated on every next dev and next build. Add it to .gitignore and never edit it. Put custom ambient types in a separate global.d.ts and list it under include. If your paths live in an old jsconfig.json, move them across and delete that file; Next only reads one.

TypeScript 7 and next build. Because 7.0 has no JavaScript compiler API, Next.js 16 runs the project-local tsc CLI for the build-time check. You get plain tsc diagnostics without Next’s route-aware code frames, and the check covers every file your include matches, test files included. If that’s too broad, point the build at a narrower file with typescript.tsconfigPath in next.config.ts:

ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  typescript: {
    tsconfigPath: process.env.NODE_ENV === 'production' ? 'tsconfig.build.json' : 'tsconfig.json',
  },
}

export default nextConfig

Check it. npx tsc --noEmit from the project root, then npx next build. The second one is the one that fails CI, so run it locally at least once.

Recipe 3: Node.js 24 and 26 server, no build step

Node 24.12 and 25.2 made type stripping stable, and Node 26 has it on by default with no warning. Node reads your .ts files, deletes the type annotations and runs what’s left. It never reads tsconfig.json. So the job of tsconfig here is to make tsc --noEmit reject anything Node can’t strip, and to keep your import paths the way Node needs them.

This is the config from the Node.js docs with the strictness base layered on:

json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": ["es2024"],
    "module": "nodenext",
    "types": ["node"],
    "rootDir": "./src",
    "outDir": "./dist",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

And in package.json:

json
{
  "type": "module",
  "scripts": {
    "dev": "node --watch src/server.ts",
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "start": "node dist/server.js"
  },
  "engines": { "node": ">=24" }
}

Why these options. module: "nodenext" makes tsc apply Node’s resolution rules, so an import that passes the checker will also load at runtime. rewriteRelativeImportExtensions is the piece people miss. Node requires the .ts extension in specifiers (import { db } from './db.ts'), and this flag lets tsc accept that and rewrite it to .js when you do compile. It also implies allowImportingTsExtensions, so the same config works whether you run the source or the build. erasableSyntaxOnly turns Node’s runtime limitation into a compile error with a file and line number: enums, namespace with runtime code, parameter properties and decorators all get TS1294 instead of a syntax error at startup. types: ["node"] restores process, Buffer and the other Node globals.

Why not paths. Node ignores them, so an alias that type-checks will throw ERR_MODULE_NOT_FOUND at runtime. Use subpath imports instead; they’re a package.json feature Node and tsc both understand:

json
{
  "imports": {
    "#db/*": "./src/db/*.ts"
  }
}

Check it. npm run typecheck && node --test covers both halves, because node:test also runs .ts files directly. The full breakdown of what type stripping does and doesn’t support is in Native TypeScript in Node.js 24, and the Node 26 specifics, including the removal of --experimental-transform-types, are in the Node.js 26 upgrade guide. If you still have ts-node and nodemon in devDependencies, this is what replaces them.

A note for NestJS and TypeORM users: those frameworks depend on decorators and parameter properties, which type stripping can’t handle. Keep compiling with tsc (drop erasableSyntaxOnly, add experimentalDecorators and emitDecoratorMetadata) and run dist/.

Recipe 4: Publishable npm library

Libraries have one extra customer: the type checker on someone else’s machine. That means declaration files, a package.json exports map that TypeScript can follow, and a check that the two agree.

json
{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022"],
    "module": "nodenext",
    "rootDir": "./src",
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "isolatedDeclarations": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}

package.json:

json
{
  "name": "@you/thing",
  "type": "module",
  "files": ["dist"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsc",
    "check:package": "publint && attw --pack ."
  }
}

Why these options. module: "nodenext" for a library, not bundler, because you don’t control what your consumers use. nodenext is the strictest resolver, so output that passes it works everywhere. declarationMap makes “go to definition” in a consumer’s editor land in your .ts source instead of the .d.ts. isolatedDeclarations requires explicit return types on every export. It’s an annoyance for about an hour and then it pays twice: tools like tsdown can generate .d.ts files without running the full checker, and your public API stops changing shape by accident when an internal type is refactored. exactOptionalPropertyTypes stops { retries?: number } from accepting { retries: undefined }, a distinction that matters when the value gets spread into a default.

The exports order matters. Conditions are matched top to bottom and the first match wins. If default sits above types, TypeScript takes default, finds a .js file, and reports your package as untyped. publint catches that; @arethetypeswrong/cli catches the subtler cases, like a .d.ts that only resolves under one of the three resolution modes. Run both against the packed tarball, since that’s what gets installed.

ESM only is fine now. Node 20.19 and 22.12 made require(esm) work without a flag, so the dual ESM/CommonJS dance is optional for anything targeting current Node. If you must ship both, use tsdown rather than hand-rolling two tsc passes; it writes matching .d.mts and .d.cts files, which tsc alone won’t. The ESM migration guide covers the package-level changes.

Recipe 5: pnpm monorepo with Turborepo

There are two ways to wire TypeScript across packages, and Turborepo’s docs are blunt about which one they prefer: “We don’t recommend using TypeScript Project References.” Their reasoning is that references add a second configuration layer and a second cache, and Turborepo already provides both. I agree for app-heavy monorepos. If your monorepo is mostly libraries built with tsc, references are still the right tool, and TypeScript 7’s --builders flag now runs them in parallel. I’ll show both.

Route A: shared config package, no references

packages/typescript-config/base.json:

json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleDetection": "force",
    "verbatimModuleSyntax": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "declaration": true,
    "declarationMap": true,
    "skipLibCheck": true
  }
}

packages/typescript-config/react-library.json extends it and swaps in "module": "esnext", "moduleResolution": "bundler", "jsx": "react-jsx" and "lib": ["es2022", "dom"]. A nextjs.json does the same with "plugins": [{ "name": "next" }] and "noEmit": true.

A consuming package, packages/ui/tsconfig.json:

json
{
  "extends": "@repo/typescript-config/react-library.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "types": ["react"]
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

The package depends on the config with "@repo/typescript-config": "workspace:*", and its exports point at source so apps consume it without a build:

json
{
  "exports": {
    "./*": "./src/*.tsx"
  }
}

Each package has its own tsconfig.json and its own "check-types": "tsc --noEmit" script. No root tsconfig; a change there would invalidate every cached task at once. turbo.json fans the checks out in parallel while still ordering them by dependency:

json
{
  "tasks": {
    "topo": { "dependsOn": ["^topo"] },
    "check-types": { "dependsOn": ["topo"] }
  }
}

turbo check-types then runs every package’s tsc --noEmit concurrently, with cache hits for anything unchanged.

Route B: project references for tsc-built packages

Add "composite": true to the base and list dependencies under references in each package:

json
{
  "extends": "@repo/typescript-config/base.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "references": [{ "path": "../core" }],
  "include": ["src"]
}

composite implies declaration and incremental, and tsc -b uses the emitted .tsbuildinfo to skip packages whose inputs haven’t changed. On TypeScript 7, tsc -b --builders 4 builds four independent packages at a time. The default --checkers 4 already parallelises type checking inside each one, so that combination can run up to 16 checkers at once. The dependency graph still bounds the order, so builder count never changes the result.

Whichever route you pick, paths aliases across packages are a smell. Use workspace package names and exports instead; every tool in the chain understands those, and nothing has to be mirrored.

Type checks in CI

A config that only runs on laptops is a suggestion. This GitHub Actions job runs the check on every push and pull request and caches the pnpm store between runs:

yaml
name: typecheck
on: [push, pull_request]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec tsc -b   # single project without references: tsc --noEmit
      # monorepo with Turborepo:
      # - run: pnpm exec turbo check-types

Pin the Node major in the job to the one in your engines field and your .nvmrc so the three never drift. Node 24 is the current LTS and is supported until April 2028; Node 26 became LTS in October 2026, so add it to a matrix when you’re ready.

When a check is slow, run tsc --extendedDiagnostics once. It prints how many files were parsed and how long checking took. The usual culprit is an include glob that swallows node_modules or a types entry that drags in a huge global package.

Errors you’ll hit after upgrading, and the fix

Error What it means Fix
TS5102 Option 'baseUrl' has been removed TypeScript 7 Delete baseUrl; rewrite paths values relative to the tsconfig, for example "@/*": ["./src/*"]
TS5101 Option 'baseUrl' is deprecated TypeScript 6 Same fix, or "ignoreDeprecations": "6.0" to defer it one major
TS5102 Option 'moduleResolution=node' has been removed TypeScript 7 nodenext for Node, bundler for Vite, Next or Bun
TS2304 Cannot find name 'process' types defaults to [] "types": ["node"] after npm i -D @types/node
TS6059 File is not under 'rootDir' rootDir no longer inferred; something in include sits outside it Set "rootDir": "./src" and make include match, or move the stray file
Output lands in dist/src/ rootDir defaulted to the tsconfig folder "rootDir": "./src"
TS2835 Relative import paths need explicit file extensions module: "nodenext" Write ./db.ts (with rewriteRelativeImportExtensions) or ./db.js
TS5097 An import path can only end with '.ts' when 'allowImportingTsExtensions' is enabled .ts specifier without the flag Add rewriteRelativeImportExtensions, or allowImportingTsExtensions if noEmit
TS1294 This syntax is not allowed when 'erasableSyntaxOnly' is enabled Enum, namespace or parameter property Replace with a const object, a plain module or an explicit field
TS1484 'X' is a type and must be imported using a type-only import verbatimModuleSyntax import type { X }
TS5055 Cannot write file ... would overwrite input file outDir is inside include Add dist to exclude or narrow include to src
Failed to resolve import "@/..." from Vite, but tsc passes Alias only in tsconfig Mirror it in vite.config.ts or add vite-tsconfig-paths
ERR_MODULE_NOT_FOUND for @/... under Node Node ignores paths Use package.json imports with a # prefix

Two debugging commands worth memorising. tsc --showConfig prints the fully resolved config after every extends, so you can see which file actually set module. tsc --listFiles prints every file the program includes, which is how you find out why a *.test.ts file is being checked by the build config.

Frequently Asked Questions

Should I set module to nodenext, esnext or preserve? nodenext when Node loads the output directly, including libraries. esnext with moduleResolution: "bundler" when Vite, Next.js or another bundler loads it. preserve is a shorthand for that second pair and also keeps require() calls as written; it’s fine, but the two-key form is what the framework templates use, so I stick with it.

Can I use @tsconfig/bases instead of writing all this? Yes, and it’s a good default for Node services. "extends": ["@tsconfig/node24/tsconfig.json", "@tsconfig/strictest/tsconfig.json"] gets you most of Recipe 3. Check the result with tsc --showConfig, because node24 still sets moduleResolution: "node16" and you may want nodenext.

Is target still relevant when I’m not emitting? It decides which lib is loaded by default and whether tsc complains about syntax newer than the target. With noEmit, pick the newest version your runtime supports and move on. TypeScript 6 and 7 default to the latest stable spec year, which is es2025 today and es2026 once 7.1 ships.

Do I still need esModuleInterop? It can no longer be turned off in TypeScript 6 and 7, so the key is redundant. Leave it out of new configs. Leaving it in doesn’t hurt.

Why does tsc still run on TypeScript 7 when my editor shows a different version? VS Code bundles its own TypeScript. Run “TypeScript: Select TypeScript Version” from the command palette and pick “Use Workspace Version” so the editor and CI agree.