JSON in ASP.NET Core: System.Text.Json and Web APIs
Last updated:
ASP.NET Core has first-class JSON support built into its HTTP pipeline. The [ApiController] attribute wires up automatic JSON binding and validation, System.Text.Json handles serialization with zero external dependencies, and minimal APIs make it trivial to return JSON from a single lambda. This guide covers every layer — controller-based APIs, serializer configuration, custom converters, ProblemDetails error handling, and reading raw JSON without a model class.
Controller-Based JSON APIs
Apply [ApiController] and [Route] to a controller class. ASP.NET Core automatically deserializes the request body from JSON and serializes return values to JSON — no explicit [FromBody] or JsonResult required.
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _products;
public ProductsController(IProductService products)
=> _products = products;
// GET /api/products → JSON array
[HttpGet]
public async Task<ActionResult<IEnumerable<ProductDto>>> GetAll()
=> Ok(await _products.GetAllAsync());
// GET /api/products/42 → JSON object or 404
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id)
{
var product = await _products.GetByIdAsync(id);
return product is null ? NotFound() : Ok(product);
}
// POST /api/products — body is automatically bound from JSON
[HttpPost]
public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request)
{
// [ApiController] returns 400 automatically if ModelState is invalid
var product = await _products.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
// PUT /api/products/42
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateProductRequest request)
{
if (!await _products.UpdateAsync(id, request))
return NotFound();
return NoContent(); // 204
}
// DELETE /api/products/42
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
if (!await _products.DeleteAsync(id))
return NotFound();
return NoContent();
}
}
// DTO records (C# 9+)
public record ProductDto(int Id, string Name, decimal Price);
public record CreateProductRequest(string Name, decimal Price);
public record UpdateProductRequest(string Name, decimal Price);System.Text.Json Options
Configure JsonSerializerOptions globally in Program.cs via AddJsonOptions. These options apply to all controller actions and JsonSerializer calls that use the DI-registered options instance.
// Program.cs
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers().AddJsonOptions(opts =>
{
var json = opts.JsonSerializerOptions;
// PascalCase → camelCase (FirstName → firstName)
json.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
// Accept any casing from clients (firstName, FIRSTNAME, etc.)
json.PropertyNameCaseInsensitive = true;
// Omit null fields from responses
json.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
// Serialize enum values as strings ("Active") not integers (1)
json.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
// Allow trailing commas and // comments in incoming JSON
json.AllowTrailingCommas = true;
json.ReadCommentHandling = JsonCommentHandling.Skip;
// Indent output in Development (optional)
json.WriteIndented = builder.Environment.IsDevelopment();
});
// Per-property attributes override global options:
public class ProductDto
{
[JsonPropertyName("product_id")] // override naming policy
public int Id { get; set; }
[JsonIgnore] // never serialized
public string InternalCode { get; set; } = "";
[JsonPropertyOrder(1)] // control field order
public string Name { get; set; } = "";
[JsonConverter(typeof(DecimalRoundingConverter))]
public decimal Price { get; set; }
}Minimal APIs
Minimal APIs (introduced in .NET 6) define HTTP endpoints directly in Program.cs as lambda functions. Returning a POCO automatically serializes it to JSON; use TypedResults for compile-time correctness and better OpenAPI generation.
var app = builder.Build();
// Implicit JSON serialization — return a POCO directly
app.MapGet("/products", async (IProductService svc) =>
await svc.GetAllAsync());
// TypedResults — explicit, type-safe, OpenAPI-friendly
app.MapGet("/products/{id:int}", async (int id, IProductService svc) =>
{
var product = await svc.GetByIdAsync(id);
return product is null
? TypedResults.NotFound()
: TypedResults.Ok(product);
});
// POST — body is bound automatically from JSON
app.MapPost("/products", async (CreateProductRequest req, IProductService svc) =>
{
var product = await svc.CreateAsync(req);
return TypedResults.Created($"/products/{product.Id}", product);
});
// Results.Json for explicit serializer control
app.MapGet("/raw", () =>
Results.Json(
new { status = "ok", timestamp = DateTimeOffset.UtcNow },
statusCode: 200
));
// Route groups — share prefix and middleware
var api = app.MapGroup("/api/v2").RequireAuthorization();
api.MapGet("/me", (HttpContext ctx) => TypedResults.Ok(ctx.User.Identity?.Name));
app.Run();Custom JSON Converters
Implement JsonConverter<T> when the default serialization does not match your wire format — for example, serializing a money value as a plain decimal number, or a DateOnly in a specific format.
using System.Text.Json;
using System.Text.Json.Serialization;
// Serialize Money struct as a plain decimal number
// { "price": 9.99 } instead of { "price": { "amount": 9.99, "currency": "USD" } }
public class MoneyConverter : JsonConverter<Money>
{
public override Money Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
var amount = reader.GetDecimal();
return new Money(amount, "USD");
}
public override void Write(
Utf8JsonWriter writer,
Money value,
JsonSerializerOptions options)
{
writer.WriteNumberValue(value.Amount);
}
}
// DateOnly converter (not built-in before .NET 7)
public class DateOnlyConverter : JsonConverter<DateOnly>
{
private const string Format = "yyyy-MM-dd";
public override DateOnly Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
=> DateOnly.ParseExact(reader.GetString()!, Format);
public override void Write(
Utf8JsonWriter writer,
DateOnly value,
JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString(Format));
}
// Register globally in Program.cs
builder.Services.AddControllers().AddJsonOptions(opts =>
{
opts.JsonSerializerOptions.Converters.Add(new MoneyConverter());
opts.JsonSerializerOptions.Converters.Add(new DateOnlyConverter());
});
// Or per property
public class OrderDto
{
[JsonConverter(typeof(MoneyConverter))]
public Money Total { get; set; }
[JsonConverter(typeof(DateOnlyConverter))]
public DateOnly OrderDate { get; set; }
}ProblemDetails Error Responses
ProblemDetails (RFC 9457) is the standard JSON error format for HTTP APIs. ASP.NET Core returns it automatically for validation errors and can be configured to return it for all unhandled exceptions.
// Program.cs — enable ProblemDetails for all error responses
builder.Services.AddProblemDetails(); // .NET 7+
builder.Services.AddControllers();
var app = builder.Build();
app.UseExceptionHandler(); // catches unhandled exceptions → ProblemDetails 500
app.UseStatusCodePages(); // catches 404/405/etc. → ProblemDetails
// [ApiController] already returns ValidationProblemDetails for 400:
// {
// "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
// "title": "One or more validation errors occurred.",
// "status": 400,
// "errors": { "Name": ["The Name field is required."] }
// }
// Return ProblemDetails manually from a controller
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
if (id <= 0)
return Problem(
detail: "Product ID must be a positive integer.",
title: "Invalid product ID",
statusCode: StatusCodes.Status400BadRequest,
type: "https://jsonic.io/errors/invalid-id"
);
var product = _products.GetById(id);
if (product is null)
return NotFound(); // automatically becomes ProblemDetails
return Ok(product);
}
// Custom exception handler for domain exceptions
app.UseExceptionHandler(exApp => exApp.Run(async ctx =>
{
var exception = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
var pd = exception switch
{
NotFoundException ex => new ProblemDetails
{
Status = 404, Title = "Not found", Detail = ex.Message
},
ValidationException ex => new ValidationProblemDetails(ex.Errors)
{
Status = 400
},
_ => new ProblemDetails
{
Status = 500, Title = "An unexpected error occurred."
}
};
ctx.Response.StatusCode = pd.Status ?? 500;
ctx.Response.ContentType = "application/problem+json";
await ctx.Response.WriteAsJsonAsync(pd);
}));Newtonsoft.Json vs System.Text.Json
Choose based on your needs. For new projects on .NET 6+, System.Text.Json is the right default. Migrate to Newtonsoft.Json only when you require features it does not support natively.
// Keep using Newtonsoft.Json — install the adapter package:
// dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson
builder.Services.AddControllers()
.AddNewtonsoftJson(opts =>
{
opts.SerializerSettings.ContractResolver =
new CamelCasePropertyNamesContractResolver();
opts.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
opts.SerializerSettings.ReferenceLoopHandling =
ReferenceLoopHandling.Ignore; // EF navigation property loops
});
// --- Attribute mapping ---
// Newtonsoft.Json System.Text.Json
// [JsonProperty("name")] → [JsonPropertyName("name")]
// [JsonIgnore] → [JsonIgnore]
// [JsonConverter(typeof(...))] → [JsonConverter(typeof(...))]
// --- API mapping ---
// JsonConvert.SerializeObject(obj) JsonSerializer.Serialize(obj, opts)
// JsonConvert.DeserializeObject<T>(s) JsonSerializer.Deserialize<T>(s, opts)
// JObject.Parse(json) JsonNode.Parse(json) or JsonDocument.Parse
// JArray JsonArray
// JToken JsonNode
// System.Text.Json advantages:
// • No external dependency
// • 2–3x faster, lower allocations
// • Span<T>-based, works with pipes/streams
// Newtonsoft.Json advantages:
// • JObject mutable DOM
// • JsonConverter with full control
// • Reference loop handling built-in
// • More lenient by default (comments, trailing commas)
// • Polymorphic deserialization via TypeNameHandlingReading Raw JSON with JsonDocument
When the incoming JSON schema is unknown at compile time, parse it with JsonDocument for read-only inspection or JsonNode for mutable access. Both are part of System.Text.Json — no external packages required.
using System.Text.Json;
using System.Text.Json.Nodes;
// --- JsonDocument (read-only, pooled, efficient) ---
string json = """{ "id": 42, "tags": ["sale", "new"], "price": 9.99 }""";
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
int id = root.GetProperty("id").GetInt32(); // 42
decimal price = root.GetProperty("price").GetDecimal(); // 9.99
foreach (var tag in root.GetProperty("tags").EnumerateArray())
Console.WriteLine(tag.GetString()); // "sale", "new"
// Safe access without throwing
if (root.TryGetProperty("discount", out var discount))
Console.WriteLine(discount.GetDecimal());
// Check node kind
var kind = root.GetProperty("price").ValueKind; // JsonValueKind.Number
// --- JsonNode (mutable DOM, .NET 6+) ---
var node = JsonNode.Parse(json)!;
node["id"] = 99; // modify
node["tags"]![0] = "clearance"; // modify array element
node["newField"] = "added"; // add field
Console.WriteLine(node.ToJsonString());
// In an action method — accept raw body as stream
[HttpPost("webhook")]
public async Task<IActionResult> Webhook()
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
var eventType = doc.RootElement.GetProperty("event").GetString();
// route to handler based on eventType...
return Ok();
}Key Definitions
- [ApiController]
- Attribute applied to a controller class that enables automatic JSON body binding without
[FromBody], automatic 400 responses for model validation failures, and inferred HTTP semantics. It marks the controller as an API controller (as opposed to an MVC page controller) and activates several behaviors that reduce boilerplate in API development. - model binding
- The process by which ASP.NET Core reads data from an HTTP request — route values, query strings, form fields, and JSON body — and maps it to C# method parameters. For
[ApiController]controllers, complex type parameters are automatically bound from the JSON body. The binding system validates data annotations ([Required],[Range], etc.) and populatesModelState. - JsonSerializerOptions
- Configuration class for
System.Text.Jsonthat controls naming policies, null handling, enum serialization, custom converters, indentation, comment handling, and more. Options are configured globally viaAddJsonOptions()inProgram.csor passed directly toJsonSerializer.Serialize()andJsonSerializer.Deserialize()for one-off calls. - ProblemDetails
- A standardized JSON structure for HTTP error responses, defined in RFC 7807 (updated by RFC 9457). Contains
type,title,status,detail, andinstancefields. ASP.NET Core usesValidationProblemDetails(a subclass) for 400 validation errors and plainProblemDetailsfor other error statuses. Returned withContent-Type: application/problem+json. - minimal API
- A lightweight ASP.NET Core hosting model introduced in .NET 6 that defines HTTP endpoints directly in
Program.csusingapp.MapGet(),app.MapPost(), etc., without controller classes or action methods. Returning a POCO from a lambda handler automatically serializes it to JSON. Minimal APIs share the same middleware pipeline, dependency injection, andSystem.Text.Jsonstack as controller-based APIs.
FAQ
How does [ApiController] handle JSON automatically in ASP.NET Core?
The [ApiController] attribute enables several automatic behaviors. For JSON deserialization, it infers [FromBody] on complex type parameters — no explicit attribute needed. For JSON serialization, returning a POCO from an action automatically serializes it via System.Text.Json. It also returns 400 Bad Request automatically when model validation fails, with a ValidationProblemDetails JSON body listing all field errors. This reduces boilerplate significantly compared to plain [Controller] classes.
What is System.Text.Json and how is it different from Newtonsoft.Json?
System.Text.Json is Microsoft's built-in JSON library included since .NET 3.0. It is 2–3x faster and allocates significantly less memory than Newtonsoft.Json by using Span<T> and avoiding intermediate string allocations. Key differences: System.Text.Json is strict by default (throws on unknown properties, does not handle reference loops without opt-in). Newtonsoft.Json is more lenient and supports more scenarios out of the box — JObject, JsonPath, reference loop handling, polymorphic serialization. For new projects on .NET 6+, System.Text.Json is the preferred default.
How do I configure camelCase JSON output in ASP.NET Core?
Set PropertyNamingPolicy = JsonNamingPolicy.CamelCase in AddJsonOptions() in Program.cs. This converts PascalCase C# properties (FirstName) to camelCase JSON keys (firstName) for both serialization and deserialization. Also set PropertyNameCaseInsensitive = true so clients can send any casing and it still binds correctly. In .NET 7+ minimal APIs, camelCase is already the default.
How do I return JSON from a minimal API in ASP.NET Core?
Return a POCO directly from the lambda handler — ASP.NET Core serializes it to JSON implicitly. Use TypedResults.Ok(data) for 200, TypedResults.Created(location, data) for 201, TypedResults.NotFound() for 404. TypedResults (introduced in .NET 7) provides compile-time type checking and improves OpenAPI schema generation. Use Results.Json(data, statusCode: 200) for explicit serializer options or non-standard status codes.
What is ProblemDetails and how does ASP.NET Core use it?
ProblemDetails is a standardized error response format defined in RFC 9457. It includes type, title, status, detail, and instance fields, and is returned with Content-Type: application/problem+json. ASP.NET Core returns ValidationProblemDetails automatically for 400 validation errors when [ApiController] is applied. Call builder.Services.AddProblemDetails() and app.UseExceptionHandler() to return ProblemDetails for all unhandled exceptions in .NET 7+. Use return Problem(...) from a controller action to return a custom ProblemDetails response.
How do I write a custom JsonConverter in System.Text.Json?
Create a class extending JsonConverter<T> and override Read() and Write(). In Read(), use methods on Utf8JsonReader (GetString(), GetInt32(), GetDecimal()) to read values and return T. In Write(), use methods on Utf8JsonWriter (WriteStringValue(), WriteNumberValue(), WriteStartObject()) to emit JSON. Register globally via opts.JsonSerializerOptions.Converters.Add(new MyConverter()) or per-property via [JsonConverter(typeof(MyConverter))].
How do I migrate from Newtonsoft.Json to System.Text.Json in ASP.NET Core?
Replace [JsonProperty("name")] with [JsonPropertyName("name")], JObject with JsonNode or JsonDocument, JsonConvert.SerializeObject() with JsonSerializer.Serialize(), and JsonConvert.DeserializeObject<T>() with JsonSerializer.Deserialize<T>(). Watch for: System.Text.Json throws on unknown properties by default (set PropertyNameCaseInsensitive), does not handle reference loops without ReferenceHandler.Preserve, and requires [JsonConstructor] for non-public constructors. To keep Newtonsoft.Json as a drop-in, install Microsoft.AspNetCore.Mvc.NewtonsoftJson and call .AddNewtonsoftJson().
How do I read raw JSON in ASP.NET Core without a model class?
Use JsonDocument.Parse(json) for efficient read-only access — call .GetProperty("key"), .EnumerateArray(), .GetString(), .GetInt32(), and .GetDecimal() on JsonElement nodes. Always wrap in a using block since JsonDocument uses a pooled buffer. For mutable access, use JsonNode.Parse(json) which returns a JsonObject or JsonArray you can modify and re-serialize. In controller actions, receive the body as a Stream and call JsonDocument.ParseAsync(Request.Body).
Further reading and primary sources
- ASP.NET Core Web API — Official ASP.NET Core Web API documentation
- System.Text.Json Docs — System.Text.Json serialization documentation
- Minimal APIs — ASP.NET Core minimal API documentation