Configuring tsconfig
You have read the lockfile and the AGENTS.md. The next file at the repo root decides something the other two don’t touch: how strict the language is allowed to be, and which knobs you are quietly not allowed to turn. That file is tsconfig.json, and it is the single most-skimmed and least-understood file in a TypeScript project. Most people open it once, see thirty lines of flags they don’t recognize, copy it forward to the next project, and never look again.
That is a mistake, because tsconfig.json is the file where the bug classes the type-checker will catch — for the entire life of the project — are decided. Skim past it and you are letting a config you don’t understand decide how much TypeScript actually protects you.
Here is the frame that makes the file readable. 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. Both halves live in one file — the split is mental, not physical — but once you can look at any line and say “that’s strictness, I own it” or “that’s compatibility, the framework owns it,” the file stops being a wall and becomes two short lists.
And that gives you a rule of thumb that anchors everything below. 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.
The whole file at a glance
Section titled “The whole file at a glance”Before we split it, here is the entire config. Thirty lines, every flag the project ships with:
{ "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. The paths line is the project’s other lever. We’ll walk them in that order.
The strictness floor — the project owns this
Section titled “The strictness floor — the project owns this”These are the five flags worth understanding line by line, because these are the ones you carry to every project. Each one is a class of bug the type-checker is told to refuse to compile.
"strict": true,"noUncheckedIndexedAccess": true,"noFallthroughCasesInSwitch": true,"noImplicitOverride": true,"forceConsistentCasingInFileNames": truestrict is the umbrella. Switching it on enables eight checks at once — noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables, alwaysStrict. The two that earn their keep daily are 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). The hard truth: anything below strict is not really TypeScript — it’s JavaScript with a few hints. This course never operates below this floor.
"strict": true,"noUncheckedIndexedAccess": true,"noFallthroughCasesInSwitch": true,"noImplicitOverride": true,"forceConsistentCasingInFileNames": trueThis is the one you’ll feel most in everyday code. With it on, array[i] and record[key] return T | undefined instead of T, because the checker can’t prove the index is in range. The cost is real — you handle the undefined case at every index read. The payoff: “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-in-a-hundred missing row in production. The course pays the cost on purpose.
"strict": true,"noUncheckedIndexedAccess": true,"noFallthroughCasesInSwitch": true,"noImplicitOverride": true,"forceConsistentCasingInFileNames": trueA case that has code but no break, return, or throw is an error. It catches the ambiguous fallthrough — the switch arm where the next reader genuinely can’t tell whether you meant to fall through or forgot the break. Intentional fallthrough still works; you just write an empty case with no body.
"strict": true,"noUncheckedIndexedAccess": true,"noFallthroughCasesInSwitch": true,"noImplicitOverride": true,"forceConsistentCasingInFileNames": trueWhen a class method overrides a base-class method, you must write the override keyword. Free insurance for the rare class hierarchy — if you rename the base method and a subclass silently stops overriding anything, this turns that into an error instead of a method that quietly never runs. You’ll hit class hierarchies rarely in this stack, which is exactly why you want the compiler watching them.
"strict": true,"noUncheckedIndexedAccess": true,"noFallthroughCasesInSwitch": true,"noImplicitOverride": true,"forceConsistentCasingInFileNames": trueRefuses an import whose casing differs from the file’s real name on disk — the cheapest preventer in the whole file. macOS filesystems are case-insensitive, so import './Button' resolves to button.tsx locally and everything works — until CI runs on case-sensitive Linux, the import doesn’t resolve, and the build fails on a machine that isn’t yours. The classic “works on my machine.” This flag makes it fail on your machine first, where you can see it.
The flag the project leaves off
Section titled “The flag the project leaves off”There is one well-known strictness flag the project deliberately does not enable, and it’s worth knowing it exists so you understand the gap on purpose rather than by accident.
Path aliases — the project’s other lever
Section titled “Path aliases — the project’s other lever” "paths": { "@/*": ["./src/*"] }This line is the project’s other lever, and it answers two questions. First, why aliases at all? Refactor safety. With an alias, moving a file doesn’t break the import — @/lib/data resolves the same no matter how deeply nested the file importing it is. The alternative is a hundred imports like '../../../lib/data' whose ../ count breaks the instant you move either file. Second, why @/ specifically? It is the Next.js App Router convention. Every editor, every formatter, every coding agent recognizes it on sight, so there is no debate and no per-project bikeshedding — you write @/components/ui/button and the whole toolchain knows you mean src/components/ui/button.tsx.
Notice there is no baseUrl. With moduleResolution: "bundler" you don’t need one — the ./src/* target resolves relative to the location of tsconfig.json itself, so the project omits baseUrl entirely. If you have seen older configs that pair paths with baseUrl, that pairing is the pre-bundler way; you don’t need it here.
The compatibility surface — Next.js owns this
Section titled “The compatibility surface — Next.js owns this”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 at the type level: dom and dom.iterable so client components type-check against window, document, and DOM iterables, and esnext so the newest standard-library types (the latest Array, Object, Promise methods) are known to the checker.
"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" says emit modern ES import/export and leave them for the bundler. moduleResolution: "bundler" (TypeScript 5+) tells the checker to resolve imports the way a bundler does, which is how Turbopack actually finds your files — 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 — emit follows the source verbatim, so a type-only import can’t accidentally 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" tells TypeScript to compile JSX straight to React 19’s automatic runtime — no import React from 'react' at the top of every component. This is the modern mode; the older preserve (leave JSX for another tool) and react (the classic React.createElement with a manual import) are not what you want here.
"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 ever type-checks and never writes a single .js file — Turbopack does the emitting, tsc is purely the gate. incremental: true caches type-check results between runs so the second check is fast. skipLibCheck: true skips type-checking the .d.ts files inside your dependencies (you didn’t write them; checking them is slow and points at problems you can’t fix). 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 is an editor-level plugin — it sharpens the diagnostics your editor shows for Next.js-specific surfaces (the metadata API, route params, dynamic segments, the typed-routes validator) without changing what tsc does on the command line.
include and exclude
Section titled “include and exclude” "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 actually looks at: the generated next-env.d.ts (next section), 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 own type augmentations are in scope. exclude keeps node_modules out of the project graph — skipLibCheck already spares you its types, and this keeps the checker from trying to treat dependency source as your own.
next-env.d.ts — generated, committed, never edited
Section titled “next-env.d.ts — generated, committed, never edited”There is one more 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 — the /// <reference> directives pull in Next’s global types and its image types, and the import line pulls in the generated typed-routes declarations:
/// <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, which breaks CI’s typecheck. And you never edit it — Next.js owns its contents and will overwrite anything you put there. It is a generated artifact that happens to live in your repo. Leave it alone and commit it; that’s the whole contract.
This is the two-owner rule in its purest form. The compatibility flags above and this generated file are not personal preference — resist the urge to harden them past what the framework sets, because the framework’s correctness is built on them being exactly this. The strictness floor is the half you own and tune. Everything else, you read once and trust.
Run the typecheck gate
Section titled “Run the typecheck gate”There is no feature to build here and no pnpm test:lesson for this lesson — the project ships this config already correct. What you confirm is that the typecheck passes clean against it. The command is tsc with the same --noEmit flag the config sets, run on its own:
-
From the project root, run the type-checker with no emit:
Terminal window tsc --noEmit -
Success is silence. No output, exit code 0 — every file in
includetype-checks against the config you just read, and nothing was written to disk.
That same command is folded into the project’s verify script — biome ci . && tsc --noEmit && next build — so the typecheck runs as one stage of the full shippability gate, separate from the build process even though it shares the --noEmit flag. The complete pnpm verify gate (Biome’s lint and format check, then this typecheck, then the production build) is the subject of the next lesson; for now you have seen where the type-checking stage sits in it, and you can run that stage clean on its own.
External resources
Section titled “External resources”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.
The official per-flag reference — every option this lesson named, with examples and the version it landed in.
Matt Pocock's interactive guide: the few flags that matter, chosen by four yes/no questions about your project.
The compatibility-surface owner's own docs: next-env.d.ts, the TS plugin, typed routes, and the build-time type gate.
The deep dive on moduleResolution, including exactly what bundler models and why it pairs with module: esnext.