Most TypeScript tutorials teach you the basics.
Nobody shows you the patterns that actually matter in a real Next.js production app.
Here are the ones I use every single project.
1. Type Your API Responses
Stop using any for API responses. Create proper types:
// types/api.ts
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
hasMore: boolean;
}
Usage in API route:
// app/api/users/route.ts
import { ApiResponse, User } from '@/types/api';
export async function GET(): Promise<Response> {
const users = await db.users.findMany();
const response: ApiResponse<User[]> = {
success: true,
data: users,
};
return Response.json(response);
}
Now your frontend knows exactly what shape the data is.
2. Component Props Pattern
Always define prop types explicitly:
// ❌ Avoid — no type safety
export function UserCard({ user, onDelete, showActions }) {
return <div>{user.name}</div>;
}
// ✅ Correct — explicit interface
interface UserCardProps {
user: {
id: string;
name: string;
email: string;
avatar?: string; // optional with ?
};
onDelete: (id: string) => void;
showActions?: boolean; // optional prop
className?: string;
}
export function UserCard({
user,
onDelete,
showActions = true, // default value
className,
}: UserCardProps) {
return (
<div className={className}>
<p>{user.name}</p>
{showActions && (
<button onClick={() => onDelete(user.id)}>Delete</button>
)}
</div>
);
}
3. Typing Server Actions
Server Actions need proper typing for form data and return values:
// types/actions.ts
export interface ActionState {
success: boolean;
message: string;
errors?: Record<string, string[]>;
}
// actions/contact.ts
'use server';
import { ActionState } from '@/types/actions';
export async function submitContact(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
if (!name) {
return {
success: false,
message: 'Validation failed',
errors: { name: ['Name is required'] }
};
}
try {
await db.contacts.create({ data: { name, email } });
return { success: true, message: 'Message sent!' };
} catch {
return { success: false, message: 'Server error' };
}
}
4. Typing Next.js Page Props
Pages in Next.js App Router have specific prop types:
// app/blog/[slug]/page.tsx
interface PageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function BlogPost({ params, searchParams }: PageProps) {
const { slug } = await params;
const { page } = await searchParams;
const post = await getPost(slug);
return <article>{post.content}</article>;
}
// Generate static params
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Note: In Next.js 15, params and searchParams are now Promises — always await them.
5. Utility Types You Actually Need
// types/utils.ts
// Make all properties optional
type PartialUser = Partial<User>;
// Make all properties required
type RequiredUser = Required<User>;
// Pick only specific properties
type UserPreview = Pick<User, 'id' | 'name' | 'avatar'>;
// Omit specific properties
type UserWithoutPassword = Omit<User, 'password'>;
// Make specific properties optional
type UpdateUserInput = Partial<Pick<User, 'name' | 'email' | 'avatar'>>;
Real usage:
// When creating a user — id and createdAt are auto-generated
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;
// When updating — everything is optional except id
type UpdateUserInput = { id: string } & Partial<Omit<User, 'id'>>;
6. Environment Variables Type Safety
// lib/env.ts
const requiredEnvVars = [
'MONGODB_URI',
'JWT_SECRET',
'NEXTAUTH_SECRET',
] as const;
type EnvVar = typeof requiredEnvVars[number];
function getEnv(key: EnvVar): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const env = {
mongodbUri: getEnv('MONGODB_URI'),
jwtSecret: getEnv('JWT_SECRET'),
nextAuthSecret: getEnv('NEXTAUTH_SECRET'),
};
Now instead of process.env.MONGODB_URI! everywhere — import from env and get type safety plus runtime validation.
7. Generic Fetch Hook
// hooks/useFetch.ts
import { useState, useEffect } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
async function fetchData() {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: T = await res.json();
if (!cancelled) setState({ data, loading: false, error: null });
} catch (err) {
if (!cancelled) {
setState({ data: null, loading: false, error: String(err) });
}
}
}
fetchData();
return () => { cancelled = true; };
}, [url]);
return state;
}
Usage:
const { data, loading, error } = useFetch<User[]>('/api/users');
The cancelled flag prevents state updates on unmounted components — a common memory leak.
Summary
| Pattern | Why |
|---|---|
| Type API responses | No more any — catch errors at compile time |
| Explicit component props | Self-documenting, refactor-safe components |
| Typed Server Actions | Know exactly what your actions return |
| Page prop types | Next.js 15 requires awaiting params |
| Utility types | Reuse types without duplication |
| Typed env vars | Crash at startup not at runtime |
| Generic hooks | Reusable data fetching with full type safety |
TypeScript feels slow at first. After a month you'll never go back.
I use all these patterns in my Next.js templates:
Get the templates: https://pixelanas.gumroad.com
What TypeScript pattern saved you the most time? Drop it below 👇
Anas — full-stack Next.js developer. X: @pixelanas
Top comments (0)