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

  • **Enable strict mode** in tsconfig.json
  • **Use Zod** for runtime validation
  • **Discriminated unions** for complex state
  • **Generic components** for reusability
  • **Type server actions** with clear input/output
  • **Avoid 'any'** - use 'unknown' if type is truly unknown
  • **Use const assertions** for literal types
  • **Leverage type inference** - don't over-annotate
  • 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