To bind a dropdown list in ASP.NET Core MVC, expose a List<SelectListItem> (or a SelectList) on your view model and render it with the <select asp-for="CountryId" asp-items="Model.Countries"></select> tag helper. The tag helper writes the <option> elements, marks the current value as selected, and posts the chosen id back to your action through model binding. In this article you will build a strongly-typed form on .NET 9 / ASP.NET Core MVC that binds a dropdown from a service, sets a selected value, adds a default option, binds an enum, validates the choice, and reads the posted id in the POST action.
Start with a strongly-typed view model
Never stuff dropdown data into ViewBag. A view model keeps the selected value and the option list together, gives you compile-time safety, and makes validation attributes possible.
public class ProductViewModel
{
[Display(Name = "Category")]
[Range(1, int.MaxValue, ErrorMessage = "Please select a category.")]
public int CategoryId { get; set; }
public ProductStatus Status { get; set; }
// The dropdown option lists (never posted back, so no validation)
public IEnumerable<SelectListItem> Categories { get; set; } = [];
public IEnumerable<SelectListItem> Statuses { get; set; } = [];
}
CategoryId holds both the initial selection and the value the form posts back. Categories is the option source. Only the scalar id travels over the wire; the list is rebuilt on the server. This split matters: the browser posts one integer, and everything the user saw in the dropdown is reconstructed server-side, which is why the option lists carry no validation attributes and why you must repopulate them after a failed post.
Keeping the data on the model instead of ViewBag also means the Razor view stays free of casts. ViewBag.Categories is dynamic, so a typo compiles fine and blows up at runtime; Model.Categories is checked at build time and gives you IntelliSense in the view.
Build the SelectListItem list
A SelectListItem has a Text (what the user sees) and a Value (what gets posted). You can build the list by hand, project it from data with LINQ, or wrap a collection in a SelectList.
var categories = new List<SelectListItem>
{
new() { Value = "1", Text = "Laptops" },
new() { Value = "2", Text = "Phones" },
new() { Value = "3", Text = "Accessories" }
};
SelectList is a shortcut when you already have domain objects. Passing the value and text property names does the projection for you, and an optional fourth argument sets the selected value:
var list = new SelectList(categories, "Id", "Name", selectedId);
Both SelectList and List<SelectListItem> implement IEnumerable<SelectListItem>, so either type works with asp-items. Use the raw list when you want full control over each option (for example disabling one), and SelectList when a simple id/name projection is all you need.
Grouping options
To render <optgroup> blocks, set the Group property on each SelectListItem. The tag helper reads it and nests the options for you:
new SelectListItem { Value = "1", Text = "Laptops",
Group = new SelectListGroup { Name = "Computers" } };
Populate the dropdown from a database
In a real app the options come from a service backed by EF Core 9. Inject the service into the controller and project the entities into SelectListItems. Keep the query lean — select only the id and the display text.
public interface ICategoryService
{
Task<IReadOnlyList<Category>> GetCategoriesAsync();
}
// In the controller
var categories = await categoryService.GetCategoriesAsync();
model.Categories = categories.Select(c => new SelectListItem
{
Value = c.Id.ToString(),
Text = c.Name
});
Because the tag helper compares each option's Value against CategoryId, setting model.CategoryId = 2 before returning the view is all it takes to preselect "Phones". Note the comparison is string-based: the tag helper renders CategoryId as text and matches it against each option's Value, so an int id of 2 matches the "2" value. There is no need to hand-set Selected = true on any SelectListItem — in fact, when you use asp-for, the bound property wins and per-item Selected flags are ignored.
Injecting the service is standard constructor injection. Register it in Program.cs with AddScoped and let the framework supply it:
builder.Services.AddScoped<ICategoryService, CategoryService>();
Render the dropdown with the select tag helper
The <select> tag helper is the modern, preferred approach in ASP.NET Core MVC. asp-for binds the element to a model property (generating the correct name, id, and validation attributes) and asp-items supplies the options.

Add a static first <option> inside the element to get a "-- Select --" prompt. Because its value is empty, it fails the [Range] check and forces a real choice:
<select asp-for="CategoryId" asp-items="Model.Categories" class="form-select">
<option value="">-- Select a category --</option>
</select>
<span asp-validation-for="CategoryId" class="text-danger"></span>
What about Html.DropDownListFor?
The older Html.DropDownListFor helper still works and produces the same markup:
@Html.DropDownListFor(m => m.CategoryId, Model.Categories, "-- Select a category --")
Prefer the tag helper: it reads like HTML, plays nicely with designers, and keeps validation attributes consistent with the rest of your form. Reach for DropDownListFor only when you are maintaining an existing Razor codebase that already uses HTML helpers.
Bind an enum to a dropdown
For a fixed set of values, an enum is cleaner than a database table. Html.GetEnumSelectList<T>() turns any enum into ready-made options, honoring [Display] names on the members.
public enum ProductStatus
{
[Display(Name = "In stock")] InStock,
[Display(Name = "Back-ordered")] BackOrdered,
Discontinued
}
// Build the options for the view
model.Statuses = Html.GetEnumSelectList<ProductStatus>();
<select asp-for="Status" asp-items="Model.Statuses" class="form-select"></select>
The tag helper preselects the option matching the current Status value automatically. Because the values come from the enum members themselves, you never have to keep a database table and a switch statement in sync.
Validate the selected value
The [Range(1, int.MaxValue)] attribute on CategoryId is what turns "please pick something" into a real rule. The empty prompt option posts an empty string, which binds to 0, which fails the range check. Wire up client-side validation so the user gets instant feedback before the round trip:
<span asp-validation-for="CategoryId" class="text-danger"></span>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
asp-validation-for renders the message container, and the jQuery Unobtrusive Validation partial reads the data-val-* attributes the tag helper emitted. Server-side, ModelState.IsValid enforces the same rule even if a client bypasses the script, so the check is never only cosmetic.
Read the posted value in the POST action
When the form posts, model binding maps the selected <option value> back onto CategoryId. The option lists are not posted, so rebuild them before you re-render the view on a validation failure.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Index(ProductViewModel model)
{
if (!ModelState.IsValid)
{
await PopulateListsAsync(model); // refill Categories + Statuses
return View(model);
}
// model.CategoryId and model.Status now hold the user's choices
return RedirectToAction(nameof(Index));
}
Forgetting to repopulate the lists is the single most common dropdown bug: the POST fails validation, the view re-renders, and the dropdown is empty because Categories came back null. A clean pattern is a private PopulateListsAsync helper called from both the GET and the failed-POST paths, so the list-building logic lives in exactly one place. On success, follow the Post/Redirect/Get pattern with RedirectToAction to avoid a duplicate submission if the user refreshes.
The same rule applies to the enum dropdown, but Html.GetEnumSelectList<T>() is cheap and stateless, so you can rebuild Statuses inline without a service call.
Key takeaways
- Put the selected value and the
IEnumerable<SelectListItem>on a strongly-typed view model, notViewBag. - Prefer
<select asp-for="..." asp-items="...">overHtml.DropDownListForin ASP.NET Core MVC. - Setting the bound property (e.g.
CategoryId = 2) before rendering preselects the matching option. - Add an empty-value
<option>for a "-- Select --" prompt and let[Range]enforce a real choice. - Use
Html.GetEnumSelectList<T>()to bind an enum without a lookup table. - Always rebuild the option lists in the POST action before returning the view on a validation failure.
Frequently asked questions
Why is my dropdown empty after a failed POST?
The <option> list is never posted back to the server, only the selected value. When ModelState is invalid and you return the same view, you must repopulate the SelectListItem collection first, otherwise it is null and the dropdown renders with no options.
Should I use the select tag helper or Html.DropDownListFor?
Use the <select asp-for asp-items> tag helper for new ASP.NET Core MVC code. It is more readable, integrates cleanly with client-side validation, and is the current idiom on .NET 9. Html.DropDownListFor remains supported for legacy Razor views.
How do I set the selected item in the dropdown?
Set the model property that asp-for binds to. The tag helper compares each option's Value against that property and marks the match as selected. You do not set Selected = true on individual SelectListItems when using asp-for.
How do I bind an enum to a dropdown?
Call Html.GetEnumSelectList<YourEnum>() to generate the options and pass the result to asp-items. Decorate enum members with [Display(Name = "...")] to control the visible text.
Comments (0)
No comments yet — be the first to share your thoughts.