How to Use Next.js with TypeScript
Next.js blurs the line between server and client code. That creates a unique TypeScript challenge. Your Server Component fetches a user object with a Date field, passes it to a Client Component, and the app crashes with a serialization error. TypeScript's compiler doesn't catch this because the types look fine on both sides. The problem only appears at runtime when Next.js tries to serialize data across the server-client boundary.
Here's the catch: TypeScript can validate your component props, but it can't enforce serialization rules. A Date object, a function, or a Map are all valid TypeScript types. They'll break your app when passed from server to client. This guide shows you how to build full-stack Next.js applications with TypeScript patterns that work within these constraints, from understanding the server-client split to typing Server Actions and route handlers.
Since Next.js is built on React, you'll use many of the same TypeScript patterns for components, props, and hooks. This guide focuses on Next.js-specific typing challenges like Server Components, the App Router, and Server Actions. For projects that don't need server-side rendering, consider Svelte with TypeScript or Vue with TypeScript for lighter-weight alternatives.
Starting a Next.js Project with TypeScript
Next.js includes TypeScript support out of the box. When you create a new project with create-next-app, it automatically sets up TypeScript configuration:
npx create-next-app@latest my-app
During setup, you'll be asked if you want to use TypeScript. Choose "Yes" and Next.js will:
- Install TypeScript and React type definitions
- Create a
tsconfig.jsonwith good defaults - Configure the Next.js TypeScript plugin for better type checking
Here's what a typical Next.js tsconfig.json looks like:
{
"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": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
The Next.js plugin ("name": "next") gives you better type checking and IntelliSense. It catches Next.js-specific errors like using client-only features in Server Components or passing non-serializable props across the server-client boundary.
Understanding Server Components vs Client Components in TypeScript
The biggest TypeScript challenge in Next.js is understanding the boundary between Server and Client Components. This isn't just a rendering difference. It affects what types you can use and how you pass data between components.
By default, all components in the App Router are Server Components. They run only on the server, can be async, and can directly access databases or file systems. For client-side data fetching and caching, TanStack Query works well with Next.js and gives you type-safe queries.
// app/posts/page.tsx - Server Component (default)
interface Post {
id: number;
title: string;
content: string;
publishedAt: Date;
}
async function getPosts(): Promise<Post[]> {
// Direct database access - only works in Server Components
const posts = await db.posts.findMany();
return posts;
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
Client Components use the 'use client' directive and can access browser APIs, React hooks, and event handlers:
// app/components/like-button.tsx - Client Component
'use client';
import { useState } from 'react';
interface LikeButtonProps {
initialLikes: number;
postId: number;
}
export default function LikeButton({ initialLikes, postId }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false);
const handleLike = async () => {
if (isLiked) return;
setIsLiked(true);
setLikes(prev => prev + 1);
// Call API route to persist the like
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
};
return (
<button onClick={handleLike} disabled={isLiked}>
{likes} {likes === 1 ? 'Like' : 'Likes'}
</button>
);
}
The critical rule: props passed from Server Components to Client Components must be serializable. This means:
✅ Serializable (safe to pass):
- Primitives: strings, numbers, booleans, null, undefined
- Plain objects and arrays
- JSON-serializable data
❌ Non-serializable (will cause runtime errors):
- Functions
- Class instances
- Date objects (serialize to ISO strings instead)
- Symbols
- Map, Set, or other complex objects
The catch: TypeScript can't catch serialization errors at compile time. This code compiles but crashes at runtime:
// ❌ Compiles but fails at runtime!
interface PostProps {
post: {
id: number;
title: string;
publishedAt: Date; // TypeScript allows this
onClick: () => void; // TypeScript allows this too
};
}
// Server Component
export default async function PostsPage() {
const post = {
id: 1,
title: 'My Post',
publishedAt: new Date(), // Runtime error: Date isn't serializable
onClick: () => console.log('clicked') // Runtime error: functions aren't serializable
};
return <PostCard post={post} />; // No TypeScript error!
}
You must manually ensure props are serializable. Here's the correct pattern for passing data from a Server Component to a Client Component:
// app/posts/[id]/page.tsx - Server Component
import LikeButton from '@/app/components/like-button';
type PageProps = {
params: Promise<{ id: string }>;
};
async function getPost(id: number) {
const post = await db.posts.findUnique({ where: { id } });
return post;
}
export default async function PostPage({ params }: PageProps) {
const { id } = await params;
const post = await getPost(Number(id));
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Pass only serializable data */}
<LikeButton initialLikes={post.likes} postId={post.id} />
</article>
);
}
Type-Safe Routing with Next.js
Next.js uses file-based routing. TypeScript can help you catch routing errors at compile time. The framework gives you helpers like PageProps and RouteContext for type-safe route parameters.
Typing Dynamic Routes
For pages with dynamic segments, Next.js 15 requires you to type params as a Promise:
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
// In Next.js 15, params is a Promise - await it first
const { slug } = await params;
const post = await getPostBySlug(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
For routes with multiple dynamic segments:
// app/[category]/[productId]/page.tsx
export default async function ProductPage({
params,
}: {
params: Promise<{ category: string; productId: string }>;
}) {
const { category, productId } = await params;
// TypeScript knows both params exist
const product = await getProduct(category, productId);
return <ProductDetails product={product} />;
}
Typing Search Params
Search params (query strings) are also available through props and should be typed:
// app/search/page.tsx
interface SearchPageProps {
searchParams: Promise<{
q?: string;
category?: string;
sort?: 'asc' | 'desc';
}>;
}
export default async function SearchPage({ searchParams }: SearchPageProps) {
const { q, category, sort = 'desc' } = await searchParams;
const results = await searchProducts({ query: q, category, sort });
return (
<div>
<h1>Search Results for "{q}"</h1>
<SearchResults results={results} />
</div>
);
}
Typing Route Handlers (API Routes)
Route handlers in the App Router need proper typing for request and response objects:
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface PostRequestBody {
title: string;
content: string;
authorId: number;
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const authorId = searchParams.get('authorId');
const posts = await db.posts.findMany({
where: authorId ? { authorId: Number(authorId) } : undefined
});
return NextResponse.json(posts);
}
export async function POST(request: NextRequest) {
const body = await request.json() as PostRequestBody;
// Validate the body shape
if (!body.title || !body.content) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const newPost = await db.posts.create({
data: {
title: body.title,
content: body.content,
authorId: body.authorId
}
});
return NextResponse.json(newPost, { status: 201 });
}
For dynamic route handlers with params:
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params;
const post = await db.posts.findUnique({
where: { id: Number(id) }
});
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
);
}
return NextResponse.json(post);
}
Data Fetching Patterns with TypeScript
Next.js offers multiple ways to fetch data, each with its own TypeScript patterns.
Server Component Data Fetching
Server Components can fetch data directly in the component body. Always type your data fetching functions:
// lib/api.ts
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
interface ApiResponse<T> {
data: T;
error?: string;
}
export async function getUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`, {
// Next.js extends fetch with caching options
next: { revalidate: 3600 } // Cache for 1 hour
});
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.statusText}`);
}
const result: ApiResponse<User> = await response.json();
if (result.error) {
throw new Error(result.error);
}
return result.data;
}
// app/users/[id]/page.tsx
import { getUser } from '@/lib/api';
export default async function UserProfile({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Handle potential errors
let user: User;
try {
user = await getUser(Number(id));
} catch (error) {
return <div>Failed to load user profile</div>;
}
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<span>Role: {user.role}</span>
</div>
);
}
Type-Safe Environment Variables
Environment variables should be typed to catch configuration errors early:
// lib/env.ts
function getEnvVar<K extends string>(key: K): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
// Type-safe with autocomplete for the keys you define
export const env = {
databaseUrl: getEnvVar('DATABASE_URL'),
apiKey: getEnvVar('API_KEY'),
appUrl: getEnvVar('NEXT_PUBLIC_APP_URL'),
} as const;
// Usage in Server Component
import { env } from '@/lib/env';
export default async function Dashboard() {
const data = await fetch(`${env.appUrl}/api/dashboard`, {
headers: {
'Authorization': `Bearer ${env.apiKey}`
}
});
// ...
}
Server Actions with TypeScript
Server Actions are a modern way to handle mutations in Next.js. They're functions that run on the server and can be called from Client Components. They give you a type-safe alternative to API routes for form submissions and data mutations.
Basic Server Action with Form Data
Create a Server Action with the 'use server' directive:
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
// Use discriminated unions for better type safety
type CreatePostResult =
| { success: true; postId: number }
| { success: false; error: string };
export async function createPost(formData: FormData): Promise<CreatePostResult> {
// Extract form fields
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Basic validation
if (!title || !content) {
return {
success: false,
error: 'Title and content are required'
};
}
try {
const post = await db.posts.create({
data: { title, content }
});
// Revalidate the posts page to show the new post
revalidatePath('/posts');
return {
success: true,
postId: post.id
};
} catch (error) {
return {
success: false,
error: 'Failed to create post'
};
}
}
Use the Server Action in a Client Component:
// app/components/create-post-form.tsx
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';
export default function CreatePostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
{ success: false, error: '' } as { success: false; error: string }
);
return (
<form action={formAction}>
{/* TypeScript narrows the type when checking success */}
{!state.success && state.error && (
<div className="error">{state.error}</div>
)}
<input
type="text"
name="title"
placeholder="Post title"
required
/>
<textarea
name="content"
placeholder="Post content"
required
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
{state.success && (
<div className="success">
Post created successfully! (ID: {state.postId})
</div>
)}
</form>
);
}
Server Actions with Validation
For production apps, validate input data using a library like Zod:
// app/actions/posts.ts
'use server';
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
tags: z.array(z.string()).optional(),
published: z.boolean().default(false)
});
type CreatePostInput = z.infer<typeof createPostSchema>;
interface ValidationError {
success: false;
errors: Record<string, string[]>;
}
interface SuccessResult {
success: true;
postId: number;
}
type CreatePostResult = ValidationError | SuccessResult;
export async function createPostValidated(
formData: FormData
): Promise<CreatePostResult> {
// Parse form data into an object
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags'),
published: formData.get('published') === 'on'
};
// Validate with Zod
const validation = createPostSchema.safeParse(rawData);
if (!validation.success) {
return {
success: false,
errors: validation.error.flatten().fieldErrors
};
}
// TypeScript knows validation.data matches CreatePostInput
const post = await db.posts.create({
data: validation.data
});
revalidatePath('/posts');
return {
success: true,
postId: post.id
};
}
Passing Server Actions as Props
You can pass Server Actions to Client Components as props for flexible composition:
// app/actions/items.ts
'use server';
export async function updateItem(formData: FormData) {
const id = formData.get('id') as string;
const name = formData.get('name') as string;
await db.items.update({
where: { id: Number(id) },
data: { name }
});
revalidatePath('/items');
}
// app/components/item-form.tsx
'use client';
interface ItemFormProps {
itemId: number;
currentName: string;
updateAction: (formData: FormData) => Promise<void>;
}
export default function ItemForm({
itemId,
currentName,
updateAction
}: ItemFormProps) {
return (
<form action={updateAction}>
<input type="hidden" name="id" value={itemId} />
<input
type="text"
name="name"
defaultValue={currentName}
/>
<button type="submit">Update</button>
</form>
);
}
// app/items/[id]/page.tsx
import { updateItem } from '@/app/actions/items';
import ItemForm from '@/app/components/item-form';
export default async function ItemPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const item = await getItem(Number(id));
return (
<div>
<h1>Edit Item</h1>
<ItemForm
itemId={item.id}
currentName={item.name}
updateAction={updateItem}
/>
</div>
);
}
Advanced TypeScript Patterns for Next.js
Generic Page Components
Create reusable page components with generics for different data types:
// components/data-table.tsx
'use client';
interface Column<T> {
key: keyof T;
header: string;
render?: (value: T[keyof T], item: T) => React.ReactNode;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
onRowClick?: (item: T) => void;
}
export default function DataTable<T extends { id: number | string }>({
data,
columns,
onRowClick
}: DataTableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map(col => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map(item => (
<tr
key={item.id}
onClick={() => onRowClick?.(item)}
>
{columns.map(col => (
<td key={String(col.key)}>
{col.render
? col.render(item[col.key], item)
: String(item[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Usage with different data types:
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
const userColumns: Column<User>[] = [
{ key: 'name', header: 'Name' },
{ key: 'email', header: 'Email' },
{
key: 'role',
header: 'Role',
render: (value) => <span className={`role-${value}`}>{value}</span>
}
];
// TypeScript automatically infers T as User from the columns type
// No need to write: <DataTable<User> data={users} columns={userColumns} />
<DataTable data={users} columns={userColumns} />
TypeScript infers the generic type T from the columns prop type. You don't need to write <DataTable<User>> explicitly. This makes the component usage cleaner while keeping full type safety.
Utility Types for Props
Use TypeScript utility types to create prop variations:
interface PageLayoutProps {
title: string;
description: string;
showSidebar: boolean;
showHeader: boolean;
className?: string;
}
// Simplified layout without sidebar
function SimpleLayout(props: Omit<PageLayoutProps, 'showSidebar'>) {
return <div className={props.className}>{/* ... */}</div>;
}
// Layout with only required props
function MinimalLayout(props: Pick<PageLayoutProps, 'title' | 'children'>) {
return <div><h1>{props.title}</h1>{props.children}</div>;
}
// Layout where everything is optional (for defaults)
function FlexibleLayout(props: Partial<PageLayoutProps>) {
const {
title = 'Default Title',
showSidebar = true,
showHeader = true
} = props;
return <div>{/* ... */}</div>;
}
Typing Metadata API
Next.js provides a type-safe Metadata API for SEO:
// app/blog/[slug]/page.tsx
// Built-in Next.js type for metadata
import type { Metadata } from 'next';
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return {
title: 'Post Not Found'
};
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
type: 'article',
publishedTime: post.publishedAt.toISOString(),
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
}
};
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
return <article>{/* ... */}</article>;
}
Where Developers Get Stuck
Next.js TypeScript has specific pain points that trip up developers. Here's how to avoid them.
Serialization Errors Between Server and Client
The most common error is passing non-serializable data from Server to Client Components:
// ❌ WRONG - This will cause a runtime error
// app/posts/page.tsx
import PostCard from './post-card'; // Client Component
export default async function PostsPage() {
const posts = await db.posts.findMany({
include: { author: true }
});
return (
<div>
{posts.map(post => (
// Error: Can't serialize Date objects or complex types
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// ✅ CORRECT - Serialize data before passing to Client Component
export default async function PostsPage() {
const posts = await db.posts.findMany({
include: { author: true }
});
// Serialize to plain objects with primitives only
const serializedPosts = posts.map(post => ({
id: post.id,
title: post.title,
publishedAt: post.publishedAt.toISOString(), // Convert Date to string
author: {
name: post.author.name,
avatar: post.author.avatar
}
}));
return (
<div>
{serializedPosts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Async Component Type Errors
If you get errors about async Server Components, make sure you're using TypeScript 5.1.3 or higher:
npm install -D typescript@latest
Update your tsconfig.json if needed:
{
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}
Route Parameter Type Confusion
Don't forget that route params are always strings:
// ❌ WRONG - params are strings, not numbers
export default async function PostPage(
props: PageProps<'/posts/[id]'>
) {
const { id } = await props.params;
const post = await getPost(id); // Type error: string not assignable to number
}
// ✅ CORRECT - Convert to number explicitly
export default async function PostPage(
props: PageProps<'/posts/[id]'>
) {
const { id } = await props.params;
const post = await getPost(Number(id)); // Explicit conversion
}
"use client" Boundary Confusion
The 'use client' directive marks the boundary, but it doesn't mean all child components are client components:
// components/interactive-section.tsx
'use client'; // This marks the boundary
import ServerChild from './server-child'; // Still a Server Component!
import { useState } from 'react';
export default function InteractiveSection() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
{/* ServerChild remains a Server Component */}
<ServerChild />
</div>
);
}
Here's the rule: imports in a 'use client' file can still be Server Components if they don't use client features. Only the component with 'use client' and its state/effects become client-side.
Environment Variable Typing Issues
Only environment variables prefixed with NEXT_PUBLIC_ are available in Client Components:
// ❌ WRONG - DATABASE_URL is undefined in Client Components
'use client';
export default function ClientComponent() {
console.log(process.env.DATABASE_URL); // undefined!
}
// ✅ CORRECT - Use NEXT_PUBLIC_ prefix for client-accessible vars
'use client';
export default function ClientComponent() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL; // Works!
}
For server-only variables, create a helper that throws an error if called client-side:
// lib/env.ts
export function getServerEnv(key: string): string {
if (typeof window !== 'undefined') {
throw new Error(`${key} is only available on the server`);
}
const value = process.env[key];
if (!value) {
throw new Error(`Missing environment variable: ${key}`);
}
return value;
}
Integrating Next.js with Type-Safe Backends
Next.js works well with type-safe backends that give you end-to-end TypeScript support. For applications that need real-time features, structured data, or complex queries, Convex integrates smoothly with Next.js and generates types automatically.
Here's a pattern for working with a type-safe API:
// lib/api-client.ts
interface ApiError {
message: string;
code: string;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get<T>(path: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message);
}
return response.json();
}
async post<T, R>(path: string, body: T): Promise<R> {
const response = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message);
}
return response.json();
}
}
export const apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL!);
For Convex integration, the setup is even simpler with automatic TypeScript types generated from your schema:
// app/dashboard/page.tsx
import { ConvexHttpClient } from 'convex/browser';
import { api } from '@/convex/_generated/api';
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export default async function Dashboard() {
// Fully typed queries with autocomplete
const tasks = await convex.query(api.tasks.list, {
completed: false
});
return (
<div>
<h1>Your Tasks</h1>
<ul>
{tasks.map(task => (
<li key={task._id}>{task.title}</li>
))}
</ul>
</div>
);
}
Building Type-Safe Next.js Applications
The server-client boundary is what makes Next.js TypeScript different from other frameworks. Learn serialization constraints first. Date objects, functions, and class instances can't cross from Server to Client Components. TypeScript won't stop you from writing publishedAt: Date in your props interface, but your app will crash when Next.js tries to serialize it. Convert dates to ISO strings, pass functions as Server Actions, and keep your prop types simple.
Type your data fetching functions with explicit return types. Without them, TypeScript defaults to any and you lose all safety. Use PageProps with Promise types for params and searchParams in Next.js 15+. Server Actions work better with Zod validation since TypeScript can't validate FormData at compile time. Environment variables need wrapper functions that throw errors for missing values instead of silently returning undefined.
Next.js builds on React, so standard React patterns apply: generic components for reusable layouts, discriminated unions for loading states, utility types like Partial and Omit for prop variations. The Next.js-specific bits are middleware typing for request/response transformation and the Metadata API for type-safe SEO configuration.
For a type-safe backend that generates TypeScript types from your database schema, Convex connects directly with Next.js Server and Client Components. Your queries, mutations, and subscriptions all get automatic type inference without manual type definitions.