Building User-Friendly Error Pages
Picture an e-commerce site where users encounter a generic "404 Not Found" page when they mistype a product URL. Instead of guiding them back to shopping, the default error page shows a stark white screen with technical jargon. They leave. You lose a sale.
Custom error pages turn these moments into opportunities. A well-designed 404 page can suggest popular products, provide a search bar, or redirect to categories. A 500 error page reassures users that the problem is temporary and you're aware of it. You control the user experience even when things go wrong.
You'll build custom error pages that handle different HTTP status codes, log errors properly, prevent information leakage, and maintain a professional appearance. You'll learn the difference between status code pages and exception handlers, and when to use each.
Handling Status Codes with UseStatusCodePages
The UseStatusCodePages middleware intercepts responses with HTTP status codes between 400 and 599 that don't have a body. This catches 404 Not Found, 403 Forbidden, and similar errors without needing exception handling. Your application sets a status code, and the middleware takes over to provide a better response.
Start with a basic implementation that redirects to custom error pages based on status code. This gives you full control over the HTML and styling for each error type.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
// Handles unhandled exceptions
app.UseExceptionHandler("/Error/500");
// Handles specific status codes (404, 403, etc.)
app.UseStatusCodePagesWithReExecute("/Error/{0}");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
UseStatusCodePagesWithReExecute re-executes the pipeline at the specified path, passing the status code as a parameter. This means your Error controller can render different views for 404 vs 403 vs 500. The original URL and status code are preserved in special headers and features.
using Microsoft.AspNetCore.Mvc;
namespace MyWebApp.Controllers;
public class ErrorController : Controller
{
private readonly ILogger<ErrorController> _logger;
public ErrorController(ILogger<ErrorController> logger)
{
_logger = logger;
}
[Route("Error/{statusCode}")]
public IActionResult Index(int statusCode)
{
// Prevent error page caching
Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
Response.Headers["Pragma"] = "no-cache";
Response.Headers["Expires"] = "0";
var statusCodeData = HttpContext.Features
.Get<Microsoft.AspNetCore.Diagnostics.IStatusCodeReExecuteFeature>();
if (statusCodeData != null)
{
_logger.LogWarning(
"Status code {StatusCode} for path {OriginalPath}",
statusCode,
statusCodeData.OriginalPath);
}
ViewData["StatusCode"] = statusCode;
ViewData["ErrorMessage"] = GetErrorMessage(statusCode);
return statusCode switch
{
404 => View("NotFound"),
403 => View("Forbidden"),
500 => View("ServerError"),
_ => View("Error")
};
}
private string GetErrorMessage(int statusCode) => statusCode switch
{
404 => "The page you're looking for doesn't exist.",
403 => "You don't have permission to access this resource.",
500 => "Something went wrong on our end. We're working on it.",
_ => "An error occurred processing your request."
};
}
The controller extracts information about the original request using IStatusCodeReExecuteFeature. This includes the path that triggered the error, which is useful for logging. Setting cache control headers prevents browsers from caching error pages—you don't want users seeing a cached 404 after you've created the missing page.
Catching Unhandled Exceptions
UseExceptionHandler middleware catches exceptions that escape your application code and prevents stack traces from leaking to users. In development, you want detailed error information. In production, you want secure, user-friendly error pages and server-side logging.
[Route("Error/500")]
public IActionResult ServerError()
{
Response.Headers["Cache-Control"] = "no-cache, no-store";
var exceptionFeature = HttpContext.Features
.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
if (exceptionFeature != null)
{
var exception = exceptionFeature.Error;
var path = exceptionFeature.Path;
// Log the full exception with stack trace
_logger.LogError(
exception,
"Unhandled exception on path {Path}: {Message}",
path,
exception.Message);
// In production, track metrics
// IncrementErrorCounter("unhandled_exception", exception.GetType().Name);
}
// Show user-friendly message without details
return View("ServerError");
}
The exception handler logs complete details server-side but shows users only a generic message. IExceptionHandlerFeature gives you access to the caught exception and the path where it occurred. Never include exception details, stack traces, or internal paths in the response body—these are security risks.
Creating Error Page Views
Error views should match your site's design while providing helpful guidance. A good 404 page includes navigation options, a search box, or popular links. A 500 error page reassures users and provides contact information if they need support.
@{
ViewData["Title"] = "Page Not Found";
}
<div class="error-page">
<div class="error-content">
<h1>404</h1>
<h2>Page Not Found</h2>
<p>The page you're looking for doesn't exist or has been moved.</p>
<div class="error-actions">
<a href="/" class="btn btn-primary">Go Home</a>
<a href="/search" class="btn btn-secondary">Search</a>
</div>
<div class="popular-links">
<h3>Popular Pages</h3>
<ul>
<li><a href="/products">Products</a></li>
<li><a href="/about">About Us</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</div>
</div>
</div>
The view provides clear options for users to continue browsing. Avoid technical jargon or developer-focused messages. Focus on getting users back to working parts of your site quickly.
Security & Safety Corner
Never expose stack traces in production: Stack traces reveal internal code structure, file paths, and dependencies. Attackers use this information to find vulnerabilities. Always use UseDeveloperExceptionPage only when Environment.IsDevelopment() is true.
Prevent error page caching: Browsers and CDNs cache responses aggressively. If a user hits a 404, they might see that cached error page even after you create the missing content. Set explicit no-cache headers on all error responses using Response.Headers["Cache-Control"] = "no-cache, no-store".
Sanitize logged data: When logging exceptions, be careful about sensitive data in exception messages or request data. Don't log passwords, tokens, credit card numbers, or personally identifiable information. Use structured logging with redaction for sensitive fields. See Microsoft's logging documentation at https://learn.microsoft.com/en-us/aspnet/core/fundamentals/logging/
Try It Yourself
Create a minimal API with custom error pages that handles both status codes and exceptions. You'll see how middleware order affects error handling and how to provide different responses for different error types.
1. dotnet new web -n ErrorPageDemo
2. cd ErrorPageDemo
3. Replace Program.cs with the code below
4. dotnet run
5. Test: curl http://localhost:5000/notfound
6. Test: curl http://localhost:5000/error
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Exception handler MUST come before StatusCodePages
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "text/plain";
context.Response.Headers["Cache-Control"] = "no-cache";
var error = context.Features
.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
if (error != null)
{
var logger = context.RequestServices
.GetRequiredService<ILogger<Program>>();
logger.LogError(error.Error, "Unhandled exception at {Path}",
error.Path);
await context.Response.WriteAsync(
"500 - An error occurred. Our team has been notified.\n");
}
});
});
// Handle status codes without exceptions
app.UseStatusCodePages(async context =>
{
context.HttpContext.Response.ContentType = "text/plain";
context.HttpContext.Response.Headers["Cache-Control"] = "no-cache";
var statusCode = context.HttpContext.Response.StatusCode;
var message = statusCode switch
{
404 => "404 - Page not found. Check the URL and try again.",
403 => "403 - Access forbidden. You lack required permissions.",
_ => $"{statusCode} - An error occurred."
};
await context.HttpContext.Response.WriteAsync(message);
});
// Routes that demonstrate different errors
app.MapGet("/", () => "App is running! Try /notfound or /error");
app.MapGet("/notfound", (HttpContext ctx) =>
{
ctx.Response.StatusCode = 404;
return Results.Empty;
});
app.MapGet("/error", () =>
{
throw new InvalidOperationException("Simulated exception for testing");
});
app.Run();
$ curl http://localhost:5000/notfound
404 - Page not found. Check the URL and try again.
$ curl http://localhost:5000/error
500 - An error occurred. Our team has been notified.
The example shows how UseExceptionHandler catches thrown exceptions while UseStatusCodePages handles explicit status codes. The order matters—exception handler must come first to catch exceptions before they become status codes.
Production Integration
In production ASP.NET Core apps, error handling integrates with logging infrastructure, monitoring systems, and content delivery. You need more than just friendly pages—you need observability and proper HTTP semantics.
Structured logging with correlation IDs: Include a unique correlation ID in each error response and log entry. Users can provide this ID to support, and you can trace the full request through your logs. Use IHttpContextAccessor to access the trace identifier: HttpContext.TraceIdentifier.
Metrics and alerting: Increment counters for each error type. Track 404 rate (might indicate broken links), 500 rate (application errors), and 403 rate (authorization issues). Alert when error rates spike—this often indicates deployment problems or attacks. Use built-in metrics with ASP.NET Core's diagnostic counters.
CDN considerations: If using a CDN, configure it to respect your Cache-Control headers on error pages. Some CDNs cache 404s by default, which causes problems. Set explicit TTL=0 for error responses in your CDN configuration. Also consider serving error pages directly from CDN edge locations for better availability during outages.
See Microsoft's error handling documentation at https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling for detailed configuration options and production patterns.