Zero JavaScript: CRUD Completo com Blazor WASM e Radzen
Introdução#
No artigo anterior desta série, analisei se Blazor WebAssembly está pronto para produção corporativa comparando-o com Angular. A conclusão foi nuançada — Blazor WASM é viável para cenários corporativos internos, mas tem trade-offs que precisam ser avaliados caso a caso. Hoje vou provar na prática construindo um CRUD completo de produtos com DataGrid paginado, formulários com validação, dialogs, notificações e inline editing — tudo em C#, sem escrever uma linha de JavaScript.
O componente de UI que escolhi é o Radzen Blazor, uma biblioteca open source (licença MIT) com mais de 70 componentes gratuitos. A razão principal: Radzen entrega uma experiência visual madura para cenários CRUD corporativos, com DataGrid, formulários, validação, dialogs e notificações prontos para uso. O JavaScript que roda internamente (Radzen.Blazor.js) é da própria biblioteca — o desenvolvedor nunca toca em JS diretamente.
O que vou construir neste tutorial:
- API REST com Minimal API para Produtos e Categorias (10 endpoints)
- Blazor WASM Standalone consumindo a API via
HttpClient - DataGrid paginado com busca, ordering e ações por linha
- Formulário em dialog reutilizável para criação e edição
- Inline editing para Categorias (pattern alternativo ao dialog)
- Exclusão com confirmação e notificações visuais
Todo o código está no repositório blog-zocateli-sample no GitHub. A ideia é que você clone, rode e forme sua própria opinião. Este artigo é o 2º da série “Frontend Moderno” — meu objetivo é demonstrar que o ecossistema de componentes Blazor já suporta cenários reais de produção, com uma experiência de desenvolvimento familiar para quem vem do .NET.
ℹ️ Informação: Radzen Blazor é open source (MIT) e inclui 70+ componentes free. O
Radzen.Blazor.jsé JavaScript interno da biblioteca — o desenvolvedor nunca escreve JavaScript diretamente. A versão usada neste tutorial é a 10.2.0 com .NET 10.
Pré-requisitos#
Para acompanhar este tutorial, você vai precisar de:
- .NET 10 SDK (10.0.201 ou superior) — download oficial
- IDE: VS Code com extensão C# Dev Kit, ou Visual Studio 2022 17.14+
- Conhecimento básico de C# e REST APIs
- Terminal (PowerShell, bash ou zsh)
Clone o repositório com todo o código pronto:
git clone https://github.com/lzocateli/blog-zocateli-sample.git
cd blog-zocateli-sample
Verifique se o SDK está instalado:
dotnet --version
Output esperado:
10.0.201
💡 Dica: Se você usa o VS Code com Dev Containers, o
.devcontainer/do repositório já tem o .NET 10 SDK configurado. Basta abrir o projeto no container e tudo estará pronto.
Criando o Projeto Blazor WASM Standalone#
O template blazorwasm do .NET cria uma aplicação Blazor WebAssembly Standalone — uma SPA que roda inteiramente no browser via WebAssembly, sem servidor ASP.NET Core hospedando. Diferente do modelo Hosted (que inclui um projeto Server), o Standalone é uma SPA pura que consome APIs externas via HTTP, exatamente como uma aplicação Angular ou React.
Scaffolding do projeto#
dotnet new blazorwasm --name BlogSamples.BlazorWasm --output frontend/blazor-wasm --framework net10.0
dotnet sln add frontend/blazor-wasm
dotnet add frontend/blazor-wasm package Radzen.Blazor
O primeiro comando cria o projeto, o segundo adiciona à solution e o terceiro instala o Radzen Blazor — a biblioteca de componentes UI. O .csproj resultante fica enxuto:
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" PrivateAssets="all" />
<PackageReference Include="Radzen.Blazor" Version="10.2.0" />
</ItemGroup>
</Project>
Configurando Program.cs#
O Program.cs é o entry point da SPA. Aqui configuro o HttpClient com a URL base da API (via appsettings.json), registro os services HTTP tipados e adiciono os componentes Radzen:
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using BlogSamples.BlazorWasm;
using BlogSamples.BlazorWasm.Services;
using Radzen;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
// Configurar HttpClient com URL base da API
var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5101";
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBaseUrl) });
// Services HTTP tipados
builder.Services.AddScoped<ProdutoApiService>();
builder.Services.AddScoped<CategoriaApiService>();
// Radzen Components (DialogService, NotificationService, etc.)
builder.Services.AddRadzenComponents();
await builder.Build().RunAsync();
A chamada AddRadzenComponents() registra automaticamente DialogService, NotificationService, TooltipService e ContextMenuService no container de DI. Sem ela, os dialogs e notificações não funcionam.
Layout com Radzen#
O MainLayout.razor define a estrutura visual da aplicação — header com toggle de sidebar, navegação lateral com RadzenPanelMenu, área de conteúdo e footer:
@inherits LayoutComponentBase
<RadzenLayout>
<RadzenHeader>
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center"
Gap="0.5rem" class="rz-p-2">
<RadzenSidebarToggle Click="@(() => sidebarExpanded = !sidebarExpanded)" />
<RadzenText Text="Blog Samples — Blazor WASM" TextStyle="TextStyle.H5"
class="rz-m-0" />
</RadzenStack>
</RadzenHeader>
<RadzenSidebar @bind-Expanded="@sidebarExpanded">
<RadzenPanelMenu>
<RadzenPanelMenuItem Text="Dashboard" Icon="dashboard" Path="/" />
<RadzenPanelMenuItem Text="Produtos" Icon="inventory_2" Path="/produtos" />
<RadzenPanelMenuItem Text="Categorias" Icon="category" Path="/categorias" />
</RadzenPanelMenu>
</RadzenSidebar>
<RadzenBody>
<div class="rz-p-4">
@Body
</div>
</RadzenBody>
<RadzenFooter>
<RadzenText Text="© 2026 Blog Samples — zocate.li" TextStyle="TextStyle.Caption"
class="rz-p-2" />
</RadzenFooter>
</RadzenLayout>
<RadzenComponents />
@code {
bool sidebarExpanded = true;
}
O componente <RadzenComponents /> no final é obrigatório — ele renderiza os containers para dialogs, notificações e tooltips. Sem ele, DialogService.OpenAsync() e NotificationService.Notify() não exibem nada na tela.
⚠️ Atenção: O
<RadzenComponents />deve estar dentro do layout, não noApp.razor. Colocá-lo fora do layout pode causar problemas de renderização com dialogs e notificações.
Para que o tema visual funcione, o App.razor precisa incluir <RadzenTheme Theme="material" />:
<RadzenTheme Theme="material" />
<Router AppAssembly="typeof(Program).Assembly">
<!-- ... -->
</Router>
O tema material do Radzen inclui toda a estilização necessária — cores, tipografia, espaçamento, ícones Material Design. Não é necessário importar Bootstrap ou qualquer outro framework CSS.
A API REST — Domínio Produtos#
Para o Blazor WASM consumir dados, criei um domínio Produtos com Minimal API no projeto principal. São 10 endpoints organizados em dois grupos:
| Verbo | Rota | Descrição |
|---|---|---|
| GET | /api/produtos?pagina=1&tamanhoPagina=20&filtro= | Listar com paginação e filtro |
| GET | /api/produtos/{id} | Obter por ID |
| POST | /api/produtos | Criar produto |
| PUT | /api/produtos/{id} | Atualizar produto |
| DELETE | /api/produtos/{id} | Remover produto |
| GET | /api/categorias | Listar todas |
| GET | /api/categorias/{id} | Obter por ID |
| POST | /api/categorias | Criar categoria |
| PUT | /api/categorias/{id} | Atualizar categoria |
| DELETE | /api/categorias/{id} | Remover categoria |
O ProdutoEndpoints.cs usa MapGroup para organizar as rotas e ProducesResponseType para documentar no Swagger:
public static class ProdutoEndpoints
{
public static void MapProdutoEndpoints(this IEndpointRouteBuilder app)
{
var produtos = app.MapGroup("/api/produtos")
.WithTags("Produtos");
produtos.MapGet("/", async (
IProdutoService service,
int pagina = 1,
int tamanhoPagina = 20,
string? filtro = null) =>
{
var resultado = await service.ListarProdutosAsync(pagina, tamanhoPagina, filtro);
return Results.Ok(resultado);
})
.WithName("ListarProdutos")
.Produces<PagedResult<ProdutoDto>>();
produtos.MapPost("/", async (CriarProdutoRequest request, IProdutoService service) =>
{
var produto = await service.CriarProdutoAsync(request);
return Results.CreatedAtRoute("ObterProduto", new { id = produto.Id }, produto);
})
.WithName("CriarProduto")
.Produces<ProdutoDto>(201)
.ProducesValidationProblem();
// ... PUT, DELETE, e endpoints de Categorias seguem o mesmo pattern
}
}
Os DTOs de request usam DataAnnotations para validação server-side, garantindo que a API valide os dados mesmo que o client-side seja bypassed:
public record CriarProdutoRequest
{
[Required(ErrorMessage = "Nome é obrigatório")]
[StringLength(200, MinimumLength = 3)]
public string Nome { get; init; } = string.Empty;
public string? Descricao { get; init; }
[Range(0.01, double.MaxValue, ErrorMessage = "Preço deve ser maior que zero")]
public decimal Preco { get; init; }
[Range(0, int.MaxValue)]
public int QuantidadeEstoque { get; init; }
[Range(1, int.MaxValue, ErrorMessage = "Selecione uma categoria")]
public int CategoriaId { get; init; }
public bool Ativo { get; init; } = true;
}
A implementação do service usa ConcurrentDictionary como storage in-memory (decisão de design para manter o tutorial focado no Blazor WASM, sem dependência de banco de dados). O seed inicial inclui 8 categorias e mais de 50 produtos distribuídos entre elas.
Configuração CORS#
Como o Blazor WASM Standalone roda em uma porta diferente da API (5200 vs 5101), é obrigatório configurar CORS no Program.cs da API:
builder.Services.AddCors(options =>
{
options.AddPolicy("BlazorWasm", policy =>
{
policy.WithOrigins("http://localhost:5200", "https://localhost:7200")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
// No pipeline:
app.UseCors("BlazorWasm");
💡 Dica: Configurar CORS é obrigatório para Blazor WASM Standalone. Sem configuração explícita, o browser bloqueará as requisições cross-origin. Em produção, substitua os origins por domínios reais.
Para testar a API isoladamente, rode dotnet run --project src/BlogSamples e acesse http://localhost:5101/docs — o Swagger mostra todos os endpoints de Produtos e Categorias.
O diagrama abaixo mostra a arquitetura completa — o Blazor WASM no browser se comunica com a API REST via HttpClient (JSON) atravessando a barreira de CORS:
Services HTTP — Consumindo a API#
O pattern que uso para consumir a API é service tipado com HttpClient injetado via primary constructor. Cada service encapsula as chamadas HTTP para um domínio específico, usando os métodos de extensão do System.Net.Http.Json — GetFromJsonAsync, PostAsJsonAsync e PutAsJsonAsync:
using System.Net.Http.Json;
using BlogSamples.BlazorWasm.Models;
namespace BlogSamples.BlazorWasm.Services;
public class ProdutoApiService(HttpClient http)
{
public async Task<PagedResult<ProdutoDto>> ListarAsync(
int pagina = 1, int tamanhoPagina = 20, string? filtro = null)
{
var url = $"api/produtos?pagina={pagina}&tamanhoPagina={tamanhoPagina}";
if (!string.IsNullOrWhiteSpace(filtro))
url += $"&filtro={Uri.EscapeDataString(filtro)}";
return await http.GetFromJsonAsync<PagedResult<ProdutoDto>>(url)
?? new PagedResult<ProdutoDto>();
}
public async Task<ProdutoDto?> ObterPorIdAsync(int id)
=> await http.GetFromJsonAsync<ProdutoDto>($"api/produtos/{id}");
public async Task<ProdutoDto?> CriarAsync(CriarProdutoRequest request)
{
var response = await http.PostAsJsonAsync("api/produtos", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ProdutoDto>();
}
public async Task<ProdutoDto?> AtualizarAsync(int id, AtualizarProdutoRequest request)
{
var response = await http.PutAsJsonAsync($"api/produtos/{id}", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ProdutoDto>();
}
public async Task RemoverAsync(int id)
{
var response = await http.DeleteAsync($"api/produtos/{id}");
response.EnsureSuccessStatusCode();
}
}
Alguns pontos importantes sobre este pattern:
Uri.EscapeDataStringno filtro previne injeção de parâmetros na query string. Nunca concatene strings diretamente em URLs sem encoding.EnsureSuccessStatusCode()lançaHttpRequestExceptionse a API retornar erro (4xx, 5xx). No componente Blazor, capturo essa exceção para exibir notificação de erro ao usuário.- Primary constructor (
HttpClient http) evita o boilerplate de campo + construtor. OHttpClienté resolvido pelo container de DI com aBaseAddressconfigurada noProgram.cs.
OCategoriaApiServicesegue exatamente o mesmo pattern, com métodosListarAsync,CriarAsync,AtualizarAsynceRemoverAsync.
No Angular, HttpClient com interceptors e operadores RxJS oferece ergonomia similar. Em Blazor, a experiência é equivalente — DelegatingHandler serve como interceptor para autenticação, logging ou retry. A diferença principal é que Blazor usa async/await nativo do C# em vez de Observable do RxJS.
ℹ️ Informação: Os models do Blazor WASM são classes (não records). Radzen Blazor usa two-way binding (
@bind-Value) que requer setters mutáveis. Records cominitnão funcionam para edição em formulários Radzen.
📖 Artigo completo com exemplos de código: Zero JavaScript: CRUD Completo com Blazor WASM e Radzen