How the Flow Works
-
The view renders normally, but form submissions or button clicks are intercepted by JavaScript.
-
JavaScript builds a request with fetch or jQuery $.ajax, setting Content-Type: application/json.
-
Routing selects the controller action, [FromBody] model-binds the JSON payload, validation runs, and the action returns JsonResult via the Json() helper.
-
The client callback receives the JSON and updates the DOM.
Server-Side Setup
Actions return IActionResult and use Json() to serialize an object. For POST requests you must validate the antiforgery token, because AJAX does not automatically send the hidden form token. Register the header name in Program.cs:
builder.Services.AddAntiforgery(options => options.HeaderName = "RequestVerificationToken");
Decorate POST actions with [ValidateAntiForgeryToken] and check ModelState.IsValid. With [ApiController] an invalid model returns 400 automatically; plain controllers must check manually. Unlike classic MVC 5, Json() allows GET requests by default and there is no JsonRequestBehavior.AllowGet or HttpContext.Current.
Client-Side Calls
Read the token from the hidden field the form tag helper renders, then send it in the request header. Use fetch (built in, no library) or jQuery if the project already references it.
Code Example
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SubmitOrder([FromBody] OrderViewModel model)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var orderId = await _repository.SaveAsync(model);
return Json(new { success = true, orderId });
}
<form asp-controller="Order" asp-action="SubmitOrder" method="post">
<button type="button" id="submitBtn">Place Order</button>
</form>
<script>
document.getElementById('submitBtn').addEventListener('click', async () => {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
const order = { productId: 5, quantity: 2 };
const response = await fetch('/Order/SubmitOrder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify(order)
});
const result = await response.json();
if (result.success) console.log('Order', result.orderId);
});
</script>
Security and Best Practices
-
Prevent over-posting: bind a view model to [FromBody] parameters, never an entity class directly.
-
CSRF protection: always send the antiforgery token on state-changing POST/PUT/DELETE requests; read-only GET calls do not need it.
-
Validate input: check ModelState.IsValid and return BadRequest for failures so the client can display field errors.
-
Error handling: wrap calls in try/catch on the client and return meaningful HTTP status codes (200, 400, 404, 500) from the server.
-
Keep responses small: return only the data the client needs, not entire view models with navigation properties.