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
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} />
}
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 })
}
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. 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
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)