Next.js with TypeScript — Complete Type Safety Guide
Advertisement
Introduction
Next.js has first-class TypeScript support. New projects created with create-next-app are TypeScript by default, and the framework ships its own types for every API surface — PageProps, LayoutProps, Metadata, NextRequest, and more. Getting these types right eliminates an entire class of runtime bugs and makes refactoring safe across large codebases.
Why This Matters
TypeScript in Next.js is not optional for serious production applications. Without proper types, a rename of a URL parameter breaks silently at runtime. An incorrect metadata structure ships broken Open Graph tags. A Server Action receives the wrong FormData fields with no warning.
Next.js 15 changed params and searchParams to be Promises, making TypeScript even more important — the type checker catches the synchronous access pattern that causes runtime errors in the new version.
Project Setup
TypeScript is configured automatically in new projects. The generated tsconfig.json includes the right settings:
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}"strict": true enables all strict checks including strictNullChecks and noImplicitAny — essential for catching bugs at compile time.
Typing Page Components
Next.js 15 params and searchParams are Promises:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
interface PageProps {
params: Promise<{ slug: string }>
searchParams: Promise<{ page?: string; tag?: string }>
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const post = await fetchPost(slug)
return { title: post?.title ?? 'Post Not Found' }
}
export default async function BlogPostPage({ params, searchParams }: PageProps) {
const { slug } = await params
const { page = '1', tag } = await searchParams
const post = await fetchPost(slug)
return (
<article>
<h1>{post.title}</h1>
<p>Page: {page}, Tag: {tag}</p>
</article>
)
}Typing Layout Components
// app/dashboard/layout.tsx
import type { ReactNode } from 'react'
interface DashboardLayoutProps {
children: ReactNode
// Parallel route slots
analytics: ReactNode
notifications: ReactNode
}
export default function DashboardLayout({
children,
analytics,
notifications,
}: DashboardLayoutProps) {
return (
<div className="grid grid-cols-12 gap-6">
<aside className="col-span-3">{analytics}</aside>
<main className="col-span-7">{children}</main>
<aside className="col-span-2">{notifications}</aside>
</div>
)
}Typing Server Actions
Use Zod for runtime validation and infer TypeScript types from schemas:
// app/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const CreatePostSchema = z.object({
title: z.string().min(5).max(200),
content: z.string().min(20),
published: z.boolean().default(false),
tags: z.array(z.string()).max(5).optional(),
})
type CreatePostInput = z.infer<typeof CreatePostSchema>
type ActionResult =
| { success: true; postId: string }
| { success: false; errors: Partial<Record<keyof CreatePostInput, string[]>> }
export async function createPost(formData: FormData): Promise<ActionResult> {
const raw = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'true',
tags: formData.getAll('tags') as string[],
}
const result = CreatePostSchema.safeParse(raw)
if (!result.success) {
return { success: false, errors: result.error.flatten().fieldErrors }
}
const post = await db.post.create({ data: result.data })
revalidatePath('/blog')
return { success: true, postId: post.id }
}Typing Route Handlers
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
interface RouteContext {
params: Promise<{ id: string }>
}
const UpdateUserSchema = z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
role: z.enum(['user', 'admin', 'moderator']).optional(),
})
export async function GET(request: NextRequest, { params }: RouteContext) {
const { id } = await params
const user = await db.user.findUnique({ where: { id } })
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json(user)
}
export async function PATCH(request: NextRequest, { params }: RouteContext) {
const { id } = await params
const body = await request.json()
const result = UpdateUserSchema.safeParse(body)
if (!result.success) {
return NextResponse.json({ errors: result.error.flatten() }, { status: 400 })
}
const user = await db.user.update({ where: { id }, data: result.data })
return NextResponse.json(user)
}Typing Component Props
// components/PostCard.tsx
import type { Post, User } from '@prisma/client'
interface PostWithAuthor extends Post {
author: Pick<User, 'id' | 'name' | 'image'>
_count: { comments: number; likes: number }
}
interface PostCardProps {
post: PostWithAuthor
showAuthor?: boolean
onLike?: (postId: string) => void
className?: string
}
export function PostCard({
post,
showAuthor = true,
onLike,
className,
}: PostCardProps) {
return (
<article className={className}>
<h2>{post.title}</h2>
{showAuthor && <p>By {post.author.name}</p>}
<p>{post._count.comments} comments</p>
{onLike && (
<button onClick={() => onLike(post.id)}>
{post._count.likes} likes
</button>
)}
</article>
)
}Generic Utility Types for APIs
// types/api.ts
export type ApiResponse<T> =
| { data: T; error: null }
| { data: null; error: string }
export type PaginatedResponse<T> = {
data: T[]
pagination: {
page: number
limit: number
total: number
pages: number
hasNext: boolean
hasPrev: boolean
}
}
// Usage
async function fetchPosts(page: number): Promise<ApiResponse<PaginatedResponse<Post>>> {
try {
const res = await fetch(`/api/posts?page=${page}`)
if (!res.ok) return { data: null, error: `HTTP ${res.status}` }
return { data: await res.json(), error: null }
} catch (err) {
return { data: null, error: 'Network error' }
}
}Type-Safe Environment Variables
// env.ts (using t3-env or manual validation)
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
NEXT_PUBLIC_APP_URL: z.string().url(),
})
export const env = envSchema.parse(process.env)
// env.DATABASE_URL — fully typed, throws at startup if missingCommon Mistakes
- Not awaiting
paramsin Next.js 15 — synchronous access causes a TypeScript error and runtime failure - Using
anytype on form data instead of proper Zod validation and type inference - Typing
childrenasJSX.Elementinstead ofReact.ReactNode— misses strings, arrays, and null - Not using
satisfiesfor type-checking objects while keeping the inferred type - Ignoring TypeScript errors with
// @ts-ignoreinstead of fixing the underlying type issue
Best Practices
- Enable
"strict": trueintsconfig.json— catches null reference errors and implicit any types - Use Zod to validate all external data (API responses, form data) and infer TypeScript types from schemas
- Define shared types in a
types/directory —Post,User,ApiResponse<T>, etc. - Use
type(notinterface) for utility types and union types; useinterfacefor extendable object shapes - Use
satisfiesoperator to check an object matches a type while retaining the literal type
Key Takeaways
- Next.js ships types for all framework APIs:
PageProps,LayoutProps,Metadata,NextRequest,NextResponse - In Next.js 15, both
paramsandsearchParamsin page/layout components are typed as Promises - Zod schemas serve as the source of truth for both runtime validation and TypeScript types via
z.infer<> "strict": trueintsconfig.jsonis essential — it enablesstrictNullChecks,noImplicitAny, and more- Route Handler
paramscontext is typed as{ params: Promise<{ id: string }> }in Next.js 15 - Use generic response types like
ApiResponse<T>andPaginatedResponse<T>for consistent API typing type CreatePostInput = z.infer<typeof CreatePostSchema>automatically derives TypeScript type from Zod schema- Use path aliases (
@/*) intsconfig.jsonto avoid relative import hell across large codebases
Advertisement