12 min read

Building Type-Safe APIs with Next.js and TypeScript

Learn how to build robust, type-safe APIs using Next.js App Router, TypeScript, and modern validation libraries for bulletproof backend development

Alex Rodriguez

Alex Rodriguez

@alexrodriguezdev
Building Type-Safe APIs with Next.js and TypeScript

Building Type-Safe APIs with Next.js and TypeScript

In the world of modern web development, APIs are the backbone of almost every application. Yet too often, we treat them as an afterthought, leading to runtime errors, inconsistent data structures, and frustrated developers. Today, we'll explore how to build truly type-safe APIs that catch errors before they reach production.

The Problem with Traditional APIs

Traditional REST APIs often suffer from several issues:

  • Runtime Type Errors: No guarantee that the data matches expected types
  • Inconsistent Responses: Different endpoints returning different structures
  • Poor Developer Experience: Manual typing and error-prone integrations
  • Documentation Drift: API docs becoming outdated quickly

Let's solve these problems with a type-first approach.

Setting Up the Foundation

Project Structure

First, let's establish a clean structure for our API endpoints:

src/
├── app/
│   └── api/
│       ├── users/
│       │   ├── route.ts
│       │   └── [id]/
│       │       └── route.ts
│       └── posts/
│           └── route.ts
├── lib/
│   ├── api/
│   │   ├── types.ts
│   │   ├── validation.ts
│   │   └── responses.ts
│   └── db/
└── schemas/

Essential Dependencies

{
  "dependencies": {
    "zod": "^3.22.4",
    "superjson": "^2.2.1"
  },
  "devDependencies": {
    "@types/node": "^20.10.0"
  }
}

Building Type-Safe Schemas

Defining Your Data Models

Start by defining your data models with Zod schemas:

// src/schemas/user.ts
import { z } from "zod";
 
export const userSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1, "Name is required"),
  avatar: z.string().url().optional(),
  role: z.enum(["USER", "ADMIN", "MODERATOR"]),
  preferences: z.object({
    theme: z.enum(["light", "dark", "system"]),
    notifications: z.boolean(),
    language: z.string().length(2),
  }),
  createdAt: z.date(),
  updatedAt: z.date(),
});
 
export const createUserSchema = userSchema.omit({
  id: true,
  createdAt: true,
  updatedAt: true,
});
 
export const updateUserSchema = userSchema
  .omit({
    id: true,
    createdAt: true,
    updatedAt: true,
  })
  .partial();
 
// Infer TypeScript types from schemas
export type User = z.infer<typeof userSchema>;
export type CreateUserData = z.infer<typeof createUserSchema>;
export type UpdateUserData = z.infer<typeof updateUserSchema>;

API Response Schemas

Create consistent response structures:

// src/lib/api/responses.ts
import { z } from "zod";
 
export const successResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
  z.object({
    success: z.literal(true),
    data: dataSchema,
    meta: z
      .object({
        timestamp: z.string(),
        requestId: z.string(),
      })
      .optional(),
  });
 
export const errorResponseSchema = z.object({
  success: z.literal(false),
  error: z.object({
    code: z.string(),
    message: z.string(),
    details: z.record(z.any()).optional(),
  }),
  meta: z
    .object({
      timestamp: z.string(),
      requestId: z.string(),
    })
    .optional(),
});
 
export const paginatedResponseSchema = <T extends z.ZodTypeAny>(
  itemSchema: T,
) =>
  z.object({
    success: z.literal(true),
    data: z.array(itemSchema),
    pagination: z.object({
      page: z.number(),
      limit: z.number(),
      total: z.number(),
      totalPages: z.number(),
      hasNext: z.boolean(),
      hasPrev: z.boolean(),
    }),
  });
 
// Type helpers
export type SuccessResponse<T> = {
  success: true;
  data: T;
  meta?: {
    timestamp: string;
    requestId: string;
  };
};
 
export type ErrorResponse = z.infer<typeof errorResponseSchema>;
export type PaginatedResponse<T> = {
  success: true;
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
    hasNext: boolean;
    hasPrev: boolean;
  };
};

Creating Type-Safe API Routes

Building a Robust API Handler

Create a wrapper that handles validation, errors, and responses:

// src/lib/api/handler.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { v4 as uuidv4 } from "uuid";
 
interface ApiConfig<TBody = any, TQuery = any> {
  bodySchema?: z.ZodSchema<TBody>;
  querySchema?: z.ZodSchema<TQuery>;
  requireAuth?: boolean;
}
 
export function createApiHandler<TBody = any, TQuery = any>(
  config: ApiConfig<TBody, TQuery> = {},
) {
  return function <TResponse>(
    handler: (params: {
      body: TBody;
      query: TQuery;
      params: Record<string, string>;
      userId?: string;
      request: NextRequest;
    }) => Promise<TResponse>,
  ) {
    return async function (
      request: NextRequest,
      context: { params: Record<string, string> },
    ) {
      const requestId = uuidv4();
      const timestamp = new Date().toISOString();
 
      try {
        // Parse and validate query parameters
        const url = new URL(request.url);
        const queryParams = Object.fromEntries(url.searchParams.entries());
        
        let validatedQuery = queryParams as TQuery;
        if (config.querySchema) {
          const queryResult = config.querySchema.safeParse(queryParams);
          if (!queryResult.success) {
            return NextResponse.json(
              {
                success: false,
                error: {
                  code: "VALIDATION_ERROR",
                  message: "Invalid query parameters",
                  details: queryResult.error.flatten(),
                },
                meta: { timestamp, requestId },
              } satisfies ErrorResponse,
              { status: 400 },
            );
          }
          validatedQuery = queryResult.data;
        }
 
        // Parse and validate request body
        let validatedBody = undefined as TBody;
        if (config.bodySchema && request.method !== "GET") {
          try {
            const rawBody = await request.json();
            const bodyResult = config.bodySchema.safeParse(rawBody);
            if (!bodyResult.success) {
              return NextResponse.json(
                {
                  success: false,
                  error: {
                    code: "VALIDATION_ERROR",
                    message: "Invalid request body",
                    details: bodyResult.error.flatten(),
                  },
                  meta: { timestamp, requestId },
                } satisfies ErrorResponse,
                { status: 400 },
              );
            }
            validatedBody = bodyResult.data;
          } catch (error) {
            return NextResponse.json(
              {
                success: false,
                error: {
                  code: "INVALID_JSON",
                  message: "Request body must be valid JSON",
                },
                meta: { timestamp, requestId },
              } satisfies ErrorResponse,
              { status: 400 },
            );
          }
        }
 
        // Authentication check (simplified)
        let userId: string | undefined;
        if (config.requireAuth) {
          const authHeader = request.headers.get("Authorization");
          if (!authHeader?.startsWith("Bearer ")) {
            return NextResponse.json(
              {
                success: false,
                error: {
                  code: "UNAUTHORIZED",
                  message: "Authentication required",
                },
                meta: { timestamp, requestId },
              } satisfies ErrorResponse,
              { status: 401 },
            );
          }
          // In real app, validate JWT and extract userId
          userId = "user-123"; // Placeholder
        }
 
        // Execute the handler
        const result = await handler({
          body: validatedBody,
          query: validatedQuery,
          params: context.params,
          userId,
          request,
        });
 
        // Return successful response
        return NextResponse.json(
          {
            success: true,
            data: result,
            meta: { timestamp, requestId },
          } satisfies SuccessResponse<TResponse>,
          { status: 200 },
        );
      } catch (error) {
        console.error("API Error:", error);
        
        return NextResponse.json(
          {
            success: false,
            error: {
              code: "INTERNAL_ERROR",
              message: "An unexpected error occurred",
            },
            meta: { timestamp, requestId },
          } satisfies ErrorResponse,
          { status: 500 },
        );
      }
    };
  };
}

Implementing API Routes

Now let's create type-safe API routes:

// src/app/api/users/route.ts
import { z } from "zod";
import { createApiHandler } from "@/lib/api/handler";
import { userSchema, createUserSchema } from "@/schemas/user";
import { paginatedResponseSchema } from "@/lib/api/responses";
 
const getUsersQuerySchema = z.object({
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(20),
  search: z.string().optional(),
  role: z.enum(["USER", "ADMIN", "MODERATOR"]).optional(),
});
 
// GET /api/users
export const GET = createApiHandler({
  querySchema: getUsersQuerySchema,
  requireAuth: true,
})<PaginatedResponse<User>>(async ({ query, userId }) => {
  // Simulated database query
  const users = await getUsers({
    page: query.page,
    limit: query.limit,
    search: query.search,
    role: query.role,
    requestingUserId: userId,
  });
 
  return {
    success: true,
    data: users.items,
    pagination: {
      page: query.page,
      limit: query.limit,
      total: users.total,
      totalPages: Math.ceil(users.total / query.limit),
      hasNext: query.page * query.limit < users.total,
      hasPrev: query.page > 1,
    },
  };
});
 
// POST /api/users
export const POST = createApiHandler({
  bodySchema: createUserSchema,
  requireAuth: true,
})<User>(async ({ body, userId }) => {
  // Create user in database
  const newUser = await createUser(body, userId);
  return newUser;
});
 
// Type-safe database operations (simplified)
async function getUsers(filters: {
  page: number;
  limit: number;
  search?: string;
  role?: string;
  requestingUserId: string;
}) {
  // Database query implementation
  return {
    items: [] as User[],
    total: 0,
  };
}
 
async function createUser(userData: CreateUserData, creatorId: string) {
  // Database creation implementation
  return {} as User;
}

Individual Resource Routes

// src/app/api/users/[id]/route.ts
import { z } from "zod";
import { createApiHandler } from "@/lib/api/handler";
import { userSchema, updateUserSchema } from "@/schemas/user";
 
const userParamsSchema = z.object({
  id: z.string().uuid("Invalid user ID format"),
});
 
// GET /api/users/[id]
export const GET = createApiHandler({
  requireAuth: true,
})<User>(async ({ params, userId }) => {
  const { id } = userParamsSchema.parse(params);
  
  const user = await getUserById(id, userId);
  if (!user) {
    throw new Error("User not found");
  }
  
  return user;
});
 
// PATCH /api/users/[id]
export const PATCH = createApiHandler({
  bodySchema: updateUserSchema,
  requireAuth: true,
})<User>(async ({ body, params, userId }) => {
  const { id } = userParamsSchema.parse(params);
  
  const updatedUser = await updateUser(id, body, userId);
  return updatedUser;
});
 
// DELETE /api/users/[id]
export const DELETE = createApiHandler({
  requireAuth: true,
})<{ deleted: boolean }>(async ({ params, userId }) => {
  const { id } = userParamsSchema.parse(params);
  
  await deleteUser(id, userId);
  return { deleted: true };
});

Client-Side Type Safety

Creating Type-Safe API Clients

Build a client that maintains type safety:

// src/lib/api/client.ts
import { z } from "zod";
 
class ApiClient {
  private baseUrl: string;
  private token?: string;
 
  constructor(baseUrl: string = "/api") {
    this.baseUrl = baseUrl;
  }
 
  setToken(token: string) {
    this.token = token;
  }
 
  private async request<T>(
    endpoint: string,
    options: RequestInit = {},
  ): Promise<T> {
    const url = `${this.baseUrl}${endpoint}`;
    const headers: HeadersInit = {
      "Content-Type": "application/json",
      ...options.headers,
    };
 
    if (this.token) {
      headers.Authorization = `Bearer ${this.token}`;
    }
 
    const response = await fetch(url, {
      ...options,
      headers,
    });
 
    const data = await response.json();
 
    if (!response.ok || !data.success) {
      throw new ApiError(data.error || "Request failed", response.status);
    }
 
    return data.data;
  }
 
  // Type-safe methods
  async getUsers(query?: {
    page?: number;
    limit?: number;
    search?: string;
    role?: string;
  }): Promise<PaginatedResponse<User>> {
    const searchParams = new URLSearchParams();
    if (query) {
      Object.entries(query).forEach(([key, value]) => {
        if (value !== undefined) {
          searchParams.append(key, String(value));
        }
      });
    }
 
    const endpoint = `/users${searchParams.toString() ? `?${searchParams}` : ""}`;
    return this.request<PaginatedResponse<User>>(endpoint);
  }
 
  async getUserById(id: string): Promise<User> {
    return this.request<User>(`/users/${id}`);
  }
 
  async createUser(data: CreateUserData): Promise<User> {
    return this.request<User>("/users", {
      method: "POST",
      body: JSON.stringify(data),
    });
  }
 
  async updateUser(id: string, data: UpdateUserData): Promise<User> {
    return this.request<User>(`/users/${id}`, {
      method: "PATCH",
      body: JSON.stringify(data),
    });
  }
 
  async deleteUser(id: string): Promise<{ deleted: boolean }> {
    return this.request<{ deleted: boolean }>(`/users/${id}`, {
      method: "DELETE",
    });
  }
}
 
class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public code?: string,
  ) {
    super(message);
    this.name = "ApiError";
  }
}
 
export const apiClient = new ApiClient();
export { ApiError };

React Hook Integration

Create hooks for seamless React integration:

// src/hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
 
export function useUsers(params?: {
  page?: number;
  limit?: number;
  search?: string;
  role?: string;
}) {
  return useQuery({
    queryKey: ["users", params],
    queryFn: () => apiClient.getUsers(params),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}
 
export function useUser(id: string) {
  return useQuery({
    queryKey: ["user", id],
    queryFn: () => apiClient.getUserById(id),
    enabled: !!id,
  });
}
 
export function useCreateUser() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: apiClient.createUser,
    onSuccess: () => {
      // Invalidate and refetch users list
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });
}
 
export function useUpdateUser() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateUserData }) =>
      apiClient.updateUser(id, data),
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({ queryKey: ["user", id] });
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });
}

Advanced Patterns

Generic CRUD Operations

Create reusable patterns for common operations:

// src/lib/api/crud.ts
import { z } from "zod";
import { createApiHandler } from "./handler";
 
export function createCrudHandlers<
  TEntity,
  TCreateSchema extends z.ZodSchema,
  TUpdateSchema extends z.ZodSchema,
>(config: {
  entityName: string;
  createSchema: TCreateSchema;
  updateSchema: TUpdateSchema;
  repository: {
    findMany: (filters: any) => Promise<{ items: TEntity[]; total: number }>;
    findById: (id: string) => Promise<TEntity | null>;
    create: (data: z.infer<TCreateSchema>) => Promise<TEntity>;
    update: (id: string, data: z.infer<TUpdateSchema>) => Promise<TEntity>;
    delete: (id: string) => Promise<void>;
  };
}) {
  const list = createApiHandler({
    requireAuth: true,
  })<PaginatedResponse<TEntity>>(async ({ query }) => {
    const result = await config.repository.findMany(query);
    return {
      success: true,
      data: result.items,
      pagination: {
        // ... pagination logic
      },
    };
  });
 
  const getById = createApiHandler({
    requireAuth: true,
  })<TEntity>(async ({ params }) => {
    const entity = await config.repository.findById(params.id);
    if (!entity) {
      throw new Error(`${config.entityName} not found`);
    }
    return entity;
  });
 
  const create = createApiHandler({
    bodySchema: config.createSchema,
    requireAuth: true,
  })<TEntity>(async ({ body }) => {
    return config.repository.create(body);
  });
 
  const update = createApiHandler({
    bodySchema: config.updateSchema,
    requireAuth: true,
  })<TEntity>(async ({ params, body }) => {
    return config.repository.update(params.id, body);
  });
 
  const remove = createApiHandler({
    requireAuth: true,
  })<{ deleted: boolean }>(async ({ params }) => {
    await config.repository.delete(params.id);
    return { deleted: true };
  });
 
  return { list, getById, create, update, remove };
}

Testing Type-Safe APIs

Write comprehensive tests for your APIs:

// src/app/api/users/route.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { testApiHandler } from "next-test-api-route-handler";
import * as handler from "./route";
 
describe("/api/users", () => {
  describe("GET", () => {
    it("should return paginated users", async () => {
      await testApiHandler({
        handler,
        test: async ({ fetch }) => {
          const res = await fetch({
            method: "GET",
            headers: {
              Authorization: "Bearer valid-token",
            },
          });
 
          expect(res.status).toBe(200);
          
          const data = await res.json();
          expect(data).toMatchObject({
            success: true,
            data: expect.any(Array),
            pagination: {
              page: expect.any(Number),
              limit: expect.any(Number),
              total: expect.any(Number),
            },
          });
        },
      });
    });
 
    it("should validate query parameters", async () => {
      await testApiHandler({
        handler,
        test: async ({ fetch }) => {
          const res = await fetch({
            method: "GET",
            url: "/api/users?page=invalid",
            headers: {
              Authorization: "Bearer valid-token",
            },
          });
 
          expect(res.status).toBe(400);
          
          const data = await res.json();
          expect(data.success).toBe(false);
          expect(data.error.code).toBe("VALIDATION_ERROR");
        },
      });
    });
  });
});

Benefits and Best Practices

Key Benefits

  1. Compile-Time Safety: Catch type errors before deployment
  2. Excellent DX: Auto-completion and intelligent error messages
  3. Self-Documenting: Types serve as living documentation
  4. Consistent APIs: Standardized request/response patterns
  5. Easy Refactoring: Type system helps with safe code changes

Best Practices

  1. Schema-First Design: Start with Zod schemas, derive types
  2. Consistent Error Handling: Use standardized error responses
  3. Validation at Boundaries: Validate all inputs and outputs
  4. Comprehensive Testing: Test both happy paths and edge cases
  5. Documentation: Generate API docs from your schemas

Conclusion

Building type-safe APIs with Next.js and TypeScript transforms the development experience. By leveraging Zod for validation, creating consistent response patterns, and maintaining type safety from backend to frontend, we create robust, maintainable APIs that scale with our applications.

The initial setup investment pays dividends in reduced bugs, improved developer experience, and easier maintenance. Start with one endpoint, establish your patterns, and gradually apply them across your entire API surface.

Resources


Ready to implement type-safe APIs in your project? Start with our starter template that includes all these patterns out of the box.

Share this post:

You might also like

Stay Updated
Get the latest posts delivered right to your inbox

We respect your privacy. Unsubscribe at any time.