DEV Community

Cover image for How I set up Next.js TypeScript for production from day one
Nayan Kyada
Nayan Kyada

Posted on • Originally published at nayankyada.com

How I set up Next.js TypeScript for production from day one

A Next.js TypeScript setup that ships cleanly in CI and catches real bugs before they hit production looks nothing like the default create-next-app output. Here is the exact configuration I apply to every client project before writing a single page component.

Start with a strict tsconfig

The default tsconfig.json Next.js generates is permissive. I replace it with this on day one:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": false,
    "skipLibCheck": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "forceConsistentCasingInFileNames": 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"]
}
Enter fullscreen mode Exit fullscreen mode

Three flags matter most beyond strict: noUncheckedIndexedAccess makes array and record lookups return T | undefined instead of lying to you. exactOptionalPropertyTypes stops you assigning { foo: undefined } where { foo?: string } is expected. allowJs: false keeps the codebase honest — no gradual drift back to untyped files.

Typed route params and search params

App Router gives you params and searchParams as plain objects. Without a helper they're any at the call site. Next.js ships its own PageProps inference when the next plugin is active, but I still add an explicit param type per route so the shape is obvious:

// src/app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

type Params = Promise<{ slug: string }>
type SearchParams = Promise<{ preview?: string }>

interface PageProps {
  params: Params
  searchParams: SearchParams
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params
  return { title: slug }
}

export default async function BlogPostPage({ params, searchParams }: PageProps) {
  const { slug } = await params
  const { preview } = await searchParams
  // slug: string, preview: string | undefined — both typed correctly
  return <article data-slug={slug} />
}
Enter fullscreen mode Exit fullscreen mode

Note that in Next.js 15+ both params and searchParams are Promise-wrapped. Typing them as Promise<…> and awaiting them stops the TypeScript plugin from warning you at build time.

Zod at every external boundary

Type assertions on API responses are a lie the compiler believes. I use Zod at three boundaries: route handler request bodies, external API responses, and form submissions.

// src/lib/schemas/contact.ts
import { z } from 'zod'

export const ContactSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  message: z.string().min(10).max(2000),
})

export type ContactPayload = z.infer<typeof ContactSchema>

// src/app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { ContactSchema } from '@/lib/schemas/contact'

export async function POST(req: NextRequest) {
  const body: unknown = await req.json()
  const parsed = ContactSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { errors: parsed.error.flatten().fieldErrors },
      { status: 422 }
    )
  }

  // parsed.data is ContactPayload — fully typed, no cast
  return NextResponse.json({ ok: true })
}
Enter fullscreen mode Exit fullscreen mode

The key is unknown on req.json(). Never type it as any. Forcing the parse through Zod means a malformed body surfaces as a 422 rather than a runtime crash.

Typed environment variables

I use @t3-oss/env-nextjs for this. It runs Zod validation at startup and gives you a typed env object everywhere in the project:

// src/env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
  server: {
    SANITY_API_TOKEN: z.string().min(1),
    SANITY_WEBHOOK_SECRET: z.string().min(32),
    SENDGRID_API_KEY: z.string().startsWith('SG.'),
  },
  client: {
    NEXT_PUBLIC_SANITY_PROJECT_ID: z.string().length(8),
    NEXT_PUBLIC_SANITY_DATASET: z.enum(['production', 'staging']),
  },
  runtimeEnv: {
    SANITY_API_TOKEN: process.env.SANITY_API_TOKEN,
    SANITY_WEBHOOK_SECRET: process.env.SANITY_WEBHOOK_SECRET,
    SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
    NEXT_PUBLIC_SANITY_PROJECT_ID: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
    NEXT_PUBLIC_SANITY_DATASET: process.env.NEXT_PUBLIC_SANITY_DATASET,
  },
})
Enter fullscreen mode Exit fullscreen mode

Now env.SANITY_API_TOKEN is string — not string | undefined. If the var is missing the build fails immediately with a clear message instead of a cryptic runtime error at the first API call. The startsWith('SG.') check on the SendGrid key has already caught a copy-paste of the wrong key once.

CI type-check gate

Running next build does not type-check your application — it only type-checks the files Webpack actually compiles. Unreferenced files with broken types pass right through. Add an explicit tsc step:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npm run build
Enter fullscreen mode Exit fullscreen mode

The tsc --noEmit step runs first and covers the entire include glob from tsconfig.json. If it fails, the build step never runs, saving Vercel minutes and protecting main. I keep this separate from next build rather than replacing it because the build step still validates bundle output, edge runtime constraints, and missing generateStaticParams exports.

A few habits that make the setup durable

allowJs: false prevents a colleague from adding a .js utility file six months in that bypasses everything above. If they need to add a file, it is .ts — full stop.

For route handlers I always type the return value explicitly as Promise<NextResponse<ResponseType>> so a future refactor that changes the shape shows up as a type error rather than a silent breaking change to the client.

I also add "checkJs": false to any tsconfig.json that inherits from this one via extends, just to be explicit.

This setup catches a meaningful class of bugs — missing env vars, mistyped params, stale API shapes — before any reviewer sees a PR. The CI gate is what makes it stick: without it, the strict config is just a suggestion.

Top comments (0)