Switch Language
Toggle Theme

Advanced Next.js TypeScript Configuration: tsconfig Optimization and Type Safety Practices

3 AM. I stared at that glaring red line in the test report: “Production Error: Cannot read property ‘id’ of undefined.” Users reported the profile page went completely blank. Traced the code—turns out the route was /users/profile instead of /user/profile. One extra ‘s’. TypeScript gave no warning. IDE flagged nothing. It just sailed straight into production.

You might think, “That’s just a rookie mistake.” Sure. But honestly, these “rookie mistakes” happen so often in my projects it’s exhausting. Route typos, environment variable name errors, function parameters all typed as any… TypeScript claims to be “type-safe,” yet using it feels no different from JavaScript.

Later I realized—it wasn’t TypeScript’s fault. My configuration was terrible. The tsconfig.json had a bunch of options, and I didn’t know which to enable or disable. Online tutorials contradicted each other—some said strict mode adds development overhead, others said not using strict mode defeats the purpose. After nearly a year of Next.js + TypeScript, my project was still drowning in any types.

This article shares what I learned from a year of stumbling. From optimizing tsconfig to implementing type-safe routing and defining environment variable types—step by step transforming TypeScript from a “stumbling block” into a “guardian.” No fancy theories, just practical stuff you can actually use.

Optimizing tsconfig - Building the Foundation

Understanding What strict Mode Really Means

Many people (including past me) thought strict: true was just a switch—flip it and TypeScript gets strict. Not quite.

Open the TypeScript docs and you’ll find strict is actually a shortcut for 7 compiler options:

{
  "compilerOptions": {
    "strict": true,
    // Equivalent to all 7 of these set to true
    "strictNullChecks": true,        // Strict null checking
    "strictFunctionTypes": true,     // Strict function type checking
    "strictBindCallApply": true,     // Strict bind/call/apply checking
    "strictPropertyInitialization": true, // Strict property initialization
    "noImplicitAny": true,          // No implicit any
    "noImplicitThis": true,         // No implicit this
    "alwaysStrict": true            // Always parse in strict mode
  }
}

The first three are the most useful. Let’s start with strictNullChecks—when enabled, TypeScript treats null and undefined as distinct types, not “valid values for any type.”

For example, fetching user info from a database:

// Without strictNullChecks
const user = await db.user.findOne({ id: userId })
console.log(user.name) // TypeScript doesn't complain, but user could be null

// With it enabled
const user = await db.user.findOne({ id: userId })
console.log(user.name) // ❌ TypeScript error: Object is possibly null

// Must write it like this
if (user) {
  console.log(user.name) // ✅ Passes
}

When I first enabled this option in an older project, my IDE instantly exploded with 200+ red squiggly lines. I panicked and almost reverted it. But taking a closer look, these “errors” were actually potential bugs—those spots without null checks would really blow up in production.

noImplicitAny is also crucial. It prevents function parameters or variables from “implicitly” becoming any types:

// Without noImplicitAny
function handleData(data) {  // data automatically becomes any
  return data.value  // No errors for any operation
}

// With it enabled
function handleData(data) {  // ❌ Error: Parameter implicitly has any type
  return data.value
}

// Must explicitly annotate
function handleData(data: { value: string }) {  // ✅
  return data.value
}

To be honest, it feels cumbersome at first. Used to just writing functions on the fly, now you have to define types. But after using it a while, you’ll notice IDE suggestions get smarter—the moment you type data., all properties pop up. No more digging through docs.

Next.js-Specific TypeScript Configuration

Next.js projects have some special tsconfig.json settings. Here’s my current best practice version:

{
  "compilerOptions": {
    // Base configuration
    "target": "ES2020",
    "lib": ["dom", "dom.iterable", "esnext"],
    "jsx": "preserve",
    "module": "esnext",
    "moduleResolution": "bundler",

    // Next.js essentials
    "allowJs": true,
    "noEmit": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "resolveJsonModule": true,

    // Strict mode (core)
    "strict": true,
    "skipLibCheck": true,

    // Performance optimization
    "incremental": true,

    // Next.js plugin
    "plugins": [
      {
        "name": "next"
      }
    ],

    // Path aliases
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./src/components/*"],
      "@/lib/*": ["./src/lib/*"],
      "@/styles/*": ["./src/styles/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts"
  ],
  "exclude": ["node_modules"]
}

Let me highlight a few easily overlooked options:

1. incremental: Incremental Compilation

60%
Build Speed Improvement

This option can dramatically speed up builds in large projects. When enabled, TypeScript caches previous compilation info and only recompiles changed files next time. I tested it on a 300+ component project—build time dropped from 45 seconds to about 18 seconds. Pretty noticeable.

2. paths: Path Aliases

Import paths used to look like this:

import Button from '../../../components/ui/Button'
import { formatDate } from '../../../../lib/utils'

Can’t count all those ..s. Adjust folder structure slightly and everything breaks.

After configuring aliases:

import Button from '@/components/ui/Button'
import { formatDate } from '@/lib/utils'

Much cleaner. Plus TypeScript correctly infers types and IDE’s jump-to-definition works.

3. plugins: Next.js Plugin

This "plugins": [{ "name": "next" }] looks simple, but it helps TypeScript understand Next.js-specific things—like layout.tsx, page.tsx file types in the app directory, and the distinction between server and client components.

Without this plugin, TypeScript might throw random type errors when you write server components.

Progressively Enabling Strict Mode

If your project has been running a while with a decent codebase, flipping strict: true immediately can be painful. My advice: don’t force it.

Strategy 1: Strict for New Code, Gradual for Old

Keep strict: true in tsconfig.json, but for files you can’t fix right away, add at the top:

// @ts-nocheck  // Skip type checking for entire file

Or for specific lines:

// @ts-ignore  // Ignore next line's type error

Note the difference between @ts-ignore and @ts-expect-error:

// @ts-ignore
const x = 1 as any  // Won't complain even if next line has no error

// @ts-expect-error
const y = 1  // TypeScript warns you this comment is unnecessary if next line has no error

I prefer @ts-expect-error because it prevents you from “forgetting to remove comments”—once the bug is fixed, TypeScript reminds you this comment is no longer needed.

Strategy 2: Enable by Module

For instance, clean up the components directory first while keeping other directories relaxed. Configure like this:

// tsconfig.strict.json (strict mode)
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true
  },
  "include": ["src/components/**/*"]
}

Use the regular tsconfig.json for daily dev, switch to the strict version when refactoring specific modules.

That said, strict mode really isn’t about hassling you. Once while refactoring an old component, enabling strictNullChecks revealed 5 missing null checks—3 had already caused production errors but got swallowed by try-catch blocks. At that moment, those red squiggly lines suddenly looked kinda cute.

Type-Safe Routing - Say Goodbye to Typos

Next.js Built-in Typed Routes

Remember that 3 AM bug from the intro? One extra ‘s’ in the route caused a 404 page. This type of error is completely preventable.

Next.js 13 introduced an experimental feature: typedRoutes. When enabled, TypeScript generates type definitions for all your routes.

How to enable?

Add one line in next.config.ts:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    typedRoutes: true,  // Enable type-safe routing
  },
}

export default nextConfig

Then restart the dev server (npm run dev). Next.js will automatically scan your app directory and generate route type definitions in the .next/types folder.

What does it look like?

Say your project structure is:

app/
├── page.tsx           // Homepage
├── blog/
│   ├── page.tsx      // Blog list
│   └── [slug]/
│       └── page.tsx  // Blog post
└── user/
    └── [id]/
        └── profile/
            └── page.tsx  // User profile

With typedRoutes enabled, when writing routes in Link components and useRouter, your IDE autocompletes:

import Link from 'next/link'

export default function Nav() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/blog">Blog</Link>
      <Link href="/blog/hello-world">Post</Link>
      <Link href="/user/123/profile">Profile</Link>

      {/* ❌ TypeScript error: Route doesn't exist */}
      <Link href="/users/123/profile" />  // Note: users not user
    </nav>
  )
}

When you type href="/, your IDE shows all available routes. Mistype and it immediately flags red.

Not kidding, the first time I experienced this feature, I thought just two words: game changer.

Limitations

However, this feature currently has some restrictions:

  1. App Router only: If your project still uses the pages directory, this won’t work
  2. Dynamic route params need manual handling: For /blog/[slug], you still have to manually concatenate the slug value
  3. No query param checking: The tab parameter in /user?tab=settings won’t be type-checked

Basically, it ensures the path itself won’t be mistyped, but param values still rely on you being careful.

Third-Party Library: nextjs-routes

If you’re still using the pages directory or want more complete route type safety (including query params), try the nextjs-routes library.

Installation and setup:

npm install nextjs-routes

Then add to next.config.ts:

const nextRoutes = require('nextjs-routes/config')

const nextConfig = nextRoutes({
  // Your original Next.js config
})

export default nextConfig

Usage:

This library generates a route function that lets you define routes as objects:

import { route } from 'nextjs-routes'

// Type-safe route object
const profileRoute = route({
  pathname: '/user/[id]/profile',
  query: {
    id: '123',
    tab: 'settings',  // Query params also get type checking
  }
})

router.push(profileRoute)  // Fully type-safe

// If path is wrong
const wrongRoute = route({
  pathname: '/users/[id]/profile',  // ❌ TypeScript error: Path doesn't exist
})

Compared to Next.js’s built-in solution, nextjs-routes offers:

  • Support for pages directory
  • Type checking for query params
  • Object-based route definition without manual string concatenation

Downside is it requires an extra dependency, and every route structure change needs regenerating type files (though that’s automatic).

Type Inference for Route Parameters

What about dynamic route params? Like app/blog/[slug]/page.tsx—what’s the type of this slug param?

Next.js automatically generates types for params:

// app/blog/[slug]/page.tsx
export default function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  return <h1>Post: {params.slug}</h1>
}

But the issue is: slug is just string type—any string can be passed. If you want stricter validation—like only allowing specific slug formats—use zod for runtime validation:

import { z } from 'zod'

const slugSchema = z.string().regex(/^[a-z0-9-]+$/)

export default function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  // Validate slug format
  const validatedSlug = slugSchema.parse(params.slug)

  return <h1>Post: {validatedSlug}</h1>
}

If the slug doesn’t match the format (like containing uppercase letters or special chars), zod throws an error.

This trick is especially useful when handling API routes. After all, you can’t control what users pass in—validating upfront beats blowing up in production.

Environment Variable Type Definitions - Completely Eliminate any

The Root of the Problem

TypeScript’s default support for environment variables is honestly pretty weak.

You’ve definitely written code like this:

const apiKey = process.env.API_KEY

Hover over apiKey and the type is string | undefined. Okay, at least it knows it could be undefined.

But more commonly it’s like this:

const apiUrl = process.env.NEXT_PUBLIC_API_URL
console.log(apiUrl.toUpperCase())  // Runtime explosion: apiUrl is undefined

TypeScript doesn’t complain, then at runtime you discover the env var wasn’t even configured.

Plus if you mistype the env var name, TypeScript is clueless:

const key = process.env.API_SECRE  // Missing a T
// TypeScript: No problem, I'm just string | undefined

Pretty awkward. Using TypeScript but still relying on eyeball checks for variable names—what’s the difference from JavaScript?

To solve this, the most widely accepted community solution is T3 Env. It provides both type checking and runtime validation—covering all bases.

Installation:

npm install @t3-oss/env-nextjs zod

Configuration:

Create env.mjs (or env.ts) in your project root:

import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"

export const env = createEnv({
  // Server-side env vars (not accessible on client)
  server: {
    DATABASE_URL: z.string().url(),
    API_SECRET: z.string().min(32),
    SMTP_HOST: z.string().min(1),
  },

  // Client-side env vars (must start with NEXT_PUBLIC_)
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_ANALYTICS_ID: z.string().optional(),
  },

  // Runtime env mapping
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    API_SECRET: process.env.API_SECRET,
    SMTP_HOST: process.env.SMTP_HOST,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
  },
})

Usage:

import { env } from './env.mjs'

// ✅ Fully type-safe, autocomplete
const dbUrl = env.DATABASE_URL  // string
const appUrl = env.NEXT_PUBLIC_APP_URL  // string

// ❌ TypeScript error: Typo
const wrong = env.DATABASE_UR

// ❌ TypeScript error: Client can't access server vars
// In a client component
'use client'
const secret = env.API_SECRET  // Compile error

Best parts:

  1. Startup validation: If env vars are missing or wrong format, app startup fails immediately—not at runtime
  2. Type inference: All env vars have accurate types, no more string | undefined
  3. Leak prevention: Client code accessing server vars triggers compile errors directly

Before using T3 Env, my test environments often failed to start because I forgot to configure some env var—had to check logs every time to figure out which one was missing. Now it’s caught at startup, saving a ton of time.

Custom Type Declaration File Approach

If you don’t want to bring in T3 Env, or your project is pretty small, you can manually extend the ProcessEnv type:

// env.d.ts
namespace NodeJS {
  interface ProcessEnv {
    // Server vars
    DATABASE_URL: string
    API_SECRET: string
    SMTP_HOST: string

    // Client vars
    NEXT_PUBLIC_APP_URL: string
    NEXT_PUBLIC_ANALYTICS_ID?: string  // Optional vars use ?
  }
}

Now TypeScript knows these variables’ types:

const dbUrl = process.env.DATABASE_URL  // string
const apiSecret = process.env.API_SECRET  // string

// ❌ TypeScript error
const wrong = process.env.DATABASE_UR  // Property 'DATABASE_UR' does not exist

Downsides:

  • No runtime validation—missing env vars only discovered at runtime
  • Can’t prevent client accessing server vars
  • Requires manual type definition maintenance

Good for small projects or scenarios where type safety requirements aren’t as high. But honestly, if you’re using TypeScript, might as well go all-in with T3 Env.

TypeScript Strict Mode in Practice - Real-World Tips

Handling Third-Party Library Type Issues

Sometimes it’s not your code—it’s that third-party libraries don’t provide type definitions, or their type definitions have bugs.

Case 1: Library has no type definitions at all

Say you use an old npm package, imports are all any:

import oldLib from 'some-old-lib'  // any

First check npm for @types/some-old-lib:

npm install -D @types/some-old-lib

If none exist, you’ll have to write your own. Create types/some-old-lib.d.ts:

declare module 'some-old-lib' {
  export function doSomething(param: string): number
  export default someOldLib
}

Now TypeScript knows this library’s types.

Case 2: Type definitions are buggy

Sometimes @types packages’ type definitions don’t match the actual API (especially for rapidly iterating libraries). You can temporarily use “type assertion”:

import { someFunction } from 'buggy-lib'

// Type definition says returns string, but actually returns number
const result = someFunction() as number

Though that’s just a band-aid—better to file an issue or PR on the library’s GitHub.

Should skipLibCheck be enabled?

There’s a skipLibCheck option in tsconfig—when enabled, TypeScript skips type checking in node_modules.

My recommendation: enable it.

Why? You can’t fix type errors in node_modules anyway, and it slows down compilation. Rather than having TypeScript check a bunch of third-party library type issues, focus on your own code.

Common any Escape Scenarios and Solutions

Even with strict mode on, some places easily “escape” to any type.

Scenario 1: Event Handler Functions

// ❌ Bad approach
const handleSubmit = (e: any) => {
  e.preventDefault()
}

// ✅ Correct approach
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault()
  // e.currentTarget has full type hints
}

Common event types:

  • React.MouseEvent<HTMLButtonElement>
  • React.ChangeEvent<HTMLInputElement>
  • React.KeyboardEvent<HTMLDivElement>

Scenario 2: API Response Data

// ❌ Bad approach
const res = await fetch('/api/user')
const data = await res.json()  // any

// ✅ Option 1: Manually define interface
interface User {
  id: string
  name: string
  email: string
}

const data: User = await res.json()

// ✅ Option 2: Use zod validation (recommended)
import { z } from 'zod'

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
})

const data = UserSchema.parse(await res.json())  // Automatic type inference

Using zod gives you both type checking and runtime validation. If the backend returns a different data structure, you’ll know immediately.

Scenario 3: Dynamic Imports

// ❌ Bad approach
const module = await import('./utils')  // any

// ✅ Correct approach
const module = await import('./utils') as typeof import('./utils')

Or directly use specific imports:

const { formatDate } = await import('./utils')  // Automatic type inference

Leveraging TypeScript Utility Types for Efficiency

TypeScript has a bunch of built-in utility types—using them well saves tons of code.

Pick: Extract Specific Properties

interface User {
  id: string
  name: string
  email: string
  password: string
  createdAt: Date
}

// Only need user's public info
type PublicUser = Pick<User, 'id' | 'name' | 'email'>
// { id: string; name: string; email: string }

Omit: Exclude Certain Properties

// Creating user doesn't need id and createdAt
type CreateUserInput = Omit<User, 'id' | 'createdAt'>

Partial: All Properties Become Optional

// Updating user, all fields are optional
type UpdateUserInput = Partial<User>

Required: All Properties Become Required

type RequiredUser = Required<Partial<User>>  // Reverse operation

Custom Utility Types

If built-ins aren’t enough, write your own:

// Make all string properties optional
type PartialString<T> = {
  [K in keyof T]: T[K] extends string ? T[K] | undefined : T[K]
}

Honestly, these utility types look intimidating at first, but once you get used to them you’ll find them really handy—especially when dealing with complex object types, they save a ton of repetitive code.

Conclusion

Thinking back to that 3 AM bug from the beginning.

If I’d enabled Next.js’s typedRoutes then, route typos couldn’t have made it to production. If I’d used T3 Env, missing env vars would’ve been caught at startup. If strict mode was properly configured, those implicit any types would’ve been flagged by TypeScript long ago.

TypeScript’s type safety isn’t about hassling you—it’s about moving bugs from “runtime” to “write-time.” Instead of waiting for users to hit white screens in production, let your IDE flag issues while you’re coding.

Here’s a quick recap of this article’s core points:

  1. tsconfig optimization: Enable strict mode, configure incremental and paths, use Next.js plugin
  2. Type-safe routing: Enable typedRoutes in Next.js 13+, or use the nextjs-routes library
  3. Environment variable types: Use T3 Env for type checking + runtime validation
  4. Strict mode in practice: Enable progressively, handle third-party library type issues, eliminate common any escape scenarios

At first, configuration might feel tedious and type annotations cumbersome. But once you get used to precise IDE hints and instantly catching potential issues while coding, you’ll never go back to the “wild west” of JavaScript.

Open your tsconfig.json right now and change strict to true. The more red squiggly lines, the more potential bugs you’ve discovered—that’s a good thing.

FAQ

Does strict mode slow down project compilation?
No. Strict mode only increases type checking rigor without noticeably affecting compilation speed. Combined with the incremental option, large projects can actually see 30%-50% faster builds.
How can I safely enable strict mode in an old project?
Use a progressive strategy: first enable strict in tsconfig.json, then mark temporarily unfixable files with @ts-expect-error, enforce strict for new code, gradually refactor old code. You can also enable by module.
What's the difference between T3 Env and manually defining ProcessEnv types?
T3 Env provides runtime validation—checks if env vars are missing or wrong format at app startup, plus prevents client access to server vars. Manual type definitions only offer compile-time checking without runtime protection.
Does Next.js's typedRoutes support the pages directory?
No. typedRoutes is an experimental feature for Next.js 13+ App Router, only works with the app directory. If you're still using pages directory, consider the nextjs-routes third-party library.
Are there security risks with skipLibCheck enabled?
No. skipLibCheck only skips type checking in node_modules—your code still gets strict checking. You can't fix third-party library type errors anyway; skipping checks actually speeds up compilation and lets you focus on your own code.

11 min read · Published on: Jan 6, 2026 · Modified on: Jan 22, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts