Skip to content
Chapter 28Lesson 4

Reading the project tsconfig.json

You have read the lockfile and the AGENTS.md. The next file at the repo root, tsconfig.json, decides something the other two don’t: how strict the language is allowed to be, and which knobs you are quietly not allowed to turn. Most people skim it once, copy it forward, and never look again — which is a mistake, because this is the file that sets the bug classes the type-checker will catch for the entire life of the project.

The key is that tsconfig.json has two owners. The project owns the strictness floor: the flags that decide which classes of bugs the type-checker catches before code ships. Next.js owns the compatibility surface: the flags that make TypeScript, the bundler, and the runtime agree on what a module is. The split is mental, not physical — both halves live in one file — but once you can tag any line as “strictness, I own it” or “compatibility, the framework owns it,” the file becomes two short lists.

That gives you a rule of thumb. Tempted to edit a compatibility flag? You’re probably wrong. Tempted to edit a strictness flag? You’re probably right. The framework’s correctness depends on the compatibility half; the strictness half is the lever you reach for.

Here is the entire file before we split it in two:

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

The first five compiler options are the strictness floor. Everything from target down to the next plugin is the compatibility surface, and the paths line is the project’s other lever. We’ll walk them in that order.

These five flags are worth reading line by line, because you carry them to every project. Each names a class of bug the type-checker is told to refuse to compile.

"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true

strict is the umbrella: it turns on eight checks at once — noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables, alwaysStrict. Two earn their keep daily: noImplicitAny (a value with no inferable type is an error, not a silent any) and strictNullChecks (null and undefined are their own types you must handle, not values hiding inside every other type).

"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true

array[i] and record[key] now return T | undefined instead of T, because the checker can’t prove the index is in range. You handle the undefined at every index read, and in return “read past the end of the array” and “look up a key that isn’t there” become compile errors instead of an undefined that sails downstream and explodes on the one missing row in production.

"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true

A case with code but no break, return, or throw is an error. It catches the fallthrough where the next reader can’t tell whether you meant to fall through or forgot the break. Intentional fallthrough still works: write an empty case with no body.

"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true

A class method that overrides a base-class method must carry the override keyword. If you later rename the base method, a subclass that silently stops overriding anything becomes an error instead of a method that quietly never runs. You hit class hierarchies rarely in this stack, which is why you want the compiler watching them.

"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true

Refuses an import whose casing differs from the file’s real name on disk. macOS filesystems are case-insensitive, so import './Button' resolves to button.tsx locally and works — until CI runs on case-sensitive Linux, the import fails to resolve, and the build breaks on a machine that isn’t yours. This flag makes it fail on your machine first, where you can see it.

1 / 1
tsconfig.json
"paths": { "@/*": ["./src/*"] }

Why use an alias at all? Refactor safety. @/lib/data resolves the same no matter where the importing file lives, so moving a file never breaks the import. The alternative is brittle relative paths like '../../../lib/data', whose ../ count breaks the moment either file moves. Why @/ specifically? It is the Next.js App Router convention, so every editor, formatter, and coding agent reads @/components/ui/button as src/components/ui/button.tsx with no per-project debate.

Notice there is no baseUrl. Under moduleResolution: "bundler" the ./src/* target resolves relative to tsconfig.json itself, so none is needed.

Everything from here down is the second owner. These flags make TypeScript, the bundler, and the runtime agree on what a module is — what JS features compile to, how import/export are interpreted, how files are found. The point of this half is not to master each flag the way you mastered the strictness floor. The point is the opposite: read it, understand roughly what each group agrees on, and don’t touch it. Next.js sets these because its own correctness depends on them.

"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"noEmit": true,
"incremental": true,
"skipLibCheck": true,
"allowJs": false,
"plugins": [{ "name": "next" }]

target: "ES2022" sets which JS features TypeScript assumes the runtime supports — broadly the Next.js 16 floor. lib declares which built-in types exist for the checker: dom and dom.iterable so client components type-check against window, document, and DOM iterables, and esnext so the newest standard-library types are known.

"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"noEmit": true,
"incremental": true,
"skipLibCheck": true,
"allowJs": false,
"plugins": [{ "name": "next" }]

module: "esnext" emits modern ES import/export and leaves them for the bundler. moduleResolution: "bundler" resolves imports the way Turbopack does, so what type-checks and what builds agree.

"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"noEmit": true,
"incremental": true,
"skipLibCheck": true,
"allowJs": false,
"plugins": [{ "name": "next" }]

These four keep the checker honest about the fact that Turbopack compiles files one at a time, with no cross-file type information. verbatimModuleSyntax forces import type on any import used only as a type, so a type-only import can’t drag a module’s runtime side effects onto the client. isolatedModules requires every file to transpile in isolation, banning const enum and untyped barrel re-exports; Turbopack requires it. esModuleInterop smooths the CommonJS ↔ES seam so import x from 'some-cjs-pkg' works. resolveJsonModule lets you import data from './x.json' typed.

"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"noEmit": true,
"incremental": true,
"skipLibCheck": true,
"allowJs": false,
"plugins": [{ "name": "next" }]

jsx: "react-jsx" compiles JSX straight to React 19’s automatic runtime — no import React from 'react' at the top of every component.

"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"noEmit": true,
"incremental": true,
"skipLibCheck": true,
"allowJs": false,
"plugins": [{ "name": "next" }]

noEmit: true means tsc only type-checks and never writes a .js file — Turbopack emits, tsc is purely the gate. incremental: true caches type-check results so the next check is fast. skipLibCheck: true skips type-checking the .d.ts files inside your dependencies — you didn’t write them and can’t fix them. allowJs: false forbids .js/.jsx sources; this is a TypeScript-only project.

"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"noEmit": true,
"incremental": true,
"skipLibCheck": true,
"allowJs": false,
"plugins": [{ "name": "next" }]

plugins: [{ "name": "next" }] loads the Next.js TypeScript plugin. It sharpens your editor’s diagnostics for Next.js-specific surfaces like the metadata API, route params, and typed routes, without changing what tsc does on the command line.

1 / 1
tsconfig.json
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]

include is the set of files the checker looks at: the generated next-env.d.ts, every .ts and .tsx you write, and two .next/... globs that pull in Next.js’s auto-generated typed-route definitions so the framework’s type augmentations are in scope. exclude keeps node_modules out of the project graph, so the checker never treats dependency source as your own.

next-env.d.ts — generated, committed, never edited

Section titled “next-env.d.ts — generated, committed, never edited”

One file in the include list you did not write and never will: Next.js generates next-env.d.ts on the first next dev or next build. It wires the framework’s ambient types into the project, pulling in Next’s global and image types and the generated typed-routes declarations:

next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

Two rules, both in that header. You commit it: the build expects it to exist, and a fresh clone that hasn’t run next dev yet won’t have it, breaking CI’s typecheck. You never edit it: Next.js owns its contents and overwrites anything you put there.

The compatibility flags and this generated file are the framework’s half of the config, built on these exact values; the strictness floor is the half you own and tune.

This lesson ships its config already correct, so there is no feature to build and no pnpm test:lesson. You only confirm the typecheck passes against it, using tsc with the same --noEmit flag the config sets:

  1. From the project root, run the type-checker with no emit:

    Terminal window
    tsc --noEmit
  2. Success is silence. No output, exit code 0 — every file in include type-checks against the config you just read, and nothing was written to disk.

This same command is one stage of the project’s verify script (biome ci . && tsc --noEmit && next build), the full shippability gate covered in the next lesson.

When you want to look up a single flag, or settle a “should I turn this on?” question, these are the references worth keeping open.