Standardizing API Responses

First Steps and Foundations

Welcome 👋 We’re at the very beginning of building a production-grade e-commerce backend with Next.js and PostgreSQL. In this lesson, you won’t connect to a database or implement business logic yet. Instead, you’ll lay down the structural rules that every API endpoint in this project will follow.

By the end of this lesson, you’ll understand how our backend defines a strict API contract, how we enforce that contract with reusable response helpers, how to safely parse request bodies, and how routing works in the Next.js App Router. These foundations might seem simple, but they’re what keep large systems consistent and predictable as they grow.

Since this is the first lesson in the course path, we’re starting from a clean slate and building our shared language from scratch.

Defining a Strict API Contract

Every backend needs a contract — a shared agreement about what responses look like. If one endpoint returns raw JSON, another returns { result: ... }, and a third returns { data: ... }, your frontend becomes fragile and inconsistent.

In this project, that contract lives in src/lib/types/api.ts. This file contains only TypeScript types — no runtime logic — but it defines the structure that every route must follow.

Let’s build it piece by piece.

First, we define the allowed error codes.

export type ApiErrorCode =
  | 'VALIDATION_ERROR'
  | 'NOT_FOUND'
  | 'CONFLICT'
  | 'DB_ERROR'
  | 'INTERNAL_ERROR';
  • ApiErrorCode is a narrow union type. This means only these exact string values are allowed — nothing else. You cannot accidentally return "NotFound" or "bad_request" because TypeScript will reject it.
  • This constraint matters because src/lib/http/response.ts imports and depends on this type. If the contract becomes too loose, you lose compile-time safety. If it’s incorrect, the app won’t compile cleanly.
  • By centralizing error codes here, we ensure the entire backend speaks the same error vocabulary.

Next, we define the shape of successful responses.

export interface ApiSuccess<T> {
  data: T;
  meta?: { timestamp: string };
}
  • ApiSuccess<T> is generic, which means it can wrap any payload type. Whether you return a product list or an order object, it always lives under data.
  • The meta field is optional, but when present, it contains a timestamp string. This gives us a consistent place for metadata like request timing or debugging context.
  • Wrapping payloads inside data prevents ambiguity. The frontend always knows where the real response lives.

Now we define the structure of error responses.

export interface ApiError {
  error: { code: ApiErrorCode; message: string; details?: unknown };
  meta?: { timestamp: string };
}
  • Instead of returning raw error strings, we wrap errors inside an error object. This keeps failure responses clearly separated from success responses.
  • code is machine-readable and restricted to ApiErrorCode, which keeps the system consistent.
  • message is human-readable and intended for display or logging.
  • details is optional and typed as unknown, which allows flexibility for validation errors or debugging context without locking the API into a rigid shape.

Finally, we define the full API response type.

export type ApiResponse<T> = ApiSuccess<T> | ApiError;
  • ApiResponse<T> is a union type. It guarantees that every response is either a success envelope or an error envelope — never both and never raw data.
  • This union becomes extremely powerful in TypeScript-aware clients because they can narrow the type based on whether error exists.
  • This type formalizes the contract. Every endpoint must conform to it.

At this point, we have defined the language of our backend. Next, we enforce it.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal