DEV Community

Dean Walters
Dean Walters

Posted on

Building a Unified API Response Architecture (ASP.NET Minimal API + Next.js)

How to eliminate inconsistent errors, simplify validation, and keep backend & frontend perfectly in sync

Introduction

If your frontend error-handling is overloaded with conditional logic…

if (error.response?.data?.message) { ... }
else if (typeof error === "string") { ... }
else if (error.status === 401) { ... }
Enter fullscreen mode Exit fullscreen mode

…it’s not your fault - it’s your backend being inconsistent.
This article shows how to design a fully unified response architecture using:

  • ASP.NET Minimal API

  • Next.js

  • a Response Envelope

  • a Rich Error Object pattern

  • fully synced C# -> TypeScript enums
    The result is predictable, type-safe, frontend-friendly APIs.


The Problem: Inconsistent API Responses

Most projects begin with “quick” error returns:

return BadRequest("Email invalid");
throw new Exception("Something went wrong");
return Unauthorized();`
But later you realize your API responds with:
`{ "error": "UserNotFound" }
{ "message": "Invalid email" }
Something went wrong
<html>401 Unauthorized</html>
Enter fullscreen mode Exit fullscreen mode

This causes:

1. Unpredictable API response structures
Different shapes break interceptors and fetch wrappers.

2. Frontend cannot centralize error handling
You end up parsing strings, objects, arrays, or HTML.

3. Validation errors are irregular
No unified shape, varying formats.

4. No type syncing
A typo in backend enums silently breaks the frontend.


Solution: The Unified Response Envelope

A single response format used by all endpoints — successful or not.
It should:

  • indicate success/failure

  • include a strict error code (enum)

  • standardize validation errors

  • never leak stack traces

  • be predictable for any client

  • fully map to TypeScript

Defining the ApiResponse Contract

Before diving into middleware, we need a consistent backend response type.

public class ApiResponse<T>
{
    public bool Success { get; set; }

    [JsonConverter(typeof(JsonStringEnumConverter))]
    public ErrorCodes? ErrorCode { get; set; }

    public string? Message { get; set; }

    public T? Data { get; set; }

    public List<ValidationError>? ValidationErrors { get; set; }

    public static ApiResponse<T> Ok(T data, string? message = null) =>
        new() { Success = true, Data = data, Message = message };

    public static ApiResponse<T> Fail(ErrorCodes code, string? message = null) =>
        new() { Success = false, ErrorCode = code, Message = message };

    public static ApiResponse<T> ValidationError(List<ValidationError> errors) =>
        new()
        {
            Success = false,
            ErrorCode = ErrorCodes.ValidationError,
            Message = "Validation failed",
            ValidationErrors = errors
        };
}
Enter fullscreen mode Exit fullscreen mode

This class ensures that all API responses follow the same structure.
Success always returns Data, errors always return ErrorCode and Message.
Why it matters:

  • One response shape -> simple front-end logic

  • Strong typing between backend & frontend

  • No accidental HTML or string leakage

  • UI becomes fully predictable


Backend Architecture

1. Global Exception Middleware

public class ErrorMiddleware
{
    private readonly RequestDelegate _next;

    public ErrorMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch
        {
            context.Response.ContentType = "application/json";
            context.Response.StatusCode = 500;

            await context.Response.WriteAsJsonAsync(
                ApiResponse<object>.Fail(
                    ErrorCodes.ServerError,
                    "Internal server error")
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This middleware wraps the entire pipeline in a try/catch.
Any unhandled exception becomes a structured 500 JSON response.
Key takeaways:

  • No stack traces or raw exceptions escape

  • Backend never returns unhandled errors

  • Frontend receives the same error shape every time

2. 401 & 403 Override

public class ApiAuthorizationHandler : IAuthorizationMiddlewareResultHandler
{
    private readonly AuthorizationMiddlewareResultHandler _default = new();

    public async Task HandleAsync(
        RequestDelegate next,
        HttpContext context,
        AuthorizationPolicy policy,
        PolicyAuthorizationResult result)
    {
        if (result.Challenged)
        {
            context.Response.StatusCode = 401;
            await context.Response.WriteAsJsonAsync(
                ApiResponse<object>.Fail(ErrorCodes.Unauthorized, "Authentication required")
            );
            return;
        }

        if (result.Forbidden)
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsJsonAsync(
                ApiResponse<object>.Fail(ErrorCodes.Forbidden, "Access denied")
            );
            return;
        }

        await _default.HandleAsync(next, context, policy, result);
    }
}
Enter fullscreen mode Exit fullscreen mode

This handler replaces raw 401/403 status codes with clean JSON envelopes.
Key takeaways:

  • No more raw status codes

  • Mobile and SPA clients get clean JSON

  • Auth errors match your global error format

3. API-Scoped 404 Handler
ASP.NET returns default HTML for unknown endpoints.
We override it only for /api/** paths.

app.Use(async (context, next) =>
{
    await next();

    if (context.Request.Path.StartsWithSegments("/api") &&
        context.Response.StatusCode == 404 &&
        !context.Response.HasStarted)
    {
        await context.Response.WriteAsJsonAsync(
            ApiResponse<object>.Fail(ErrorCodes.NotFound, "API endpoint not found")
        );
    }
});
Enter fullscreen mode Exit fullscreen mode

Key takeaways:

  • No HTML 404 pages for API routes

  • Every API failure is a JSON envelope

Why This Architecture Works

- Predictable
Every response follows one structure.

- Strongly typed
Backend enums → frontend TS types.

- Easier debugging
You always know where things went wrong.

- Cleaner UI code
One global error handler instead of dozens.

- More secure
No stack traces or framework-generated HTML.


Frontend Architecture (Next.js)

1. apiFetch: Unified Fetch Wrapper

export async function apiFetch<T>(
  url: string,
  options: RequestInit = {}
): Promise<ApiResponse<T>> {
  const res = await fetch(API_URL + url, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...(options.headers || {}),
    },
    credentials: "include",
  });

  let json: ApiResponse<T>;

  try {
    json = await res.json();
  } catch {
    return {
      success: false,
      errorCode: "ServerError",
      message: "Invalid JSON from server",
    };
  }

  return json;
}
Enter fullscreen mode Exit fullscreen mode

This wrapper ensures that every request returns an ApiResponse<T>.
If the server returns invalid JSON, we still wrap the error safely.
Key points:

  • No front-end crashes from malformed responses

  • Always returns a predictable structure

2. Rich Error Object Pattern (ApiError)

export class ApiError extends Error {
  public error: ErrorResponse;

  constructor(response: ApiResponse<any>) {
    super("API Error");
    Object.setPrototypeOf(this, ApiError.prototype);
    this.name = "ApiError";

    this.error = {
      errorCode: response.errorCode!,
      message: response.message,
      validationErrors: response.validationErrors,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Throwing plain strings or raw fetch errors is unhelpful.
Instead, we throw a structured, typed error object that UIs can use safely.
Takeaways:

  • UI receives rich error metadata

  • Works perfectly with Next.js server/client components

  • Makes error boundaries more useful

2. Using apiFetch in a Service Module
Example:

export const userService = {
  async getUser(id: number) {
    const res = await apiFetch<{ id: number; name: string }>(
      `/api/users/${id}`
    );

    if (!res.success) {
      throw new ApiError(res);
    }

    return res.data;
  },
};
Enter fullscreen mode Exit fullscreen mode

Why this is clean

All API logic is centralized - components receive only clean data or ApiError.

4. Syncing TypeScript With C# Enums
To eliminate typos and keep error codes synchronized between backend & frontend.

app.MapGet("/ts/error-codes.ts", () =>
{
    var names = Enum.GetNames(typeof(ErrorCodes));
    var lines = names.Select(n => $"  | \"{n}\"").ToList();
    lines[0] = lines[0].Replace("|", "");

    var ts = "export type ErrorCode =\n" + string.Join("\n", lines) + ";\n";

    return Results.Text(ts, "text/plain");
});
Enter fullscreen mode Exit fullscreen mode

Result:
You generate the file:

curl http://localhost:5000/ts/error-codes.ts > lib/errorCodes.ts
Enter fullscreen mode Exit fullscreen mode

Now the frontend has:

export type ErrorCode =
  | "Unauthorized"
  | "Forbidden"
  | "NotFound"
  | "ValidationError"
  | "Conflict"
  | "ServerError";
Enter fullscreen mode Exit fullscreen mode

5. Frontend Error Map

export const errorMap: Record<ErrorCode, string> = {
  Unauthorized: "Need Authentication",
  Forbidden: "Access Denied",
  NotFound: "Not Found",
  ValidationError: "Validation Error",
  Conflict: "Conflict Detected",
  ServerError: "Server Error",
};
Enter fullscreen mode Exit fullscreen mode

Why this matters:
TypeScript forces developers to map every error code — or fail the build.


Developer Experience: Before vs After

Before:

  • inconsistent API formats

  • HTML error pages

  • unpredictable validation formats

  • lots of repeated try/catch logic

  • difficult UI error states

After:

  • one universal response structure

  • strict typing across stack

  • easy global error handler

  • predictable UX

  • safer exceptions

  • more reliable monitoring/logging

When NOT to Use an Envelope

This pattern is not ideal for:

  • Public REST APIs (Stripe style)

  • OpenAPI-first API-first schemas

  • Strictly RESTful HATEOAS-driven systems

But for:

  • SPAs

  • SSR apps

  • mobile apps

  • internal tools

  • B2B dashboards

  • admin panels

…it’s a massive improvement.


Examples

  • All example code: Github
    You can clone that and start developing with this boilerplate.

  • Used in my recent production project: Neon Royale
    And integrating the API with the frontend was a smooth and straightforward process thanks to this architecture.

Top comments (0)