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) { ... }
…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>
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
};
}
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")
);
}
}
}
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);
}
}
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")
);
}
});
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;
}
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,
};
}
}
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;
},
};
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");
});
Result:
You generate the file:
curl http://localhost:5000/ts/error-codes.ts > lib/errorCodes.ts
Now the frontend has:
export type ErrorCode =
| "Unauthorized"
| "Forbidden"
| "NotFound"
| "ValidationError"
| "Conflict"
| "ServerError";
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",
};
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)