Cascading dropdowns populate a second <select> based on what the user picks in the first: choose a country and the state list refills, choose a state and the city list refills. In ASP.NET Core MVC you do this by returning the child items from a controller action as JsonResult, then fetching them from the browser and rebuilding the <option> elements when the parent changes. This article builds a complete Country -> State -> City example on .NET 9 / ASP.NET Core MVC using the fetch API and vanilla JavaScript, with no jQuery.
How cascading dropdowns work
The pattern has three moving parts. The server renders the first dropdown with data it already has. The browser listens for the change event on that dropdown, calls a JSON endpoint passing the selected id, and replaces the options of the dependent dropdown with whatever comes back. Each level depends only on the one directly above it, so the same wiring repeats for every additional tier.
Nothing is posted or reloaded while the user navigates the dropdowns. Only a small JSON payload travels over the wire, which keeps the interaction fast and lets you show clear loading and empty states.
Set up the project
Create a standard MVC app on .NET 9. The Program.cs uses minimal hosting and the conventional controller/view routing.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Location}/{action=Index}/{id?}");
app.Run();
Provide the data
A real app would read from EF Core 9 or an API. To keep the sample focused, the controller holds an in-memory model of countries, their states, and each state's cities. The child lookups are keyed by the parent id.
public record Item(int Id, string Name);
private static readonly Item[] Countries =
[
new(1, "India"), new(2, "United States")
];
private static readonly Dictionary<int, Item[]> States = new()
{
[1] = [new(11, "Maharashtra"), new(12, "Karnataka")],
[2] = [new(21, "California"), new(22, "Texas")]
};
private static readonly Dictionary<int, Item[]> Cities = new()
{
[11] = [new(111, "Pune"), new(112, "Mumbai")],
[12] = [new(121, "Bengaluru"), new(122, "Mysuru")],
[21] = [new(211, "Los Angeles"), new(212, "San Diego")],
[22] = [new(221, "Austin"), new(222, "Dallas")]
};
Return child items as JsonResult
Each dependent level gets its own action that returns JsonResult. The action takes the selected parent id, looks up the children, and returns them with Json(...). System.Text.Json serializes the records to camelCase by default, so the client sees id and name.

[HttpGet]
public JsonResult GetStates(int countryId)
{
var states = States.TryGetValue(countryId, out var list)
? list
: [];
return Json(states);
}
[HttpGet]
public JsonResult GetCities(int stateId)
{
var cities = Cities.TryGetValue(stateId, out var list)
? list
: [];
return Json(cities);
}
Returning an empty array rather than null matters: the client can treat "no children" as a normal, empty result instead of an error.
GET or POST, and anti-forgery
These lookups only read data and expose nothing sensitive, so a plain HttpGet is appropriate and cache-friendly. Reserve POST for actions that change state.
If you do expose a cascading endpoint as POST, protect it with an anti-forgery token. Render the token in the view and send it in the request header, then validate it on the action:
[HttpPost]
[ValidateAntiForgeryToken]
public JsonResult GetStatesSecure(int countryId) =>
Json(States.GetValueOrDefault(countryId, []));
On the client you would read the hidden token and add it as a RequestVerificationToken header. For read-only cascades, GET keeps things simpler.
Wire up the view with the fetch API
The view renders the country dropdown from the model and leaves the state and city dropdowns empty. A single reusable function fetches child items and repopulates a target <select>, handling loading and empty states as it goes.
<select id="country" asp-items="Model.Countries">
<option value="">-- Select country --</option>
</select>
<select id="state"><option value="">-- Select state --</option></select>
<select id="city"><option value="">-- Select city --</option></select>
<script>
async function loadOptions(select, url, placeholder) {
select.disabled = true;
select.innerHTML = `<option value="">Loading...</option>`;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(res.status);
const items = await res.json();
select.innerHTML =
`<option value="">${placeholder}</option>`;
for (const it of items) {
const opt = document.createElement("option");
opt.value = it.id;
opt.textContent = it.name;
select.appendChild(opt);
}
if (items.length === 0) {
select.innerHTML =
`<option value="">-- none available --</option>`;
}
} catch {
select.innerHTML =
`<option value="">-- failed to load --</option>`;
} finally {
select.disabled = false;
}
}
</script>
Now connect the change events. Selecting a country loads states and resets the city list; selecting a state loads cities. Reset the downstream dropdowns whenever a parent changes so the form never shows stale children.
<script>
const country = document.getElementById("country");
const state = document.getElementById("state");
const city = document.getElementById("city");
function reset(select, placeholder) {
select.innerHTML = `<option value="">${placeholder}</option>`;
}
country.addEventListener("change", () => {
reset(city, "-- Select city --");
if (!country.value) return reset(state, "-- Select state --");
loadOptions(state,
`/Location/GetStates?countryId=${country.value}`,
"-- Select state --");
});
state.addEventListener("change", () => {
if (!state.value) return reset(city, "-- Select city --");
loadOptions(city,
`/Location/GetCities?stateId=${state.value}`,
"-- Select city --");
});
</script>
Because fetch is built into every modern browser, this works with no external library. The async/await flow keeps the loading, success, empty, and error branches readable.
Common mistakes
A few recurring errors turn a simple cascade into a frustrating one:
- Returning a partial view or HTML instead of JSON. If the action returns
PartialView(...)or a raw string,res.json()throws while parsing. Keep the endpoint returningJsonResultand let the client build the options. - Not resetting or disabling the child on parent change. When the country changes but the city list still shows the previous country's cities, the form submits an id that no longer belongs to the selection. Always reset every downstream dropdown, not just the one immediately below.
- Fighting the JSON casing.
System.Text.Jsonemits camelCase, so readit.idandit.nameon the client, notit.Id. Do not reach for[FromQuery]attributes to "fix" binding; a simpleint countryIdparameter binds from the query string automatically. - Firing a request on every keystroke. Cascades belong on the
changeevent of a<select>. If you build an autocomplete-style input instead, debounce the input so you are not hammering the endpoint on every letter. - No error handling. A dropped connection or a 500 should degrade to a visible "failed to load" state, never a silently empty list that looks like valid data.
Secure and validate the endpoints
Even read-only lookups deserve a little discipline. Keep GET endpoints for reads and POST for state changes, and when you do use POST, require an anti-forgery token. Render it once in the view and send it as a header from fetch:
const token =
document.querySelector('input[name="__RequestVerificationToken"]').value;
await fetch("/Location/GetStatesSecure", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"RequestVerificationToken": token
},
body: `countryId=${encodeURIComponent(country.value)}`
});
Just as important, never trust the incoming id. Validate that the selected parent exists and that the requested child actually belongs to it before returning data, so a hand-crafted request cannot enumerate ids it should not see:
[HttpGet]
public JsonResult GetCities(int stateId)
{
if (!Cities.ContainsKey(stateId))
return Json(Array.Empty<Item>());
return Json(Cities[stateId]);
}
Accessibility and caching
Because the dependent <select> changes without a page reload, tell assistive technology what is happening. Set aria-busy="true" and disabled on the target while it loads, then clear both when the options arrive; the sample already disables the select in loadOptions, so adding select.setAttribute("aria-busy", "true") alongside it is enough. Progressive enhancement also means the first dropdown should render from server data, so the page is usable before the script runs.
Finally, geographic lists rarely change, so let the browser cache them. Because the endpoints are GET requests keyed by parent id, adding Response.Headers.CacheControl = "public, max-age=3600" on the action lets repeat selections skip the round trip entirely.
Key takeaways
- Cascading dropdowns fill a child
<select>from the parent's selected value over AJAX, with no page reload. - Return child items from a controller action as
JsonResultusingJson(...); return an empty array, nevernull. - Use the
fetchAPI with vanilla JavaScript on .NET 9 MVC; jQuery is not required. - Use
HttpGetfor read-only lookups; use POST with[ValidateAntiForgeryToken]only when the action changes state. - Always reset downstream dropdowns when a parent changes to avoid stale selections.
- Show loading, empty, and error states so the user always knows what the control is doing.
Frequently asked questions
What is the difference between JsonResult and Json() in ASP.NET Core?
JsonResult is the action result type; Json(...) is the helper on Controller that creates one. Declaring the return type as JsonResult and calling return Json(data) is the idiomatic way to send JSON from an MVC controller on .NET 9.
Do I need jQuery for cascading dropdowns?
No. The fetch API is available in every modern browser and covers the whole flow: call the endpoint, await the JSON, and rebuild the options. jQuery's $.getJSON was only ever a convenience wrapper around the same idea.
Should the cascade endpoint use GET or POST?
Use GET for read-only lookups; it is simpler and cacheable. Use POST only when the request changes server state, and in that case add [ValidateAntiForgeryToken] and send the token in a request header.
How do I handle a parent with no children?
Return an empty array from the action and check items.length on the client. Show an explicit "none available" option so the empty state is obvious rather than looking like a broken control.
Comments (0)
No comments yet — be the first to share your thoughts.