CRUD con Blazor WebAssembly y ASP.NET Core
Tutorial de CRUD con Blazor WebAssembly, ASP.NET Core, Entity Framework Core y SQL Server, con orientación para .NET 10.
Actualizado el
En este ejemplo vamos a crear un CRUD de películas con Blazor WebAssembly como frontend y ASP.NET Core Web API como backend. La API guardará los datos en SQL Server usando Entity Framework Core.
La idea es sencilla:
- Blazor muestra la lista y los formularios.
- ASP.NET Core expone los endpoints
GET,POST,PUTyDELETE. - Entity Framework Core se encarga de leer y escribir en la base de datos.

Estado del tutorial en 2026
El proyecto original de este tutorial se creó con .NET 5, una versión que dejó de recibir soporte en mayo de 2022. El código que sigue conserva la estructura histórica de la plantilla hospedada de Blazor WebAssembly, con los proyectos Client, Server y Shared.
Esa plantilla hosted ya no está disponible en .NET 8 ni en versiones posteriores, así que no debe tomarse como la forma actual de crear un proyecto. Lo que sigue siendo válido es todo lo que enseña el tutorial: cliente WebAssembly, API HTTP, CRUD, Entity Framework Core, validación y consultas mediante HttpClient.
Para un proyecto nuevo en 2026, la recomendación es .NET 10 LTS y una de estas alternativas:
- Blazor Web App con interactividad
InteractiveWebAssemblyoInteractiveAuto. - Standalone Blazor WebAssembly (
dotnet new blazorwasm) más una API ASP.NET Core separada, si necesitas una SPA completamente estática.
Si no existe un requisito real de ejecutar C# en el navegador, InteractiveServer es la opción más simple.
| Tutorial original | Proyecto nuevo en .NET 10 |
|---|---|
| Plantilla hosted WASM | Blazor Web App o standalone WASM + API |
Client/Server/Shared generados | .Client para componentes WASM y contratos compartidos definidos explícitamente |
Startup.cs + Program.cs | Program.cs con minimal hosting |
| Controladores Web API | Controladores o Minimal APIs |
| .NET 5 | .NET 10 LTS |
Requisitos
- Visual Studio, Visual Studio Code o Rider.
- .NET SDK instalado. Para el proyecto nuevo equivalente, usa .NET 10.
- SQL Server o SQL Server Express.
- Conocimientos básicos de C# y Razor.
Crear un proyecto equivalente con .NET 10
Para reproducir el resultado con la plantilla actual, crea una Blazor Web App con interactividad WebAssembly para toda la aplicación:
dotnet new blazor -n FilmCrud -int WebAssembly -aiEl comando genera una Blazor Web App con interactividad WebAssembly; no crea automáticamente los tres proyectos de la plantilla hosted antigua. Si necesitas una arquitectura equivalente a la SPA separada, crea el cliente y la API por separado:
dotnet new blazorwasm -n FilmCrud.Client
dotnet new webapi -n FilmCrud.ApiLa forma esencial de registrar los render modes en una Blazor Web App es esta:
builder.Services.AddRazorComponents()
.AddInteractiveWebAssemblyComponents();
app.MapRazorComponents<App>()
.AddInteractiveWebAssemblyRenderMode();El componente interactivo WebAssembly debe vivir en el proyecto .Client y usar @rendermode InteractiveWebAssembly cuando el modo no se aplique globalmente. Los render modes solo existen en Blazor Web App: standalone Blazor WebAssembly no los usa.
Registrar HttpClient
En una aplicación standalone o en la parte cliente WebAssembly, registra un HttpClient apuntando al origen de la aplicación:
builder.Services.AddScoped(_ => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});En una Blazor Web App con prerendering, el servidor también necesita disponer de HttpClient; si no está registrado, aparece un InvalidOperationException al prerenderizar:
builder.Services.AddHttpClient();Para una solución robusta con prerendering, conviene abstraer el acceso a datos mediante una interfaz, con una implementación HTTP en el cliente y una implementación de servidor. Registrar el HttpClient del cliente no es suficiente para el servidor prerenderizado.
Creación del proyecto original con .NET 5
Aviso: las capturas y la casilla ASP.NET Core hospedado son históricas (Visual Studio 2019 y .NET 5) y no aparecen en las plantillas actuales. Se mantienen para documentar el proyecto original.
Abre Visual Studio y crea un nuevo proyecto Blazor WebAssembly:

Elige Blazor WebAssembly App y asegúrate de marcar ASP.NET Core hospedado:

Selecciona la plataforma de destino y el tipo de autenticación:

La solución generada tendrá tres proyectos:

Estructura hosted original de .NET 5
Aviso: esta estructura de tres proyectos la generaba la plantilla hospedada de .NET 5 y no se reproduce en las plantillas actuales.
Al crear una aplicación Blazor WebAssembly hospedada con ASP.NET Core tendrás tres proyectos:
| Proyecto | Para qué sirve |
|---|---|
FilmCrud.Client | Aplicación Blazor WebAssembly. Es lo que ve el usuario. |
FilmCrud.Server | API ASP.NET Core. Contiene controladores, Entity Framework y conexión a SQL Server. |
FilmCrud.Shared | Clases compartidas entre cliente y servidor, como el modelo Film. |
El cliente no debe conectarse directamente a la base de datos. Las operaciones se hacen siempre contra la API.
Nota de alcance: los fragmentos que vienen a continuación conservan los nombres
FilmCrud.Client,FilmCrud.ServeryFilmCrud.Sharedpara coincidir con el proyecto original. En .NET 10 debes adaptarlos al proyecto.Client, al servidor y, si compartes el contrato, a una biblioteca de contratos explícita. No hagas un reemplazo masivo de espacios de nombres que deje los fragmentos incompatibles entre sí.
Modelo Film
Crea una carpeta Models dentro de FilmCrud.Shared y añade la clase Film:

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace FilmCrud.Shared.Models;
public class Film
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Duration { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}Id será la clave primaria. Title, Description y Duration son los datos que editaremos desde Blazor.
DbContext con Entity Framework Core
En FilmCrud.Server instala los paquetes de Entity Framework Core para SQL Server:

Crea una carpeta Data con la clase AppDbContext:
using FilmCrud.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace FilmCrud.Server.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Film> Films => Set<Film>();
}Si necesitas profundizar en el contexto, las migraciones y las consultas LINQ, consulta el tutorial de Entity Framework Core.
Después registra el contexto en la aplicación servidor. En proyectos con Program.cs sería algo así:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));Este registro por petición es válido en una API ASP.NET Core como la de este tutorial. Si accedes directamente a Entity Framework Core desde componentes InteractiveServer, usa AddDbContextFactory<AppDbContext> e inyecta IDbContextFactory<AppDbContext>: un DbContext registrado como scoped puede quedar compartido durante el circuito y no es thread-safe.
Y en appsettings.json añade la cadena de conexión:
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=Films;Trusted_Connection=True;TrustServerCertificate=True;"
}
}Adapta Server, Database y la autenticación a tu entorno.
Migraciones
Para crear la tabla en SQL Server puedes usar migraciones:
dotnet ef migrations add CreateFilmsTable --project FilmCrud.Server
dotnet ef database update --project FilmCrud.ServerSi usas la consola de paquetes de Visual Studio, los comandos equivalentes son:
Add-Migration CreateFilmsTable
Update-Database

Si la migración se ejecuta sin errores, verás la tabla Films creada en SQL Server:

API para el CRUD
En FilmCrud.Server, haz clic derecho sobre la carpeta Controllers, selecciona Agregar -> Controlador y elige Controlador de API con acciones que usan Entity Framework:

Selecciona Film como clase de modelo y AppDbContext como clase de contexto:

El código generado tendrá los endpoints GET, POST, PUT y DELETE. A continuación se muestra una versión adaptada del controlador (en un proyecto nuevo puedes usar controladores o Minimal APIs; aquí se mantiene el controlador del ejemplo original):
using FilmCrud.Server.Data;
using FilmCrud.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace FilmCrud.Server.Controllers;
[Route("api/[controller]")]
[ApiController]
public class FilmsController : ControllerBase
{
private readonly AppDbContext _context;
public FilmsController(AppDbContext context)
{
_context = context;
}
[HttpGet]
public async Task<ActionResult<List<Film>>> GetFilms()
{
return await _context.Films
.OrderBy(film => film.Title)
.ToListAsync();
}
[HttpGet("{id:int}")]
public async Task<ActionResult<Film>> GetFilm(int id)
{
var film = await _context.Films.FindAsync(id);
return film is null ? NotFound() : film;
}
[HttpPost]
public async Task<ActionResult<Film>> CreateFilm(Film film)
{
film.CreatedAt = DateTime.UtcNow;
film.UpdatedAt = DateTime.UtcNow;
_context.Films.Add(film);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetFilm), new { id = film.Id }, film);
}
[HttpPut("{id:int}")]
public async Task<IActionResult> UpdateFilm(int id, Film film)
{
if (id != film.Id)
{
return BadRequest();
}
var exists = await _context.Films.AnyAsync(item => item.Id == id);
if (!exists)
{
return NotFound();
}
film.UpdatedAt = DateTime.UtcNow;
_context.Entry(film).State = EntityState.Modified;
await _context.SaveChangesAsync();
return NoContent();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> DeleteFilm(int id)
{
var film = await _context.Films.FindAsync(id);
if (film is null)
{
return NotFound();
}
_context.Films.Remove(film);
await _context.SaveChangesAsync();
return NoContent();
}
}Con esto ya tienes la API lista:
GET /api/films: lista películas.GET /api/films/{id}: obtiene una película.POST /api/films: crea una película.PUT /api/films/{id}: actualiza una película.DELETE /api/films/{id}: elimina una película.
Listar películas en Blazor
En FilmCrud.Client, crea la carpeta Pages/Films y añade Index.razor:
@page "/films"
@using FilmCrud.Shared.Models
@inject HttpClient Http
@inject NavigationManager Navigation
<h3>Películas</h3>
@if (_error is not null)
{
<p role="alert">@_error</p>
}
@if (_films is null)
{
@if (_error is null)
{
<p><em>Cargando...</em></p>
}
}
else if (_films.Count == 0)
{
<p>No hay películas creadas.</p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Título</th>
<th>Descripción</th>
<th>Duración</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var film in _films)
{
<tr>
<td>@film.Title</td>
<td>@film.Description</td>
<td>@film.Duration min</td>
<td>
<button class="btn btn-info" @onclick="() => Edit(film.Id)">Editar</button>
<button class="btn btn-danger" @onclick="() => Delete(film.Id)">Eliminar</button>
</td>
</tr>
}
</tbody>
</table>
}
<button class="btn btn-success" @onclick="Create">Añadir película</button>
@code {
private List<Film>? _films;
private string? _error;
protected override Task OnInitializedAsync() => LoadFilms();
private async Task LoadFilms()
{
_films = null;
_error = null;
try
{
using var response = await Http.GetAsync("api/films");
if (!response.IsSuccessStatusCode)
{
_error = ApiError(response);
return;
}
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
_films = new List<Film>();
return;
}
var films = await response.Content.ReadFromJsonAsync<List<Film>>();
if (films is null)
{
_error = "La API no devolvió una lista de películas.";
return;
}
_films = films;
}
catch (System.Net.Http.HttpRequestException)
{
_error = "No se pudo conectar con la API para cargar las películas.";
}
catch (System.Text.Json.JsonException)
{
_error = "La API devolvió una respuesta JSON no válida al cargar las películas.";
}
}
private async Task Delete(int id)
{
_error = null;
try
{
using var response = await Http.DeleteAsync($"api/films/{id}");
if (!response.IsSuccessStatusCode)
{
_error = ApiError(response);
return;
}
}
catch (System.Net.Http.HttpRequestException)
{
_error = "No se pudo conectar con la API para eliminar la película.";
return;
}
await LoadFilms();
}
private void Edit(int id)
{
Navigation.NavigateTo($"/films/edit/{id}");
}
private void Create()
{
Navigation.NavigateTo("/films/create");
}
private static string ApiError(HttpResponseMessage response) => response.StatusCode switch
{
System.Net.HttpStatusCode.BadRequest => "La API rechazó la solicitud (400). Revisa los datos e inténtalo de nuevo.",
System.Net.HttpStatusCode.NotFound => "No se encontró el recurso solicitado (404).",
_ => $"La API devolvió un error ({(int)response.StatusCode} {response.StatusCode})."
};
}OnInitializedAsync carga los datos al entrar en la página. Después, los botones llaman a la API o navegan al formulario correspondiente.
Crear películas
Añade Create.razor:
@page "/films/create"
@using FilmCrud.Shared.Models
@inject HttpClient Http
@inject NavigationManager Navigation
<h3>Añadir película</h3>
@if (_error is not null)
{
<p role="alert">@_error</p>
}
<EditForm Model="_film" OnValidSubmit="Save">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label>Título</label>
<InputText class="form-control" @bind-Value="_film.Title" />
</div>
<div class="form-group">
<label>Descripción</label>
<InputTextArea class="form-control" @bind-Value="_film.Description" />
</div>
<div class="form-group">
<label>Duración en minutos</label>
<InputNumber class="form-control" @bind-Value="_film.Duration" />
</div>
<button type="submit" class="btn btn-success">Guardar</button>
</EditForm>
@code {
private readonly Film _film = new();
private string? _error;
private async Task Save()
{
_error = null;
try
{
using var response = await Http.PostAsJsonAsync("api/films", _film);
if (!response.IsSuccessStatusCode)
{
_error = ApiError(response);
return;
}
Navigation.NavigateTo("/films");
}
catch (System.Net.Http.HttpRequestException)
{
_error = "No se pudo conectar con la API para crear la película.";
}
}
private static string ApiError(HttpResponseMessage response) => response.StatusCode switch
{
System.Net.HttpStatusCode.BadRequest => "La API rechazó la solicitud (400). Revisa los datos e inténtalo de nuevo.",
System.Net.HttpStatusCode.NotFound => "No se encontró el recurso solicitado (404).",
_ => $"La API devolvió un error ({(int)response.StatusCode} {response.StatusCode})."
};
}EditForm enlaza el formulario con el modelo Film. Al enviar, Blazor genera el JSON y lo manda a la API con PostAsJsonAsync.
Editar películas
Añade Edit.razor:
@page "/films/edit/{Id:int}"
@using FilmCrud.Shared.Models
@inject HttpClient Http
@inject NavigationManager Navigation
<h3>Editar película</h3>
@if (_error is not null)
{
<p role="alert">@_error</p>
}
@if (_film is null)
{
@if (_error is null)
{
<p><em>Cargando...</em></p>
}
}
else
{
<EditForm Model="_film" OnValidSubmit="Save">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label>Título</label>
<InputText class="form-control" @bind-Value="_film.Title" />
</div>
<div class="form-group">
<label>Descripción</label>
<InputTextArea class="form-control" @bind-Value="_film.Description" />
</div>
<div class="form-group">
<label>Duración en minutos</label>
<InputNumber class="form-control" @bind-Value="_film.Duration" />
</div>
<button type="submit" class="btn btn-success">Guardar cambios</button>
</EditForm>
}
@code {
[Parameter]
public int Id { get; set; }
private Film? _film;
private string? _error;
protected override async Task OnInitializedAsync()
{
_film = null;
_error = null;
try
{
using var response = await Http.GetAsync($"api/films/{Id}");
if (!response.IsSuccessStatusCode)
{
_error = ApiError(response);
return;
}
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
_error = "La API no devolvió una película (204).";
return;
}
_film = await response.Content.ReadFromJsonAsync<Film>();
if (_film is null)
{
_error = "La API no devolvió los datos de la película.";
}
}
catch (System.Net.Http.HttpRequestException)
{
_error = "No se pudo conectar con la API para cargar la película.";
}
catch (System.Text.Json.JsonException)
{
_error = "La API devolvió una respuesta JSON no válida al cargar la película.";
}
}
private async Task Save()
{
if (_film is null)
{
return;
}
_error = null;
try
{
using var response = await Http.PutAsJsonAsync($"api/films/{_film.Id}", _film);
if (!response.IsSuccessStatusCode)
{
_error = ApiError(response);
return;
}
Navigation.NavigateTo("/films");
}
catch (System.Net.Http.HttpRequestException)
{
_error = "No se pudo conectar con la API para guardar los cambios.";
}
}
private static string ApiError(HttpResponseMessage response) => response.StatusCode switch
{
System.Net.HttpStatusCode.BadRequest => "La API rechazó la solicitud (400). Revisa los datos e inténtalo de nuevo.",
System.Net.HttpStatusCode.NotFound => "No se encontró el recurso solicitado (404).",
_ => $"La API devolvió un error ({(int)response.StatusCode} {response.StatusCode})."
};
}La ruta @page "/films/edit/{Id:int}" recibe el identificador de la película desde la URL. Con ese Id se carga el registro y se envía de vuelta a la API cuando el usuario guarda.
Añadir enlace al menú
Para que el usuario pueda entrar al CRUD, añade un enlace en el menú de Blazor. En la estructura original de .NET 5, ese menú está en Shared/NavMenu.razor; en una plantilla moderna la ubicación puede ser distinta:
<NavLink class="nav-link" href="films">
Películas
</NavLink>Recomendaciones
- No pongas cadenas de conexión, contraseñas ni claves privadas en el proyecto
Client. - Valida los datos también en el servidor, no solo en el formulario de Blazor.
- Usa DTOs si el modelo de base de datos empieza a crecer o no quieres exponer todas sus propiedades.
- Comprueba las respuestas de la API antes de redirigir al usuario en una aplicación real.
- Si el CRUD va a ser público, añade autenticación y autorización.
Código fuente
El repositorio con el código original de .NET 5 está en GitHub: CrudBlazorWebAssembly. Es el proyecto histórico que acompaña al tutorial, no una plantilla actual recomendada para .NET 10.
Preguntas frecuentes
¿Necesito configurar CORS si el cliente Blazor WebAssembly y la API están separados?
CORS es una política del navegador para el acceso cross-origin. Una solicitud simple puede llegar al servidor, pero, si faltan las cabeceras CORS adecuadas, el navegador bloquea que JavaScript lea la respuesta. Si el servidor deniega el preflight OPTIONS, el navegador no envía la solicitud real. CORS no es autenticación, autorización ni protección contra CSRF.
¿Blazor WebAssembly sigue teniendo soporte aunque .NET 5 esté fuera de soporte?
.NET 5 dejó de recibir soporte el 10 de mayo de 2022, pero Blazor WebAssembly sigue teniendo soporte en las versiones actuales de .NET. Consulta la política de soporte de .NET y los modelos de hospedaje de Blazor.
¿Una aplicación standalone de Blazor WebAssembly se prerenderiza automáticamente?
No. Una app standalone se ejecuta en el navegador y no se prerenderiza por ser standalone. El prerenderizado corresponde a una Blazor Web App; sus componentes con modo Interactive WebAssembly se prerenderizan en el servidor de forma predeterminada. Consulta los modelos de hospedaje y los modos de renderizado de Blazor.