DEV Community

Anas Sheikh
Anas Sheikh

Posted on

TypeScript Patterns I Use in Every Next.js Project

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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[]>;
}
Enter fullscreen mode Exit fullscreen mode
// 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' };
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }));
}
Enter fullscreen mode Exit fullscreen mode

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'>>;
Enter fullscreen mode Exit fullscreen mode

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'>>;
Enter fullscreen mode Exit fullscreen mode

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'),
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const { data, loading, error } = useFetch<User[]>('/api/users');
Enter fullscreen mode Exit fullscreen mode

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)