How I set up Next.js TypeScript for production from day one
By Nayan Kyada · · 5 min read
Part of Next.js Performance
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"]
}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
In the App Router, params and searchParams are Promises, and nothing stops you from typing them loosely at the call site. Next.js 16 generates a global PageProps<'/blog/[slug]'> helper (during next dev, next build, or next typegen) that you can use instead, but I still like 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} />
}Note that in Next.js 15+ both params and searchParams are Promise-wrapped. Typing them as Promise<…> and awaiting them keeps your types honest — accessing them synchronously isn't supported in Next.js 16.
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 })
}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,
},
})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. A format check like startsWith('SG.') on the SendGrid key also catches a copy-paste of the wrong key.
CI type-check gate
next build already fails on TypeScript errors, but an explicit tsc step fails faster, runs before the expensive build, and still protects you if someone later sets typescript.ignoreBuildErrors. Add it as its own 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 buildThe 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 catches problems tsc can't, such as bundling and prerendering errors.
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.