TypeScriptBest PracticesNext.js
TypeScript Best Practices for Next.js Projects in 2025
Aaron InnovationsDecember 12, 202410 min read
Introduction
TypeScript has become essential for building maintainable Next.js applications. In 2025, with Next.js 16 and React 19, TypeScript integration is more powerful than ever.
This guide covers modern TypeScript patterns, type-safe APIs, and best practices for Next.js projects.
Project Setup
tsconfig.json Configuration
Start with strict mode enabled:
{
"compilerOptions": {
"target": "ES2022",
"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"]
}Type-Safe Server Components
Async Params and SearchParams
With Next.js 16, params and searchParams are async:
type PageProps = {
params: Promise<{ id: string }>
searchParams: Promise<{ filter?: string }>
}
export default async function Page({ params, searchParams }: PageProps) {
const { id } = await params
const { filter } = await searchParams
return <div>Post {id}, Filter: {filter}</div>
}Metadata Generation
import { Metadata } from 'next'
type Props = {
params: Promise<{ slug: string }>
}
// Dynamic metadata is produced from an async function that
// receives the route params and returns a typed Metadata object.
async function buildMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
}
}Type-Safe Server Actions
Define clear input/output types:
'use server'
import { z } from 'zod'
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
})
type ContactInput = z.infer<typeof contactSchema>
type ContactResult =
| { success: true; id: string }
| { success: false; error: string }
export async function submitContact(
data: ContactInput
): Promise<ContactResult> {
const validated = contactSchema.safeParse(data)
if (!validated.success) {
return {
success: false,
error: validated.error.message
}
}
const id = await saveToDatabase(validated.data)
return { success: true, id }
}Type-Safe API Routes
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
})
type ApiResponse<T> = {
data: T
total: number
page: number
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const query = querySchema.parse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
})
const { data, total } = await fetchPosts(query)
const response: ApiResponse<Post[]> = {
data,
total,
page: query.page,
}
return NextResponse.json(response)
}Component Patterns
Generic Components
type ListProps<T> = {
items: T[]
renderItem: (item: T) => React.ReactNode
keyExtractor: (item: T) => string
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>
{renderItem(item)}
</li>
))}
</ul>
)
}
// Usage with type inference
<List
items={posts}
renderItem={post => <PostCard {...post} />}
keyExtractor={post => post.id}
/>Discriminated Unions for State
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
function useData<T>(fetcher: () => Promise<T>) {
const [state, setState] = useState<FetchState<T>>({ status: 'idle' })
useEffect(() => {
setState({ status: 'loading' })
fetcher()
.then(data => setState({ status: 'success', data }))
.catch(error => setState({ status: 'error', error }))
}, [])
return state
}
// Type-safe usage
function Component() {
const state = useData(fetchPosts)
switch (state.status) {
case 'loading':
return <Spinner />
case 'success':
return <PostList posts={state.data} /> // data is typed
case 'error':
return <Error message={state.error.message} />
default:
return null
}
}Database Type Safety
With Prisma
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
// Fully typed queries
async function getPost(id: string) {
const post = await prisma.post.findUnique({
where: { id },
include: { author: true, comments: true },
})
return post // Type is inferred automatically
}With Raw SQL (Supabase/Neon)
import { sql } from '@vercel/postgres'
type Post = {
id: string
title: string
content: string
created_at: Date
}
async function getPosts(): Promise<Post[]> {
const { rows } = await sql<Post>`
SELECT id, title, content, created_at
FROM posts
ORDER BY created_at DESC
`
return rows
}Utility Types
Create reusable type utilities:
// Make specific fields optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
type User = {
id: string
name: string
email: string
}
type UserUpdate = PartialBy<User, 'name' | 'email'>
// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never
type Posts = Post[]
type SinglePost = ArrayElement<Posts>
// Create form types from action inputs
type FormData<T> = {
[K in keyof T]: string | undefined
}
type ContactFormData = FormData<ContactInput>Best Practices Summary
Conclusion
TypeScript in Next.js 16 provides unmatched type safety and developer experience. By following these patterns, you'll build more maintainable, less error-prone applications.
Embrace TypeScript's power and your codebase will thank you.
Enjoyed this article?
Let's discuss how we can bring these concepts to your next project.
Get in Touch