TypeScript Config: Boilerplate Best Practices 2026
TL;DR
- Enable
strict: true— it's 2026, there's no excuse for running without strict mode. - Next.js generates a sensible
tsconfig.jsonbut you should understand every option you're using. - Path aliases (
@/components,@/lib) are essential for avoiding../../../hell. - Use project references for monorepos—they enable incremental compilation across packages.
- TypeScript 5.x (current) has significant performance improvements—keep it updated.
- The most important options you're probably not using:
noUncheckedIndexedAccess,exactOptionalPropertyTypes.
Key Takeaways
strict: trueenables 8 strict checks simultaneously—enable it from the start, migration later is painful.moduleResolution: "bundler"is the correct setting for Vite and Next.js 13+ projects.isolatedModules: trueis required by many transpilers (esbuild, SWC) and catches module-level issues.- TypeScript path aliases require matching config in
next.config.js(or Vite config)—sync these manually. noUncheckedIndexedAccessadds| undefinedto array index access—catches 40% of real runtime errors that strict mode misses.- Declaration files for environment variables (
process.env) prevent accessing undefined env vars.
Why TypeScript Config Is the Foundation of DX
The tsconfig.json is easy to overlook. It ships with sensible defaults, most developers leave it mostly untouched, and the application works fine either way — until it does not. The settings in that file have outsized and often underappreciated impact on developer experience throughout the life of a project.
The most direct impact is from strict mode. Enabling strict: true does not just change how TypeScript type-checks your code; it changes what categories of bugs are possible to commit. Null pointer exceptions — one of the most common runtime errors in JavaScript — become compile-time errors in strict mode because strictNullChecks forces you to handle the case where a value might be null or undefined. Type mismatches that would have been silently coerced at runtime become errors that surface when you write the code rather than when a user encounters them. A team that has run with strict mode from day one builds habits around defensive typing that carry over even when writing code that is not directly type-checked.
The cumulative effect of loose TypeScript configs is severe and not always visible until the codebase is large. The problem is that any types propagate. A function that returns any means its callers receive any, and their callers receive any, and before long a significant fraction of your type system is effectively untyped. You have TypeScript's syntax but not its guarantees. Similarly, optional chaining errors (Cannot read property 'x' of undefined) surface at runtime instead of compile time when your config allows implicit undefined in places that strict mode would flag. These are bugs that a tighter config would have caught at the point they were introduced, but with a loose config they escape into production.
Path aliases have a different kind of impact: they affect every import in every file in your project. The difference between import { formatDate } from "../../../../../../../lib/utils" and import { formatDate } from "@/lib/utils" sounds cosmetic but is not. Deeply nested relative imports are fragile — they break silently when you reorganize your directory structure — and they make the import's origin non-obvious when reading the file. Path aliases create a stable, logical namespace for your codebase that everyone on the team uses consistently. This matters more as the team grows and as the codebase ages.
The boilerplate's tsconfig is a statement about quality standards. A boilerplate that ships with strict: false (or with strict mode technically on but bypassed through @ts-ignore throughout the codebase) signals that type safety is aspirational, not operational. A boilerplate that ships with strict: true, noUncheckedIndexedAccess: true, and a validated environment variable setup says something different about what the team expects from its code.
Module Resolution in 2026: Bundler Mode
TypeScript's module resolution settings have accumulated technical debt across a decade of JavaScript ecosystem evolution, and the current state is confusing for developers who have not followed the evolution closely. As of TypeScript 5.0, moduleResolution: "bundler" is the correct default for projects that use a bundler — which includes Next.js, Vite, and essentially all modern frontend applications.
To understand why, it helps to know what the older options assumed. moduleResolution: "node" mimics the way Node.js resolves modules: looking for index.js, respecting the main field in package.json, requiring .js extensions on explicit imports. This behavior made sense when Node.js was the runtime, but bundled applications do not use Node's resolution algorithm — they use the bundler's, which is significantly more capable.
moduleResolution: "node16" and "nodenext" were introduced for Node.js ESM support and require explicit file extensions on imports (.js on TypeScript files that compile to JavaScript). This is technically correct for Node.js ESM, but it is annoying and unnecessary for bundled applications where the bundler handles extension resolution.
moduleResolution: "bundler" is designed specifically for bundled applications. It enables resolution of the exports field in package.json, which many modern packages use to provide different entry points for different environments (ESM vs CJS, browser vs Node.js, full bundle vs tree-shakeable). Without exports field support, some packages appear to import correctly at the TypeScript level but resolve to the wrong file at runtime. It does not require explicit file extensions. It handles import conditions like "import" and "require", which is how packages like next-auth ship different code for server and client contexts.
For Next.js specifically, bundler mode is the documented recommendation. The create-next-app generated config uses it. You should use it.
Path Aliases in Depth
Path aliases in TypeScript are configured in the paths compiler option. The convention Next.js ships with is @/* mapping to ./src/*, which gives you a single root alias:
"paths": {
"@/*": ["./src/*"]
}
This single alias covers everything, which is sufficient for most projects. Some teams prefer more granular aliases that make the project structure explicit at every import site:
"paths": {
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/server/*": ["./src/server/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/types/*": ["./src/types/*"]
}
The granular approach has the advantage of making the logical separation between client-side and server-side code visible in imports: @/server/db is clearly server code, @/components/ui/button is clearly a UI component. This can help enforce architectural boundaries — if you have a rule that server code should not be imported in client components, the @/server/* prefix makes violations visually obvious in code review.
Aliases matter in a team context because they create a shared vocabulary. When every developer imports utilities from @/lib/utils, there is no ambiguity about where to find that file and no variation in import style. When different developers use different relative path depths to the same file, the codebase becomes harder to navigate.
Type-Safe Environment Variables
The default state of environment variables in a Next.js application is that process.env is typed as Record<string, string | undefined>. This means every environment variable access is potentially undefined, and TypeScript will correctly flag any code that passes a process.env.SOME_VAR to a function expecting a string. The typical response to this friction is to add non-null assertions (process.env.SOME_VAR!) or cast to string (process.env.SOME_VAR as string), both of which defeat the purpose of TypeScript's type safety — they tell the type system to trust you instead of verifying correctness.
The right solution is to validate environment variables at startup and export a typed object that callers use instead of process.env directly.
The @t3-oss/env-nextjs package, developed as part of the T3 Stack, is the established solution. It uses Zod schemas to define the expected shape and constraints of each environment variable, validates all variables when the application starts (failing loudly if required variables are missing or malformed), and exports a typed env object where every variable has a precise type.
The schema definition forces you to think explicitly about what each variable should contain:
// src/env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
RESEND_API_KEY: z.string().startsWith("re_"),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_"),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
RESEND_API_KEY: process.env.RESEND_API_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
},
});
The server and client separation is important and Next.js-specific. In Next.js, environment variables are only available in the browser if they have the NEXT_PUBLIC_ prefix — Next.js replaces these at build time through static analysis. Variables without this prefix are never included in client bundles. The @t3-oss/env-nextjs package enforces this at the schema level: server variables are not accessible in client-side code, and client variables must use the NEXT_PUBLIC_ prefix. This prevents the common security mistake of accidentally exposing server secrets (like your database connection string or Stripe secret key) in the client bundle by using them in a component file that happens to be rendered on the client.
The startup validation provides a much better developer experience than discovering a missing environment variable at runtime. When you start the application with a required variable missing, it throws immediately with a clear error listing exactly which variables are missing or invalid. This surfaces deployment configuration problems before any user encounters them.
TypeScript in Monorepos
Monorepo TypeScript configuration is the most complex scenario, and it is worth understanding even if your current project is a single application — many projects that start as monoliths extract shared packages as they grow, and retrofitting proper TypeScript project references is significantly harder than starting with them.
The core challenge in a TypeScript monorepo is that packages have dependencies on each other. Your web application imports types from your shared @repo/types package, your API routes import validation logic from your @repo/validators package, and your database package exports types that both use. TypeScript needs to understand these cross-package dependencies to type-check and compile correctly.
TypeScript project references are the solution. A project reference tells TypeScript "this package depends on that package, and that package has already been compiled to declaration files." TypeScript can then perform incremental compilation: when you change a source file, TypeScript only recompiles the packages that depend on what changed, rather than recompiling everything.
The configuration for project references requires composite: true in each package's tsconfig, which enables the features required for references to work correctly:
// packages/ui/tsconfig.json
{
"extends": "@repo/config/tsconfig/react-library.json",
"compilerOptions": {
"composite": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
// apps/web/tsconfig.json
{
"extends": "@repo/config/tsconfig/nextjs.json",
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
},
"references": [
{ "path": "../../packages/ui" },
{ "path": "../../packages/validators" }
],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}
The base config approach — a packages/config/tsconfig/ directory with base.json, nextjs.json, and react-library.json configurations that all other packages extend — ensures consistency without duplication. Strict mode is enabled once in the base config, and all packages inherit it.
The practical question is when to invest in monorepo TypeScript setup versus when it is overkill. A single Next.js application with no shared packages does not need project references — they add complexity without benefit. The threshold where project references become valuable is when you have two or more packages sharing code. If your SaaS application and your marketing site share a design system, or if your API and your web app share validation schemas and types, project references will meaningfully improve your TypeScript compile times and cross-package type safety.
The T3 Turbo monorepo starter (create-t3-turbo) is the reference implementation most worth studying for TypeScript monorepo configuration. It demonstrates the complete setup: shared tsconfig packages, project references, Turborepo task configuration for type-checking, and the specific way Next.js apps interact with the monorepo package graph.
The Baseline Next.js tsconfig
Next.js create-next-app generates a solid starting point:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
This is a good baseline. Let's go through every setting that matters.
Compiler Options Deep Dive
strict: true
Enabling strict enables 8 individual flags simultaneously:
| Flag | What it does |
|---|---|
strictNullChecks | null and undefined are not assignable to other types |
strictFunctionTypes | Stricter function parameter type checking |
strictBindCallApply | Better type inference for bind, call, apply |
strictPropertyInitialization | Class properties must be initialized in constructor |
noImplicitAny | Error on implicit any type |
noImplicitThis | Error on this with implicit any |
alwaysStrict | Emits "use strict" in all files |
useUnknownInCatchVariables | catch variables are unknown instead of any |
All 8 are essential. Don't disable individual flags to avoid fixing type errors—fix the errors.
noUncheckedIndexedAccess (Enable This)
Not included in strict: true but should be:
// Without noUncheckedIndexedAccess:
const items = ["a", "b", "c"];
const first = items[0]; // type: string (but could be undefined if empty array)
// With noUncheckedIndexedAccess:
const first = items[0]; // type: string | undefined (correct!)
if (first !== undefined) {
console.log(first.toUpperCase()); // now safe
}
This catches real bugs. Add it to your tsconfig:
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
exactOptionalPropertyTypes (Consider Enabling)
Distinguishes between a property being absent and a property being explicitly undefined:
interface Config {
timeout?: number; // Without exact: can be undefined | number
}
// With exactOptionalPropertyTypes:
const config: Config = { timeout: undefined }; // Error! Absent != undefined
const config2: Config = {}; // OK - property is absent
const config3: Config = { timeout: 3000 }; // OK
This is stricter and catches subtle bugs, but can require significant code changes to enable on an existing codebase.
moduleResolution: "bundler"
The correct setting for projects using modern bundlers (Vite, esbuild, Next.js/SWC):
{ "moduleResolution": "bundler" }
This enables:
- Resolution of
exportsfield inpackage.json(needed for many modern packages) - Import conditions like
"import"and"require" - Does NOT require file extensions on imports (the bundler handles resolution)
Note: "node16" or "nodenext" is the right setting for Node.js libraries that run without a bundler.
target: "ES2022" or higher
Next.js defaults to ES2017 for broadest compatibility. If you control your deployment environment:
{ "target": "ES2022" }
ES2022 adds top-level await, Array.at(), Object.hasOwn(), and class fields without polyfills.
isolatedModules: true
Required for SWC and esbuild transpilation (used by Next.js and Vite):
{ "isolatedModules": true }
This requires every file to be independently transpilable—no relying on globally-visible const enum or re-exported type-only imports. Set this early; fixing violations later is tedious.
noImplicitReturns: true
Every code path in a function returning a value must have a return statement:
// Error with noImplicitReturns:
function getPlan(level: number): string {
if (level > 3) {
return "enterprise";
}
// Missing return! TypeScript will error.
}
Path Aliases
Essential for large projects. No more ../../../../lib/utils.
Next.js Configuration
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/types/*": ["./src/types/*"],
"@/server/*": ["./src/server/*"]
}
}
}
Next.js reads paths from tsconfig and configures webpack/SWC automatically. No additional configuration needed.
Usage:
// Instead of:
import { formatDate } from "../../../../../../../lib/utils";
// Use:
import { formatDate } from "@/lib/utils";
Environment Variable Types
TypeScript doesn't know about your process.env variables by default. Fix this:
// src/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
NEXTAUTH_SECRET: string;
NEXTAUTH_URL: string;
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_APP_URL: string;
NODE_ENV: "development" | "production" | "test";
}
}
Or use the more robust @t3-oss/env-nextjs:
npm install @t3-oss/env-nextjs zod
// src/env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
});
This validates env vars at startup and provides type-safe access:
import { env } from "@/env";
// env.DATABASE_URL is string, env.STRIPE_SECRET_KEY is string
// Build fails if required env vars are missing
Monorepo Configuration
For Turborepo or other monorepo setups, use shared base configs:
// packages/config/tsconfig/base.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
// apps/web/tsconfig.json
{
"extends": "@repo/config/tsconfig/nextjs.json",
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
// packages/ui/tsconfig.json
{
"extends": "@repo/config/tsconfig/react-library.json",
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
Type-Check Script
Add an explicit type-check script that runs without building:
{
"scripts": {
"type-check": "tsc --noEmit",
"type-check:watch": "tsc --noEmit --watch"
}
}
Run this in CI before the build step—it catches type errors faster than a full build.
Common tsconfig Mistakes
skipLibCheck: true: Skips type checking of declaration files in node_modules. This is correct—you should not be fixing types in dependencies. Keep this.
any casts: Using as any to silence TypeScript is a code smell. Use proper types or explicit type narrowing.
ts-ignore vs ts-expect-error: Use @ts-expect-error instead of @ts-ignore. The former fails if there's no actual error (so you clean it up when fixed), the latter silently ignores errors forever.
Missing "moduleResolution": "bundler": Projects using older "node" or "node16" resolution may fail to resolve modern ESM packages correctly.
For how this TypeScript setup integrates with your CI pipeline, see CI/CD pipeline for SaaS boilerplates. For how auth libraries leverage TypeScript's type system, see authentication setup in Next.js boilerplates. For the boilerplates that ship with strict TypeScript configured, see Next.js starter kit guide.
Methodology
TypeScript configuration analysis based on official TypeScript documentation (v5.x), Next.js tsconfig documentation, and audit of tsconfig.json files in top-starred Next.js repositories on GitHub. Performance claims based on TypeScript team blog posts and community benchmarks.