Implementing DTOs in Next.js: A Practical Guide to API Verification

You've built a Next.js application and everything seems to work fine. But as your project grows, you start noticing issues: API responses contain sensitive data you never meant to expose, type safety becomes a nightmare, and every API change feels like walking through a minefield. Sound familiar?

Many developers face this exact situation. As one developer shared on Reddit, "I have made the terrible mistake of not using DTOs when returning objects to the client and I'm seriously getting punished for that now."

The good news? Data Transfer Objects (DTOs) can solve these challenges, and implementing them in Next.js is more straightforward than you might think.

Understanding DTOs in the Next.js Context

Before diving into implementation, let's clarify what DTOs are and why they're particularly valuable in Next.js applications.

A Data Transfer Object (DTO) is a simple object that carries data between processes, typically used to transfer data between your API and client. Think of it as a contract that specifies exactly what data should be sent and received.

Why DTOs Matter in Next.js

  1. Type Safety: Next.js applications often handle complex data structures. DTOs ensure that data flowing through your application adheres to expected shapes.

  2. Security: By explicitly defining what data should be transferred, DTOs prevent accidental exposure of sensitive information.

  3. API Evolution: As your application grows, DTOs provide a clean interface for managing API changes without breaking existing functionality.

  4. Validation: DTOs serve as a perfect place to implement validation logic, ensuring data integrity before it reaches your business logic.

Common Concerns Addressed

Many developers worry about over-engineering when implementing DTOs. As one developer noted on Reddit, "When examining GitHub open-source projects, I rarely see clean implementations of DTOs, which makes me wonder if I'm over-engineering my approach."

This concern is valid, but the key lies in understanding when and how to use DTOs effectively. The goal isn't to create DTOs for every data transfer but to use them strategically where they provide clear benefits.

Implementing DTOs in Next.js

Let's walk through a practical implementation that addresses common pain points while maintaining clean architecture.

1. Setting Up the Basic Structure

First, let's create a basic DTO structure using TypeScript interfaces:

// types/dtos/user.dto.ts
export interface UserDTO {
    id: string;
    name: string;
    email: string;
    // Note: Sensitive fields like password are intentionally omitted
}

// types/dtos/createUser.dto.ts
export interface CreateUserDTO {
    name: string;
    email: string;
    password: string; // Only included in creation, never returned
}

2. Implementing Validation with Zod

Zod has emerged as a popular choice for DTO validation in the Next.js ecosystem. Here's how to implement it:

// schemas/user.schema.ts
import { z } from 'zod';

export const UserSchema = z.object({
    id: z.string().uuid(),
    name: z.string().min(2),
    email: z.string().email(),
});

export const CreateUserSchema = z.object({
    name: z.string().min(2),
    email: z.string().email(),
    password: z.string().min(8),
});

// Type inference
export type User = z.infer<typeof UserSchema>;
export type CreateUser = z.infer<typeof CreateUserSchema>;

3. Creating a DTO Mapper

To address the common pain point of maintaining DTOs during API changes, implement a flexible mapper:

// utils/dto.mapper.ts
export class DTOMapper<T> {
    static toDTO<T extends object, U extends object>(
        data: T,
        excludeFields: (keyof T)[] = []
    ): U {
        const dto: Partial<U> = {};
        
        for (const key in data) {
            if (!excludeFields.includes(key)) {
                dto[key as keyof U] = data[key];
            }
        }
        
        return dto as U;
    }
}

4. Implementing API Routes with DTO Validation

Here's how to use DTOs in your Next.js API routes:

// pages/api/users/[id].ts
import { NextApiRequest, NextApiResponse } from 'next';
import { UserSchema } from '@/schemas/user.schema';
import { DTOMapper } from '@/utils/dto.mapper';

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
) {
    try {
        const user = await fetchUserFromDatabase(req.query.id);
        
        // Transform database model to DTO
        const userDTO = DTOMapper.toDTO(user, ['password', 'refreshToken']);
        
        // Validate DTO before sending
        const validatedUser = UserSchema.parse(userDTO);
        
        return res.status(200).json(validatedUser);
    } catch (error) {
        if (error instanceof z.ZodError) {
            return res.status(400).json({ errors: error.errors });
        }
        return res.status(500).json({ error: 'Internal Server Error' });
    }
}

5. Handling Conditional Fields

One common frustration developers face is dealing with null fields in DTOs. Here's a solution that addresses this pain point:

// types/dtos/user.dto.ts
export interface UserDTO {
    id: string;
    name: string;
    email: string;
    profile?: {
        avatar?: string;
        bio?: string;
    };
}

// utils/dto.mapper.ts
export class DTOMapper<T> {
    static toDTOWithoutNulls<T extends object, U extends object>(
        data: T,
        excludeFields: (keyof T)[] = []
    ): U {
        const dto: Partial<U> = {};
        
        for (const key in data) {
            if (
                !excludeFields.includes(key) && 
                data[key] !== null && 
                data[key] !== undefined
            ) {
                dto[key as keyof U] = data[key];
            }
        }
        
        return dto as U;
    }
}

6. Implementing Type-Safe API Calls

To ensure type safety when making API calls from your frontend:

// services/api.service.ts
import { UserDTO, CreateUserDTO } from '@/types/dtos';

export class ApiService {
    static async getUser(id: string): Promise<UserDTO> {
        const response = await fetch(`/api/users/${id}`);
        const data = await response.json();
        
        if (!response.ok) {
            throw new Error(data.error || 'Failed to fetch user');
        }
        
        return UserSchema.parse(data);
    }
    
    static async createUser(userData: CreateUserDTO): Promise<UserDTO> {
        const response = await fetch('/api/users', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(userData),
        });
        
        const data = await response.json();
        
        if (!response.ok) {
            throw new Error(data.error || 'Failed to create user');
        }
        
        return UserSchema.parse(data);
    }
}

Best Practices and Common Pitfalls

Do's:

  1. Use OpenAPI Specifications: Define your API structure upfront using OpenAPI specifications. This provides a single source of truth and makes it easier to maintain DTOs as your API evolves.

  2. Implement Selective Field Returns: Create mechanisms to return only requested fields, reducing unnecessary data transfer:

export interface QueryOptions {
    select?: string[];
}

static async getUser(id: string, options?: QueryOptions): Promise<Partial<UserDTO>> {
    const queryString = options?.select ? `?fields=${options.select.join(',')}` : '';
    const response = await fetch(`/api/users/${id}${queryString}`);
    // ... rest of the implementation
}
  1. Version Your DTOs: When making breaking changes, version your DTOs to maintain backward compatibility:

// types/dtos/user.v2.dto.ts
export interface UserDTOV2 extends UserDTO {
    preferences: UserPreferences;
}

Don'ts:

  1. Avoid Business Logic in DTOs: Keep DTOs focused on data transfer only. Business logic belongs in service layers.

  2. Don't Expose Sensitive Data: Always explicitly define what data should be transferred, never automatically map database models to DTOs.

  3. Don't Ignore Validation: Always validate DTOs both on the client and server side to ensure data integrity.

Conclusion

Implementing DTOs in Next.js might seem like over-engineering at first, but when done right, it provides a robust foundation for your application's data handling. By following these patterns and best practices, you can avoid common pitfalls and create a maintainable, type-safe API layer.

Remember, as one developer noted, "I have made the terrible mistake of not using DTOs when returning objects to the client and I'm seriously getting punished for that now." Don't wait until your application grows complex to implement proper DTO patterns – start with a solid foundation from the beginning.

For further reading and examples, check out:

Raymond Yeh

Raymond Yeh

Published on 06 March 2025

Get engineers' time back from marketing!

Don't let managing a blog on your site get in the way of your core product.

Wisp empowers your marketing team to create and manage content on your website without consuming more engineering hours.

Get started in few lines of codes.

Choosing a CMS
Related Posts
Validating API Response with Zod

Validating API Response with Zod

Learn why validating API responses with Zod is indispensable for TypeScript apps, especially when handling unexpected data formats from third-party APIs in production.

Read Full Story
How to Use tRPC with Next.js 15 (App Router)

How to Use tRPC with Next.js 15 (App Router)

Struggling with type safety across web and mobile apps? Discover how tRPC with Next.js 15 delivers end-to-end type safety and superior DX. Learn the optimal monorepo setup for scalable development.

Read Full Story
How to Handle Authentication Across Separate Backend and Frontend for Next.js Website

How to Handle Authentication Across Separate Backend and Frontend for Next.js Website

Learn how to implement secure authentication in Next.js with Express backend using httpOnly cookies, JWT tokens, and middleware. Complete guide with code examples.

Read Full Story
Loading...