Build an Email Newsletter Builder
Build a drag-and-drop email newsletter editor with Blazor and EF Core.
Introduction
30 Second Summary
Every company sends email newsletters. Whether it's a product update or a flash sale, someone has to design and assemble those emails using a drag-and-drop builder.
In this project, you will build your own email newsletter builder using Blazor, Microsoft's framework for creating interactive web apps with C#. You will create a drag-and-drop editor, a reusable component library, and a full newsletter management dashboard.
What You'll Build
An email newsletter builder admin panel with a component library, a drag-and-drop editor, and multi-page newsletter management, all powered by Entity Framework Core and SQLite.
By the end of this project, you'll have:
- A component library page for creating, editing, and deleting reusable email block templates like headers, text blocks, and buttons.
- A drag-and-drop newsletter editor with a component palette, a canvas, and live inline editing.
- A newsletter list page with save, edit, duplicate, and delete functionality.
- Secret Mission: Export your finished newsletter as email-ready HTML with inline styles that works in email clients.
Are there any prerequisites?
You need a basic understanding of HTML/CSS and any programming language. C# syntax is introduced throughout the project with comparisons to JavaScript and Python, so no prior C# experience is required.
Set Up the Project and Database
Every web app needs a foundation: a project structure, data models, and a database. In Blazor, these are organized similarly to frameworks you may already know, like React or Flask.
In this step, you will create a Blazor Web App, install Entity Framework Core for database access, define C# classes that describe your newsletter data, and generate a SQLite database from those classes.
In this step, get ready to:
- Create a Blazor Web App with Interactive Server rendering.
- Define data models for component templates, newsletters, and newsletter blocks.
- Set up a SQLite database with Entity Framework Core and seed it with starter data.
Create the Blazor Web App and explore the project structure
Blazor uses project templates to scaffold a working app with everything you need. The Interactive Server mode creates a real-time connection between the browser and server, so every click and UI update flows through that connection.
Terminal
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the Blazor Web App project and move into it by running these commands:
dotnet new blazor -o NewsletterBuilder -int Server
cd NewsletterBuilder
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio and press Enter to open it.
- Click Open a project or solution.
- Navigate to the NewsletterBuilder folder on your Desktop and select NewsletterBuilder.csproj.
- Click Open.
Visual Studio
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio and press Enter to open it.
- Click Create a new project.
- Search for Blazor Web App in the template search bar.
- Select the Blazor Web App template and click Next.
- Enter NewsletterBuilder in the Project name field.
- Click Next.
- Set Interactive render mode to Server.
- Set Interactivity location to Per page/component.
- Make sure Include sample pages is checked.
- Click Create.
- Run the app by pressing Ctrl+F5 in Visual Studio to start without debugging.
A browser tab opens showing the default Blazor template with a sidebar navigation and sample pages.
Don't see the app in the browser?
If your browser does not open automatically, check the Output window in Visual Studio for a URL. Copy it and paste it into your browser.
If the build fails, make sure you selected the Blazor Web App template (not Blazor WebAssembly Standalone App) and that .NET 10 is selected as the framework.
Help me troubleshoot this
What is the project structure?
The generated project has a structure similar to frameworks you may know. Components/ is where Razor components live (similar to React components).
- Components/Pages/ holds routable pages, like the pages/ folder in Next.js.
- Components/Layout/ has the layout wrapper (MainLayout.razor and NavMenu.razor).
- wwwroot/ holds static files like CSS and images.
- Program.cs is the entry point that configures services and middleware, like Express's app.js or Flask's app.py.
Install EF Core packages and create the data models
Entity Framework Core is a tool that lets you define C# classes and automatically turns them into database tables. You will install the EF Core packages and the command-line migration tool, then create the classes that represent your newsletter data.
Terminal
- Install the EF Core SQLite provider, the EF Core tools package, and the dotnet-ef CLI tool by running these commands in your project folder:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.9
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 10.0.9
dotnet tool install --global dotnet-ef
Visual Studio
- In Solution Explorer, right-click the NewsletterBuilder project.
- Click Manage NuGet Packages.
- Click the Browse tab.
- Search for Microsoft.EntityFrameworkCore.Sqlite and install version 10.0.9.
- Search for Microsoft.EntityFrameworkCore.Tools and install version 10.0.9.
- Click Tools in the top menu bar.
- Select Command Line, then Developer Command Prompt.
- Install the dotnet-ef CLI tool by running:
dotnet tool install --global dotnet-ef
Seeing an error when installing dotnet-ef?
If you see a message that dotnet-ef is already installed, you are all set. You can also run dotnet tool update --global dotnet-ef to make sure you have the latest version.
If the command is not found at all, make sure your .dotnet/tools directory is on your system PATH.
Help me fix this
Now you will create the data model classes that describe your newsletter data. Each class maps to a database table.
- In Solution Explorer, right-click the NewsletterBuilder project name.
- Click Add, then New Folder.
- Name the folder Data.
- Right-click the new Data folder.
- Click Add, then Class.
- Name the file RenderType.cs and click Add.
- Replace the generated code with the following:
namespace NewsletterBuilder.Data;
// Defines the five ways a newsletter block can render
public enum RenderType
{
Heading,
Text,
Image,
Button,
Divider
}
- Create another class in the Data folder. Name it ComponentTemplate.cs and replace the generated code with:
namespace NewsletterBuilder.Data;
// Represents a reusable email component in the palette
public class ComponentTemplate
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public RenderType RenderType { get; set; }
public string DefaultContent { get; set; } = string.Empty;
public string DefaultBackgroundColor { get; set; } = "#ffffff";
public string DefaultTextColor { get; set; } = "#333333";
// Nullable fields for optional defaults
public string? DefaultImageUrl { get; set; }
public string? DefaultButtonUrl { get; set; }
}
What do these models define?
RenderType is an enum listing the five visual types a newsletter block can be: Heading, Text, Image, Button, or Divider.
ComponentTemplate represents a reusable template in the component palette. Each template has a name, an icon character, a render type, and default content and colors. When you drag a template onto the canvas later, it copies these defaults into a new block.
What is the C# property syntax?
A C# class with { get; set; } properties is like a Python dataclass or a JavaScript object with typed fields. Each property has a type (string, int, etc.) and a name.
The ? after a type (like string?) means nullable. This is similar to TypeScript's string | null. Properties without ? must always have a value.
- Create another class in the Data folder. Name it Newsletter.cs and replace the generated code with:
namespace NewsletterBuilder.Data;
// Represents a saved newsletter with a name, timestamps, and a list of blocks
public class Newsletter
{
public int Id { get; set; }
public string Name { get; set; } = "Untitled Newsletter";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation property: one newsletter has many blocks
public List<NewsletterBlock> Blocks { get; set; } = new();
}
- Create one more class in the Data folder. Name it NewsletterBlock.cs and replace the generated code with:
namespace NewsletterBuilder.Data;
// Represents one block inside a newsletter (content, colors, URLs, sort order)
public class NewsletterBlock
{
public int Id { get; set; }
public int NewsletterId { get; set; }
public RenderType RenderType { get; set; }
public string Content { get; set; } = string.Empty;
public string? ImageUrl { get; set; }
public string? AltText { get; set; }
public string? ButtonUrl { get; set; }
public string BackgroundColor { get; set; } = "#ffffff";
public string TextColor { get; set; } = "#333333";
// Controls the order blocks appear in the newsletter
public int SortOrder { get; set; }
// Navigation property back to the parent newsletter
public Newsletter Newsletter { get; set; } = null!;
}
What do the newsletter models define?
Newsletter is the parent object. It stores the newsletter name, creation and update timestamps, and a list of NewsletterBlock objects.
NewsletterBlock is one block inside a newsletter. It stores the block's content, colors, optional image and button URLs, and a SortOrder integer that controls the display order. The NewsletterId property links each block to its parent newsletter.
- Save all four files in the Data folder.
You will not see a visible change yet. These model classes define the shape of your data. The next substep uses them to create a database.
✔️ Awesome, I've got everything!
Great. Make sure all four files are saved inside the Data folder.
ⓧ I'd like to double check the full code
Here are the four data model files for comparison.
namespace NewsletterBuilder.Data;
public enum RenderType
{
Heading,
Text,
Image,
Button,
Divider
}
namespace NewsletterBuilder.Data;
public class ComponentTemplate
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public RenderType RenderType { get; set; }
public string DefaultContent { get; set; } = string.Empty;
public string DefaultBackgroundColor { get; set; } = "#ffffff";
public string DefaultTextColor { get; set; } = "#333333";
public string? DefaultImageUrl { get; set; }
public string? DefaultButtonUrl { get; set; }
}
namespace NewsletterBuilder.Data;
public class Newsletter
{
public int Id { get; set; }
public string Name { get; set; } = "Untitled Newsletter";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public List<NewsletterBlock> Blocks { get; set; } = new();
}
namespace NewsletterBuilder.Data;
public class NewsletterBlock
{
public int Id { get; set; }
public int NewsletterId { get; set; }
public RenderType RenderType { get; set; }
public string Content { get; set; } = string.Empty;
public string? ImageUrl { get; set; }
public string? AltText { get; set; }
public string? ButtonUrl { get; set; }
public string BackgroundColor { get; set; } = "#ffffff";
public string TextColor { get; set; } = "#333333";
public int SortOrder { get; set; }
public Newsletter Newsletter { get; set; } = null!;
}
Create the DbContext, configure Program.cs, and create the database
The DbContext is the bridge between your C# classes and the database. It tells Entity Framework Core which tables to create, how they relate, and what starter data to seed.
- Create a new class in the Data folder. Name it NewsletterDbContext.cs and replace the generated code with:
using Microsoft.EntityFrameworkCore;
namespace NewsletterBuilder.Data;
public class NewsletterDbContext : DbContext
{
public NewsletterDbContext(DbContextOptions<NewsletterDbContext> options)
: base(options)
{
}
// Each DbSet maps to a database table
public DbSet<ComponentTemplate> ComponentTemplates => Set<ComponentTemplate>();
public DbSet<Newsletter> Newsletters => Set<Newsletter>();
public DbSet<NewsletterBlock> NewsletterBlocks => Set<NewsletterBlock>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configure cascade delete: deleting a newsletter removes its blocks
modelBuilder.Entity<Newsletter>()
.HasMany(n => n.Blocks)
.WithOne(b => b.Newsletter)
.HasForeignKey(b => b.NewsletterId)
.OnDelete(DeleteBehavior.Cascade);
// Add an index for efficient block ordering queries
modelBuilder.Entity<NewsletterBlock>()
.HasIndex(b => new { b.NewsletterId, b.SortOrder });
// Seed the palette with five default component templates
modelBuilder.Entity<ComponentTemplate>().HasData(
new ComponentTemplate { Id = 1, Name = "Heading", Icon = "H", RenderType = RenderType.Heading, DefaultContent = "Your Heading Here", DefaultTextColor = "#333333" },
new ComponentTemplate { Id = 2, Name = "Text Block", Icon = "T", RenderType = RenderType.Text, DefaultContent = "Write your content here. This text block supports any length of text for your newsletter.", DefaultTextColor = "#333333" },
new ComponentTemplate { Id = 3, Name = "Image", Icon = "I", RenderType = RenderType.Image, DefaultContent = "" },
new ComponentTemplate { Id = 4, Name = "Button", Icon = "B", RenderType = RenderType.Button, DefaultContent = "Click Here", DefaultButtonUrl = "https://example.com" },
new ComponentTemplate { Id = 5, Name = "Divider", Icon = "D", RenderType = RenderType.Divider, DefaultContent = "" }
);
}
}
What does this code do?
The three DbSet properties tell EF Core to create a table for each model class. Think of a DbSet as a collection that is backed by a database table instead of in-memory storage.
The OnModelCreating method configures relationships and seed data. The cascade delete rule means that when you delete a newsletter, all of its blocks are automatically removed too. The HasData call seeds five default component templates so the palette is not empty on first launch.
Why DbContextFactory instead of AddDbContext?
In Blazor Server, a single "scope" stays alive for the entire user session. If you used AddDbContext, every component in that session would share one DbContext instance, which causes threading bugs.
AddDbContextFactory creates a fresh DbContext for each database operation, then disposes it. This is the Microsoft-recommended pattern for Blazor Server apps.
Now you will register the DbContext in the application's startup and add the database connection string.
- Open Program.cs in the project root (not inside the Data folder).
- Replace the entire contents of Program.cs with the following code:
using Microsoft.EntityFrameworkCore;
using NewsletterBuilder.Components;
using NewsletterBuilder.Data;
var builder = WebApplication.CreateBuilder(args);
// Register Razor component services with Interactive Server support
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// Register a DbContext factory so each DB operation gets its own context
builder.Services.AddDbContextFactory<NewsletterDbContext>(options =>
options.UseSqlite(
builder.Configuration.GetConnectionString("NewsletterDb")
?? "Data Source=NewsletterBuilder.db"));
var app = builder.Build();
// Apply any pending migrations on startup so the DB is always up to date
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider
.GetRequiredService<NewsletterDbContext>();
dbContext.Database.Migrate();
}
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
What changed in Program.cs?
Three additions connect EF Core to the app.
- AddDbContextFactory<NewsletterDbContext> registers the database context factory in the dependency injection container. The UseSqlite call tells EF Core to use SQLite and reads the connection string from appsettings.json.
- Database.Migrate() automatically applies any pending migrations when the app starts. This ensures the database is always up to date without you running a separate command each time.
- The using keyword in using var scope is C#'s equivalent of Python's with statement. It automatically cleans up the scope when the block ends.
- Open appsettings.json in the project root.
- Replace the entire contents with the following:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"NewsletterDb": "Data Source=NewsletterBuilder.db"
},
"AllowedHosts": "*"
}
What does the connection string do?
The ConnectionStrings section tells EF Core where to store the database. Data Source=NewsletterBuilder.db creates a SQLite file called NewsletterBuilder.db in the project folder. No server setup or connection configuration is needed.
- Save both Program.cs and appsettings.json.
Now you will create the initial database migration and apply it to generate the SQLite database file.
Terminal
- Create the initial migration and apply it by running these commands in the NewsletterBuilder project folder:
dotnet ef migrations add InitialCreate
dotnet ef database update
Visual Studio (Package Manager Console)
- Click Tools in the top menu bar.
- Select NuGet Package Manager, then Package Manager Console.
- Run the following commands in the console:
Add-Migration InitialCreate
Update-Database
- Confirm the database file was created by checking for NewsletterBuilder.db in the project folder.
- In Solution Explorer, click the Show All Files button at the top. You should see NewsletterBuilder.db listed in the project root.
Don't see the database file?
Make sure you saved Program.cs and appsettings.json before running the migration commands.
If you see a build error, check that the using Microsoft.EntityFrameworkCore; line is at the top of Program.cs and that both NuGet packages installed successfully.
Help me debug this
- Run the app again by pressing Ctrl+F5 in Visual Studio.
The app should launch in the browser just like before. The database is now ready behind the scenes.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files and that NewsletterBuilder.db exists in the project root.
ⓧ I'd like to double check the full code
Here are the DbContext, Program.cs, and appsettings.json files for comparison.
using Microsoft.EntityFrameworkCore;
namespace NewsletterBuilder.Data;
public class NewsletterDbContext : DbContext
{
public NewsletterDbContext(DbContextOptions<NewsletterDbContext> options)
: base(options)
{
}
public DbSet<ComponentTemplate> ComponentTemplates => Set<ComponentTemplate>();
public DbSet<Newsletter> Newsletters => Set<Newsletter>();
public DbSet<NewsletterBlock> NewsletterBlocks => Set<NewsletterBlock>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Newsletter>()
.HasMany(n => n.Blocks)
.WithOne(b => b.Newsletter)
.HasForeignKey(b => b.NewsletterId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<NewsletterBlock>()
.HasIndex(b => new { b.NewsletterId, b.SortOrder });
modelBuilder.Entity<ComponentTemplate>().HasData(
new ComponentTemplate { Id = 1, Name = "Heading", Icon = "H", RenderType = RenderType.Heading, DefaultContent = "Your Heading Here", DefaultTextColor = "#333333" },
new ComponentTemplate { Id = 2, Name = "Text Block", Icon = "T", RenderType = RenderType.Text, DefaultContent = "Write your content here. This text block supports any length of text for your newsletter.", DefaultTextColor = "#333333" },
new ComponentTemplate { Id = 3, Name = "Image", Icon = "I", RenderType = RenderType.Image, DefaultContent = "" },
new ComponentTemplate { Id = 4, Name = "Button", Icon = "B", RenderType = RenderType.Button, DefaultContent = "Click Here", DefaultButtonUrl = "https://example.com" },
new ComponentTemplate { Id = 5, Name = "Divider", Icon = "D", RenderType = RenderType.Divider, DefaultContent = "" }
);
}
}
using Microsoft.EntityFrameworkCore;
using NewsletterBuilder.Components;
using NewsletterBuilder.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddDbContextFactory<NewsletterDbContext>(options =>
options.UseSqlite(
builder.Configuration.GetConnectionString("NewsletterDb")
?? "Data Source=NewsletterBuilder.db"));
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider
.GetRequiredService<NewsletterDbContext>();
dbContext.Database.Migrate();
}
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"NewsletterDb": "Data Source=NewsletterBuilder.db"
},
"AllowedHosts": "*"
}
Your project foundation is ready. You have a running Blazor app, four data model classes, and a SQLite database seeded with five component templates. Next up, you will build the Component Library page where you can create, edit, and delete templates.
Build the Component Library Manager
In the previous step, you created data models and a database to store component templates, newsletters, and blocks. Now you need a way for admins to actually manage that component library.
The component library is the palette of reusable email blocks (headings, text, images, buttons, dividers) that the newsletter editor will pull from. In this step, you will build your first interactive Blazor page: a full CRUD interface for managing component templates.
In this step, get ready to:
- Create the Component Library page that lists all templates from the database.
- Add a form for creating and editing templates using two-way data binding.
- Update the navigation menu with links to all three pages.
Create the Component Library page with list and delete
Every Razor component in Blazor is a .razor file that combines HTML markup with C# logic. Pages live in the Components/Pages/ folder and use an @page directive to define their route.
- Create a new file called ComponentLibrary.razor inside the Components/Pages/ folder.
- Paste the following code into it:
@page "/components"
@rendermode InteractiveServer
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
<PageTitle>Component Library</PageTitle>
<h1>Component Library</h1>
@if (templates is null)
{
<p>Loading...</p>
}
else
{
<table class="newsletter-table">
<thead>
<tr>
<th>Icon</th>
<th>Name</th>
<th>Type</th>
<th>Default Content</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var t in templates)
{
<tr>
<td><span class="palette-icon">@t.Icon</span></td>
<td>@t.Name</td>
<td>@t.RenderType</td>
<td>@(t.DefaultContent.Length > 40 ? t.DefaultContent[..40] + "..." : t.DefaultContent)</td>
<td>
<button @onclick="() => DeleteTemplate(t.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
@code {
// Holds the list of templates loaded from the database
private List<ComponentTemplate>? templates;
// Load templates when the page first renders
protected override async Task OnInitializedAsync()
{
await LoadTemplates();
}
// Create a fresh DbContext, query all templates, dispose the context
private async Task LoadTemplates()
{
using var context = await DbFactory.CreateDbContextAsync();
templates = await context.ComponentTemplates
.OrderBy(t => t.Id)
.ToListAsync();
}
// Find a template by ID and remove it from the database
private async Task DeleteTemplate(int id)
{
using var context = await DbFactory.CreateDbContextAsync();
var t = await context.ComponentTemplates.FindAsync(id);
if (t is not null)
{
context.ComponentTemplates.Remove(t);
await context.SaveChangesAsync();
}
await LoadTemplates();
}
}
What does this code do?
The five lines at the top of the file configure the page:
- @page "/components" makes this a routable page at the /components URL. This works like React Router's <Route path="/components"> or a Next.js page file.
- @rendermode InteractiveServer turns on real-time interactivity. Without it, the page renders as static HTML and button clicks do nothing.
- @inject IDbContextFactory<NewsletterDbContext> DbFactory is Blazor's dependency injection. Instead of creating the database factory yourself, the framework provides it automatically (similar to React's useContext or Angular's constructor injection).
In the @code block, every database operation follows the same pattern: using var context = await DbFactory.CreateDbContextAsync(). This creates a short-lived database connection that disposes itself at the end of the block, like Python's with open(...) as f: statement.
- Save ComponentLibrary.razor.
- In your browser, navigate to /components (add it to the end of your app's URL). You should see a table listing the five seeded templates: Heading, Text Block, Image, Button, and Divider.
- Click the Delete button on any template. The template disappears from the list immediately.
Page shows a blank screen or 404?
- Confirm your file is named exactly ComponentLibrary.razor (capital C, capital L) and is inside Components/Pages/. Blazor only discovers pages in this folder.
- Check that @page "/components" is the very first line of the file. A missing or misspelled route directive causes a 404.
- Make sure your app is still running. If you stopped dotnet watch, restart it from the terminal or press Ctrl+F5 in Visual Studio.
Why is my Component Library page not loading?
Add create and edit functionality with a form
Listing and deleting templates is only half the job. You also need a form that lets admins create new templates and edit existing ones.
Blazor uses @bind for two-way data binding. Writing @bind="template.Name" does the same thing as React's value={name} onChange={e => setName(e.target.value)} but in one attribute.
- In ComponentLibrary.razor, find the <h1>Component Library</h1> line.
- Paste the following form code between the <h1> line and the @if (templates is null) line:
<div class="template-form">
<h3>@(isEditing ? "Edit Template" : "Add New Template")</h3>
<div class="form-row">
<label>Name</label>
<!-- @bind syncs the input value with the C# property in real time -->
<input type="text" @bind="editTemplate.Name" @bind:event="oninput" placeholder="e.g. Sale Banner" />
</div>
<div class="form-row">
<label>Icon</label>
<input type="text" @bind="editTemplate.Icon" @bind:event="oninput" maxlength="2" placeholder="H" />
</div>
<div class="form-row">
<label>Render Type</label>
<!-- Loops through the RenderType enum values to build dropdown options -->
<select @bind="editTemplate.RenderType">
@foreach (var rt in Enum.GetValues<RenderType>())
{
<option value="@rt">@rt</option>
}
</select>
</div>
<div class="form-row">
<label>Default Content</label>
<textarea @bind="editTemplate.DefaultContent" @bind:event="oninput" rows="2"></textarea>
</div>
<div class="form-row">
<label>Text Color</label>
<input type="color" @bind="editTemplate.DefaultTextColor" />
<label>Background</label>
<input type="color" @bind="editTemplate.DefaultBackgroundColor" />
</div>
<div class="form-actions">
<!-- Button label changes based on whether we're adding or editing -->
<button class="btn-save" @onclick="SaveTemplate">
@(isEditing ? "Update" : "Add")
</button>
@if (isEditing)
{
<button class="btn-back" @onclick="CancelEdit">Cancel</button>
}
</div>
</div>
What is @bind?
@bind is Blazor's two-way data binding. It connects a form input directly to a C# property so changes flow in both directions. When the user types, the property updates. When the property changes in code, the input updates.
Adding @bind:event="oninput" makes the binding update on every keystroke instead of waiting until the field loses focus. This is what gives you the live-editing feel.
Next, the template table needs an Edit link so admins can click on a template to load it into the form.
- In the table's <tbody>, find the <td> that contains the Delete button.
- Find this line:
<td>
<button @onclick="() => DeleteTemplate(t.Id)">Delete</button>
</td>
- Add an Edit link before the Delete button, so the <td> now looks like this:
<td>
<a href="#" @onclick="() => StartEdit(t)" @onclick:preventDefault="true">Edit</a>
<button @onclick="() => DeleteTemplate(t.Id)">Delete</button>
</td>
Now the markup is complete, but the form references several C# methods and fields that do not exist yet.
- Replace the entire @code { } block in ComponentLibrary.razor with this updated version that includes the create, edit, and save logic:
@code {
// Holds the list of templates loaded from the database
private List<ComponentTemplate>? templates;
// Holds the template currently being created or edited in the form
private ComponentTemplate editTemplate = new();
// Tracks whether the form is in "edit" mode or "add" mode
private bool isEditing;
protected override async Task OnInitializedAsync()
{
await LoadTemplates();
}
private async Task LoadTemplates()
{
using var context = await DbFactory.CreateDbContextAsync();
templates = await context.ComponentTemplates
.OrderBy(t => t.Id)
.ToListAsync();
}
// Copy the selected template's values into a new object for editing
private void StartEdit(ComponentTemplate t)
{
isEditing = true;
editTemplate = new ComponentTemplate
{
Id = t.Id,
Name = t.Name,
Icon = t.Icon,
RenderType = t.RenderType,
DefaultContent = t.DefaultContent,
DefaultBackgroundColor = t.DefaultBackgroundColor,
DefaultTextColor = t.DefaultTextColor,
DefaultImageUrl = t.DefaultImageUrl,
DefaultButtonUrl = t.DefaultButtonUrl
};
}
// Reset the form back to "add" mode
private void CancelEdit()
{
isEditing = false;
editTemplate = new();
}
// Save a new template or update an existing one
private async Task SaveTemplate()
{
using var context = await DbFactory.CreateDbContextAsync();
if (isEditing)
{
// Find the existing record and copy the edited values onto it
var existing = await context.ComponentTemplates.FindAsync(editTemplate.Id);
if (existing is not null)
{
existing.Name = editTemplate.Name;
existing.Icon = editTemplate.Icon;
existing.RenderType = editTemplate.RenderType;
existing.DefaultContent = editTemplate.DefaultContent;
existing.DefaultBackgroundColor = editTemplate.DefaultBackgroundColor;
existing.DefaultTextColor = editTemplate.DefaultTextColor;
existing.DefaultImageUrl = editTemplate.DefaultImageUrl;
existing.DefaultButtonUrl = editTemplate.DefaultButtonUrl;
}
}
else
{
// Add the new template directly to the DbSet
context.ComponentTemplates.Add(editTemplate);
}
await context.SaveChangesAsync();
isEditing = false;
editTemplate = new();
await LoadTemplates();
}
private async Task DeleteTemplate(int id)
{
using var context = await DbFactory.CreateDbContextAsync();
var t = await context.ComponentTemplates.FindAsync(id);
if (t is not null)
{
context.ComponentTemplates.Remove(t);
await context.SaveChangesAsync();
}
await LoadTemplates();
}
}
What does this code do?
- editTemplate is a blank ComponentTemplate object that the form's @bind attributes are connected to. As the user types, this object's properties update in real time.
- StartEdit copies the clicked template's values into a brand-new object. This prevents the form from editing the list item directly, which would change the table row before the user hits Save.
- SaveTemplate checks the isEditing flag to decide whether to update an existing record or add a new one. After saving, it resets the form and reloads the list.
- @onclick:preventDefault="true" on the Edit link stops the browser from following the href="#" and scrolling to the top of the page.
- Save ComponentLibrary.razor.
- Refresh /components in your browser. You should see the form at the top with fields for Name, Icon, Render Type, Default Content, and two color pickers.
- Type Sale Banner in the Name field, set the icon to S, choose Heading as the Render Type, and click Add. The new template appears in the table below.
- Click Edit on any template. The form populates with that template's values and the button changes to Update.
Clicking Add or Edit does nothing?
- Make sure @rendermode InteractiveServer is on the second line of your file. Without it, all event handlers (@onclick, @bind) are silently ignored because the page renders as static HTML.
- Check that the @code block includes the editTemplate and isEditing fields. The form markup references these. Missing them causes a compile error.
- Look at the terminal or Output window for any error messages. A missing @using NewsletterBuilder.Data directive will prevent the page from compiling.
Why aren't my form buttons working in Blazor?
✔️ Awesome, I've got everything!
Great. Double check you have saved ComponentLibrary.razor before continuing.
ⓧ I'd like to double check the full code
Here is the complete ComponentLibrary.razor file. Make sure your file matches this exactly:
@page "/components"
@rendermode InteractiveServer
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
<PageTitle>Component Library</PageTitle>
<h1>Component Library</h1>
<div class="template-form">
<h3>@(isEditing ? "Edit Template" : "Add New Template")</h3>
<div class="form-row">
<label>Name</label>
<input type="text" @bind="editTemplate.Name" @bind:event="oninput" placeholder="e.g. Sale Banner" />
</div>
<div class="form-row">
<label>Icon</label>
<input type="text" @bind="editTemplate.Icon" @bind:event="oninput" maxlength="2" placeholder="H" />
</div>
<div class="form-row">
<label>Render Type</label>
<select @bind="editTemplate.RenderType">
@foreach (var rt in Enum.GetValues<RenderType>())
{
<option value="@rt">@rt</option>
}
</select>
</div>
<div class="form-row">
<label>Default Content</label>
<textarea @bind="editTemplate.DefaultContent" @bind:event="oninput" rows="2"></textarea>
</div>
<div class="form-row">
<label>Text Color</label>
<input type="color" @bind="editTemplate.DefaultTextColor" />
<label>Background</label>
<input type="color" @bind="editTemplate.DefaultBackgroundColor" />
</div>
<div class="form-actions">
<button class="btn-save" @onclick="SaveTemplate">
@(isEditing ? "Update" : "Add")
</button>
@if (isEditing)
{
<button class="btn-back" @onclick="CancelEdit">Cancel</button>
}
</div>
</div>
@if (templates is null)
{
<p>Loading...</p>
}
else
{
<table class="newsletter-table">
<thead>
<tr>
<th>Icon</th>
<th>Name</th>
<th>Type</th>
<th>Default Content</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var t in templates)
{
<tr>
<td><span class="palette-icon">@t.Icon</span></td>
<td>@t.Name</td>
<td>@t.RenderType</td>
<td>@(t.DefaultContent.Length > 40 ? t.DefaultContent[..40] + "..." : t.DefaultContent)</td>
<td>
<a href="#" @onclick="() => StartEdit(t)" @onclick:preventDefault="true">Edit</a>
<button @onclick="() => DeleteTemplate(t.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
@code {
private List<ComponentTemplate>? templates;
private ComponentTemplate editTemplate = new();
private bool isEditing;
protected override async Task OnInitializedAsync()
{
await LoadTemplates();
}
private async Task LoadTemplates()
{
using var context = await DbFactory.CreateDbContextAsync();
templates = await context.ComponentTemplates
.OrderBy(t => t.Id)
.ToListAsync();
}
private void StartEdit(ComponentTemplate t)
{
isEditing = true;
editTemplate = new ComponentTemplate
{
Id = t.Id,
Name = t.Name,
Icon = t.Icon,
RenderType = t.RenderType,
DefaultContent = t.DefaultContent,
DefaultBackgroundColor = t.DefaultBackgroundColor,
DefaultTextColor = t.DefaultTextColor,
DefaultImageUrl = t.DefaultImageUrl,
DefaultButtonUrl = t.DefaultButtonUrl
};
}
private void CancelEdit()
{
isEditing = false;
editTemplate = new();
}
private async Task SaveTemplate()
{
using var context = await DbFactory.CreateDbContextAsync();
if (isEditing)
{
var existing = await context.ComponentTemplates.FindAsync(editTemplate.Id);
if (existing is not null)
{
existing.Name = editTemplate.Name;
existing.Icon = editTemplate.Icon;
existing.RenderType = editTemplate.RenderType;
existing.DefaultContent = editTemplate.DefaultContent;
existing.DefaultBackgroundColor = editTemplate.DefaultBackgroundColor;
existing.DefaultTextColor = editTemplate.DefaultTextColor;
existing.DefaultImageUrl = editTemplate.DefaultImageUrl;
existing.DefaultButtonUrl = editTemplate.DefaultButtonUrl;
}
}
else
{
context.ComponentTemplates.Add(editTemplate);
}
await context.SaveChangesAsync();
isEditing = false;
editTemplate = new();
await LoadTemplates();
}
private async Task DeleteTemplate(int id)
{
using var context = await DbFactory.CreateDbContextAsync();
var t = await context.ComponentTemplates.FindAsync(id);
if (t is not null)
{
context.ComponentTemplates.Remove(t);
await context.SaveChangesAsync();
}
await LoadTemplates();
}
}
The last thing to do in this step is update the navigation menu so all pages are easy to reach.
- Open Components/Layout/NavMenu.razor in the editor.
- Replace its entire contents with the following code:
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">Newsletter Builder</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<!-- Link to the newsletter list (home page) -->
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
My Newsletters
</NavLink>
</div>
<!-- Link to the component template manager -->
<div class="nav-item px-3">
<NavLink class="nav-link" href="components">
Component Library
</NavLink>
</div>
<!-- Link to create a new newsletter -->
<div class="nav-item px-3">
<NavLink class="nav-link" href="editor">
New Newsletter
</NavLink>
</div>
</nav>
</div>
What is NavLink?
NavLink is a built-in Blazor component that works like an HTML <a> tag but automatically applies an "active" CSS class when the current URL matches its href. This highlights the current page in the sidebar without any extra JavaScript.
Match="NavLinkMatch.All" on the home link means it only highlights when the URL is exactly /. Without it, the home link would stay highlighted on every page since all URLs start with /.
- Save NavMenu.razor.
- Check the sidebar in your browser. You should see three links: My Newsletters, Component Library, and New Newsletter.
- Click Component Library in the sidebar. You should land on the /components page with the template list and form.
Sidebar still showing the old links?
- Make sure you saved NavMenu.razor and that the browser has refreshed. If using dotnet watch, it should hot-reload automatically.
- Check that you replaced the entire file contents, including the navbar-brand text. The brand should now read Newsletter Builder instead of the default app name.
Why is my Blazor sidebar not updating after I changed NavMenu.razor?
✔️ Awesome, I've got everything!
Great. Double check you have saved NavMenu.razor before continuing.
ⓧ I'd like to double check the full code
Here is the complete NavMenu.razor file. Make sure your file matches this exactly:
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">Newsletter Builder</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
My Newsletters
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="components">
Component Library
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="editor">
New Newsletter
</NavLink>
</div>
</nav>
</div>
Your component library is fully operational. Next up, you will build the newsletter editor with drag-and-drop so admins can pull templates from this library onto a live canvas.
Build the Newsletter Editor with Drag-and-Drop
Your component library is live. Admins can create, edit, and delete templates from the /components page, and those templates are sitting in the database ready to be used.
Now it's time to build the core of the app: a drag-and-drop newsletter editor. You'll compose multiple Razor components together, handle browser drag events entirely in C#, and render email-safe HTML that updates live as the user edits.
In this step, get ready to:
- Build the Palette and PaletteItem child components for the draggable template list.
- Build the Canvas and BlockRenderer components for the drop target and email-safe rendering.
- Create the Editor page that wires all components together with shared state and inline editing.
Build the Palette and PaletteItem components
The editor's left sidebar needs a palette showing all available component templates. You'll build this as two components: a small PaletteItem card that handles the drag, and a Palette wrapper that loads templates from the database and renders one card per template.
- In the Solution Explorer sidebar, right-click the Components folder.
- Select Add, then New Folder.
- Name the folder Newsletter.
Why a separate Newsletter folder?
Blazor uses folders to organize components by feature. The Components/Newsletter/ folder will hold the child components (Palette, Canvas, BlockRenderer) that are composed into the Editor page. The Components/Pages/ folder holds routable pages.
- Right-click the new Newsletter folder.
- Select Add, then Razor Component.
- Name it PaletteItem.razor and paste the following code:
<!-- A single draggable card in the component palette -->
<div class="palette-item" draggable="true"
@ondragstart="OnDragStart">
<span class="palette-icon">@Icon</span>
<span>@Label</span>
</div>
@code {
// Parameters passed from the parent component (like React props)
[Parameter] public int TemplateId { get; set; }
[Parameter] public string Label { get; set; } = string.Empty;
[Parameter] public string Icon { get; set; } = string.Empty;
// Callback to notify the parent which template is being dragged
[Parameter] public EventCallback<int> OnDragStarted { get; set; }
// Fires when the user starts dragging this item
private async Task OnDragStart(DragEventArgs e)
{
await OnDragStarted.InvokeAsync(TemplateId);
}
}
What does this code do?
This component renders a small card with an icon and label. The draggable="true" HTML attribute makes it draggable in the browser. The @ondragstart directive is Blazor's way of handling the browser's native drag event entirely in C#.
The [Parameter] attribute marks properties that the parent component passes in. This is identical to React's props. EventCallback<int> is how a child sends data back up to its parent, like React's onChange callback props.
- Save PaletteItem.razor.
You won't see a change yet. The next component, Palette, uses PaletteItem to build the full sidebar.
- Create another Razor Component in the Components/Newsletter folder named Palette.razor and paste the following code:
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
<div class="palette">
<h3>Components</h3>
@if (templates is not null)
{
@foreach (var t in templates)
{
<!-- Render a draggable card for each template in the database -->
<PaletteItem TemplateId="t.Id" Label="@t.Name" Icon="@t.Icon"
OnDragStarted="OnBlockDragStarted" />
}
}
</div>
@code {
// Callback that forwards the dragged template ID up to the Editor page
[Parameter] public EventCallback<int> OnBlockDragStarted { get; set; }
private List<ComponentTemplate>? templates;
// Load all component templates from the database when the component initializes
protected override async Task OnInitializedAsync()
{
using var context = await DbFactory.CreateDbContextAsync();
templates = await context.ComponentTemplates
.OrderBy(t => t.Id)
.ToListAsync();
}
}
What does this code do?
The Palette loads every ComponentTemplate from the database when it initializes, then renders a PaletteItem for each one. It passes each template's Id, Name, and Icon down as parameters.
Notice how the drag event flows: PaletteItem fires OnDragStarted with a template ID, Palette receives it via OnBlockDragStarted, and then forwards it up to the Editor page. This parent-child callback chain is the standard Blazor pattern for data flowing upward.
- Save Palette.razor.
You won't see a change yet. The Editor page in the next substep renders the Palette on screen.
Why native drag-and-drop instead of a JavaScript library?
Libraries like SortableJS are designed for reordering items within a list. This editor uses a different pattern: dragging from a palette onto a canvas. Blazor's built-in @ondragstart, @ondragover, and @ondrop directives handle this cleanly without adding JavaScript interop complexity.
For reordering blocks on the canvas, you'll use simple Move Up and Move Down buttons. This avoids DOM conflict issues between JavaScript drag libraries and Blazor's virtual DOM rendering.
✔️ Awesome, I've got everything!
Great. Make sure you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is PaletteItem.razor:
<div class="palette-item" draggable="true"
@ondragstart="OnDragStart">
<span class="palette-icon">@Icon</span>
<span>@Label</span>
</div>
@code {
[Parameter] public int TemplateId { get; set; }
[Parameter] public string Label { get; set; } = string.Empty;
[Parameter] public string Icon { get; set; } = string.Empty;
[Parameter] public EventCallback<int> OnDragStarted { get; set; }
private async Task OnDragStart(DragEventArgs e)
{
await OnDragStarted.InvokeAsync(TemplateId);
}
}
Here is Palette.razor:
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
<div class="palette">
<h3>Components</h3>
@if (templates is not null)
{
@foreach (var t in templates)
{
<PaletteItem TemplateId="t.Id" Label="@t.Name" Icon="@t.Icon"
OnDragStarted="OnBlockDragStarted" />
}
}
</div>
@code {
[Parameter] public EventCallback<int> OnBlockDragStarted { get; set; }
private List<ComponentTemplate>? templates;
protected override async Task OnInitializedAsync()
{
using var context = await DbFactory.CreateDbContextAsync();
templates = await context.ComponentTemplates
.OrderBy(t => t.Id)
.ToListAsync();
}
}
Build the Canvas and BlockRenderer components
The canvas is the drop target where newsletter blocks land. Each block on the canvas is rendered by a BlockRenderer component that produces email-safe table-based HTML with inline styles.
Why table-based HTML with inline styles?
Email clients like Outlook, Gmail, and Yahoo have very limited CSS support. They strip out external stylesheets and ignore most modern layout techniques like flexbox and grid. The industry standard for email HTML is table-based layout with all styles written inline on each element.
- Create a new Razor Component in the Components/Newsletter folder named BlockRenderer.razor and paste the following code:
@using NewsletterBuilder.Data
<!-- Wrapper div that highlights when selected and handles click-to-select -->
<div class="canvas-block @(IsSelected ? "selected" : "")"
@onclick="OnSelect">
<div class="block-content">
@switch (Block.RenderType)
{
case RenderType.Heading:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px; background-color: @Block.BackgroundColor;
color: @Block.TextColor; font-size: 24px; font-weight: bold;
font-family: Arial, sans-serif;">
@Block.Content
</td>
</tr>
</table>
break;
case RenderType.Text:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px; background-color: @Block.BackgroundColor;
color: @Block.TextColor; font-size: 16px; line-height: 1.5;
font-family: Arial, sans-serif;">
@Block.Content
</td>
</tr>
</table>
break;
case RenderType.Image:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px; text-align: center;
background-color: @Block.BackgroundColor;">
@if (!string.IsNullOrEmpty(Block.ImageUrl))
{
<img src="@Block.ImageUrl" alt="@Block.AltText"
style="max-width: 100%; height: auto;" />
}
else
{
<div style="background: #e0e0e0; padding: 40px;
color: #888; font-family: Arial, sans-serif;">
Image Placeholder
</div>
}
</td>
</tr>
</table>
break;
case RenderType.Button:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px; text-align: center;
background-color: @Block.BackgroundColor;">
<a href="@(Block.ButtonUrl ?? "#")"
style="background-color: #007bff; color: #ffffff;
padding: 12px 24px; text-decoration: none;
border-radius: 4px; font-family: Arial, sans-serif;
font-size: 16px; display: inline-block;">
@Block.Content
</a>
</td>
</tr>
</table>
break;
case RenderType.Divider:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px;">
<hr style="border: none; border-top: 1px solid #dddddd;" />
</td>
</tr>
</table>
break;
}
</div>
<!-- Action buttons appear on hover; stopPropagation prevents click from also selecting -->
<div class="block-actions">
<button @onclick="OnMoveUp" @onclick:stopPropagation="true">Up</button>
<button @onclick="OnMoveDown" @onclick:stopPropagation="true">Down</button>
<button @onclick="OnDelete" @onclick:stopPropagation="true">X</button>
</div>
</div>
@code {
// The newsletter block data to render
[Parameter] public NewsletterBlock Block { get; set; } = null!;
[Parameter] public bool IsSelected { get; set; }
// Callbacks the parent Canvas uses to handle user actions
[Parameter] public EventCallback OnSelect { get; set; }
[Parameter] public EventCallback OnMoveUp { get; set; }
[Parameter] public EventCallback OnMoveDown { get; set; }
[Parameter] public EventCallback OnDelete { get; set; }
}
What does this code do?
The switch (Block.RenderType) statement renders different HTML based on the block type. Each case produces a <table> with inline styles. This is the same structure real email template builders use.
The Up, Down, and Delete buttons use @onclick:stopPropagation="true" to prevent the click from bubbling up to the wrapper div's @onclick="OnSelect" handler. Without this, clicking Delete would also select the block.
- Save BlockRenderer.razor.
You won't see a change yet. The Canvas component below uses BlockRenderer to display each dropped block.
- Create another Razor Component in the Components/Newsletter folder named Canvas.razor and paste the following code:
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
<!-- The drop target area; preventDefault on dragover is required to allow drops -->
<div class="canvas"
@ondragover="HandleDragOver"
@ondragover:preventDefault="true"
@ondrop="HandleDrop">
@if (Blocks.Count == 0)
{
<div class="canvas-empty">
Drag components here to build your newsletter
</div>
}
else
{
@for (int i = 0; i < Blocks.Count; i++)
{
var index = i;
var block = Blocks[index];
<BlockRenderer Block="block"
IsSelected="block == SelectedBlock"
OnSelect="() => SelectBlock(block)"
OnMoveUp="() => MoveBlockUp(index)"
OnMoveDown="() => MoveBlockDown(index)"
OnDelete="() => DeleteBlock(index)" />
}
}
</div>
@code {
// Parameters passed from the Editor page
[Parameter] public List<NewsletterBlock> Blocks { get; set; } = new();
[Parameter] public NewsletterBlock? SelectedBlock { get; set; }
[Parameter] public EventCallback<NewsletterBlock?> SelectedBlockChanged { get; set; }
[Parameter] public int? DraggedTemplateId { get; set; }
// Empty handler required so the @ondragover:preventDefault attribute works
private void HandleDragOver(DragEventArgs e)
{
}
// Creates a new block from the dragged template's defaults
private async Task HandleDrop(DragEventArgs e)
{
if (DraggedTemplateId is null) return;
using var context = await DbFactory.CreateDbContextAsync();
var template = await context.ComponentTemplates.FindAsync(DraggedTemplateId.Value);
if (template is null) return;
var newBlock = new NewsletterBlock
{
RenderType = template.RenderType,
Content = template.DefaultContent,
BackgroundColor = template.DefaultBackgroundColor,
TextColor = template.DefaultTextColor,
ImageUrl = template.DefaultImageUrl,
ButtonUrl = template.DefaultButtonUrl,
SortOrder = Blocks.Count
};
Blocks.Add(newBlock);
await SelectedBlockChanged.InvokeAsync(newBlock);
}
private void SelectBlock(NewsletterBlock block)
{
SelectedBlockChanged.InvokeAsync(block);
}
// Swap the block with its neighbor above
private void MoveBlockUp(int index)
{
if (index <= 0) return;
(Blocks[index], Blocks[index - 1]) = (Blocks[index - 1], Blocks[index]);
UpdateSortOrders();
}
// Swap the block with its neighbor below
private void MoveBlockDown(int index)
{
if (index >= Blocks.Count - 1) return;
(Blocks[index], Blocks[index + 1]) = (Blocks[index + 1], Blocks[index]);
UpdateSortOrders();
}
private void DeleteBlock(int index)
{
var block = Blocks[index];
Blocks.RemoveAt(index);
UpdateSortOrders();
if (block == SelectedBlock)
{
SelectedBlockChanged.InvokeAsync(null);
}
}
// Keep SortOrder values in sync with the list position
private void UpdateSortOrders()
{
for (int i = 0; i < Blocks.Count; i++)
{
Blocks[i].SortOrder = i;
}
}
}
What does this code do?
The Canvas handles three things. First, @ondragover:preventDefault="true" tells the browser to allow drops on this element. Without it, the browser blocks the drop by default.
Second, HandleDrop looks up the dragged template's defaults in the database and creates a brand new NewsletterBlock with those values. The new block is independent of the template, so editing a template later won't change existing newsletters.
Third, MoveBlockUp and MoveBlockDown use C# tuple swapping to reorder blocks. The var index = i line inside the for loop captures the current value. Without it, all lambdas would reference the final loop value.
- Save Canvas.razor.
You won't see a change yet. The next substep creates the Editor page and BlockEditor that bring everything together.
✔️ Awesome, I've got everything!
Great. Make sure you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is BlockRenderer.razor:
@using NewsletterBuilder.Data
<div class="canvas-block @(IsSelected ? "selected" : "")"
@onclick="OnSelect">
<div class="block-content">
@switch (Block.RenderType)
{
case RenderType.Heading:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px; background-color: @Block.BackgroundColor;
color: @Block.TextColor; font-size: 24px; font-weight: bold;
font-family: Arial, sans-serif;">
@Block.Content
</td>
</tr>
</table>
break;
case RenderType.Text:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px; background-color: @Block.BackgroundColor;
color: @Block.TextColor; font-size: 16px; line-height: 1.5;
font-family: Arial, sans-serif;">
@Block.Content
</td>
</tr>
</table>
break;
case RenderType.Image:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px; text-align: center;
background-color: @Block.BackgroundColor;">
@if (!string.IsNullOrEmpty(Block.ImageUrl))
{
<img src="@Block.ImageUrl" alt="@Block.AltText"
style="max-width: 100%; height: auto;" />
}
else
{
<div style="background: #e0e0e0; padding: 40px;
color: #888; font-family: Arial, sans-serif;">
Image Placeholder
</div>
}
</td>
</tr>
</table>
break;
case RenderType.Button:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px; text-align: center;
background-color: @Block.BackgroundColor;">
<a href="@(Block.ButtonUrl ?? "#")"
style="background-color: #007bff; color: #ffffff;
padding: 12px 24px; text-decoration: none;
border-radius: 4px; font-family: Arial, sans-serif;
font-size: 16px; display: inline-block;">
@Block.Content
</a>
</td>
</tr>
</table>
break;
case RenderType.Divider:
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 10px 20px;">
<hr style="border: none; border-top: 1px solid #dddddd;" />
</td>
</tr>
</table>
break;
}
</div>
<div class="block-actions">
<button @onclick="OnMoveUp" @onclick:stopPropagation="true">Up</button>
<button @onclick="OnMoveDown" @onclick:stopPropagation="true">Down</button>
<button @onclick="OnDelete" @onclick:stopPropagation="true">X</button>
</div>
</div>
@code {
[Parameter] public NewsletterBlock Block { get; set; } = null!;
[Parameter] public bool IsSelected { get; set; }
[Parameter] public EventCallback OnSelect { get; set; }
[Parameter] public EventCallback OnMoveUp { get; set; }
[Parameter] public EventCallback OnMoveDown { get; set; }
[Parameter] public EventCallback OnDelete { get; set; }
}
Here is Canvas.razor:
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
<div class="canvas"
@ondragover="HandleDragOver"
@ondragover:preventDefault="true"
@ondrop="HandleDrop">
@if (Blocks.Count == 0)
{
<div class="canvas-empty">
Drag components here to build your newsletter
</div>
}
else
{
@for (int i = 0; i < Blocks.Count; i++)
{
var index = i;
var block = Blocks[index];
<BlockRenderer Block="block"
IsSelected="block == SelectedBlock"
OnSelect="() => SelectBlock(block)"
OnMoveUp="() => MoveBlockUp(index)"
OnMoveDown="() => MoveBlockDown(index)"
OnDelete="() => DeleteBlock(index)" />
}
}
</div>
@code {
[Parameter] public List<NewsletterBlock> Blocks { get; set; } = new();
[Parameter] public NewsletterBlock? SelectedBlock { get; set; }
[Parameter] public EventCallback<NewsletterBlock?> SelectedBlockChanged { get; set; }
[Parameter] public int? DraggedTemplateId { get; set; }
private void HandleDragOver(DragEventArgs e)
{
}
private async Task HandleDrop(DragEventArgs e)
{
if (DraggedTemplateId is null) return;
using var context = await DbFactory.CreateDbContextAsync();
var template = await context.ComponentTemplates.FindAsync(DraggedTemplateId.Value);
if (template is null) return;
var newBlock = new NewsletterBlock
{
RenderType = template.RenderType,
Content = template.DefaultContent,
BackgroundColor = template.DefaultBackgroundColor,
TextColor = template.DefaultTextColor,
ImageUrl = template.DefaultImageUrl,
ButtonUrl = template.DefaultButtonUrl,
SortOrder = Blocks.Count
};
Blocks.Add(newBlock);
await SelectedBlockChanged.InvokeAsync(newBlock);
}
private void SelectBlock(NewsletterBlock block)
{
SelectedBlockChanged.InvokeAsync(block);
}
private void MoveBlockUp(int index)
{
if (index <= 0) return;
(Blocks[index], Blocks[index - 1]) = (Blocks[index - 1], Blocks[index]);
UpdateSortOrders();
}
private void MoveBlockDown(int index)
{
if (index >= Blocks.Count - 1) return;
(Blocks[index], Blocks[index + 1]) = (Blocks[index + 1], Blocks[index]);
UpdateSortOrders();
}
private void DeleteBlock(int index)
{
var block = Blocks[index];
Blocks.RemoveAt(index);
UpdateSortOrders();
if (block == SelectedBlock)
{
SelectedBlockChanged.InvokeAsync(null);
}
}
private void UpdateSortOrders()
{
for (int i = 0; i < Blocks.Count; i++)
{
Blocks[i].SortOrder = i;
}
}
}
Create the Editor page and wire everything together
You have four child components ready to go. Now you need two more pieces: a BlockEditor panel for inline editing, and the Editor page that composes everything into a three-column layout.
- Create a new Razor Component in the Components/Newsletter folder named BlockEditor.razor and paste the following code:
@using NewsletterBuilder.Data
@if (Block is not null)
{
<div class="block-editor">
<h4>Edit @Block.RenderType</h4>
@if (Block.RenderType == RenderType.Heading || Block.RenderType == RenderType.Text)
{
<label>Content</label>
<!-- oninput updates on every keystroke instead of waiting for blur -->
<textarea @bind="Block.Content" @bind:event="oninput" rows="3"></textarea>
<label>Text Color</label>
<input type="color" @bind="Block.TextColor" />
<label>Background Color</label>
<input type="color" @bind="Block.BackgroundColor" />
}
else if (Block.RenderType == RenderType.Image)
{
<label>Image URL</label>
<input type="text" @bind="Block.ImageUrl" @bind:event="oninput"
placeholder="https://example.com/image.jpg" />
<label>Alt Text</label>
<input type="text" @bind="Block.AltText" @bind:event="oninput"
placeholder="Describe the image" />
<label>Background Color</label>
<input type="color" @bind="Block.BackgroundColor" />
}
else if (Block.RenderType == RenderType.Button)
{
<label>Button Text</label>
<input type="text" @bind="Block.Content" @bind:event="oninput" />
<label>Button URL</label>
<input type="text" @bind="Block.ButtonUrl" @bind:event="oninput"
placeholder="https://example.com" />
<label>Background Color</label>
<input type="color" @bind="Block.BackgroundColor" />
}
else if (Block.RenderType == RenderType.Divider)
{
<p>No editable properties for dividers.</p>
}
</div>
}
@code {
// The currently selected block passed from the Editor page
[Parameter] public NewsletterBlock? Block { get; set; }
}
What does this code do?
BlockEditor shows different form fields depending on the selected block's RenderType. Heading and Text blocks get content, text color, and background color fields. Image blocks get a URL and alt text. Button blocks get button text and URL.
The @bind:event="oninput" directive makes the binding update on every keystroke. This is what lets the canvas update live as you type. Writing @bind="Block.Content" does the same thing as React's value={content} onChange={e => setContent(e.target.value)} but in one attribute.
- Save BlockEditor.razor.
You won't see a change yet. The Editor page below renders BlockEditor in the right column.
- Create a new Razor Component in the Components/Pages folder named Editor.razor and paste the following code:
@page "/editor/{Id:int?}"
@rendermode InteractiveServer
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
@inject NavigationManager Navigation
<PageTitle>Newsletter Editor</PageTitle>
<!-- Editor header with name input, save button, and back link -->
<div class="editor-header">
<input type="text" @bind="newsletter.Name" @bind:event="oninput"
class="newsletter-name-input" placeholder="Newsletter name..." />
<button class="btn-save" @onclick="SaveNewsletter" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save")
</button>
<a href="/" class="btn-back">Back to List</a>
@if (!string.IsNullOrEmpty(saveMessage))
{
<span class="save-message">@saveMessage</span>
}
</div>
<!-- Three-column layout: palette | canvas | block editor -->
<div class="editor-layout">
<Palette OnBlockDragStarted="HandleDragStarted" />
<Canvas Blocks="newsletter.Blocks"
SelectedBlock="selectedBlock"
SelectedBlockChanged="block => selectedBlock = block"
DraggedTemplateId="draggedTemplateId" />
<BlockEditor Block="selectedBlock" />
</div>
@code {
// Optional route parameter for editing an existing newsletter
[Parameter] public int? Id { get; set; }
private Newsletter newsletter = new();
private NewsletterBlock? selectedBlock;
private int? draggedTemplateId;
private bool isSaving;
private string? saveMessage;
// Load existing newsletter if an ID is in the URL
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
using var context = await DbFactory.CreateDbContextAsync();
var loaded = await context.Newsletters
.Include(n => n.Blocks.OrderBy(b => b.SortOrder))
.FirstOrDefaultAsync(n => n.Id == Id.Value);
if (loaded is not null)
{
newsletter = loaded;
}
}
}
// Store the template ID when a palette item is dragged
private void HandleDragStarted(int templateId)
{
draggedTemplateId = templateId;
}
// Save the newsletter and its blocks to the database
private async Task SaveNewsletter()
{
isSaving = true;
saveMessage = null;
using var context = await DbFactory.CreateDbContextAsync();
// Ensure sort orders match the current block positions
for (int i = 0; i < newsletter.Blocks.Count; i++)
{
newsletter.Blocks[i].SortOrder = i;
}
newsletter.UpdatedAt = DateTime.UtcNow;
if (newsletter.Id == 0)
{
context.Newsletters.Add(newsletter);
}
else
{
context.Newsletters.Update(newsletter);
}
await context.SaveChangesAsync();
saveMessage = "Saved!";
isSaving = false;
// Redirect to the saved newsletter's URL for new newsletters
if (Id is null || Id == 0)
{
Navigation.NavigateTo($"/editor/{newsletter.Id}", replace: true);
}
}
}
What does this code do?
The Editor page is the orchestrator. It holds the shared state that all child components need: the newsletter object, which template is being dragged, and which block is selected. It passes this state down to Palette, Canvas, and BlockEditor via parameters.
The route @page "/editor/{Id:int?}" uses an optional route parameter. Navigating to /editor creates a new newsletter. Navigating to /editor/3 loads newsletter ID 3 from the database.
The SaveNewsletter method checks newsletter.Id == 0 to decide whether to add a new record or update an existing one. After saving a new newsletter, it redirects the URL to include the new ID so subsequent saves update instead of creating duplicates.
- Save Editor.razor.
The editor page will render, but it needs CSS to create the three-column layout. Without styles, the palette, canvas, and block editor will stack vertically.
- Open wwwroot/app.css and add the following styles to the bottom of the file:
.editor-layout {
display: flex;
gap: 16px;
padding: 16px;
min-height: calc(100vh - 120px);
}
.palette {
width: 200px;
flex-shrink: 0;
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
}
.palette h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #333;
}
.palette-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 8px;
background: #ffffff;
border: 1px solid #dee2e6;
border-radius: 6px;
cursor: grab;
font-size: 14px;
font-family: Arial, sans-serif;
transition: box-shadow 0.15s;
}
.palette-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.palette-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: #e9ecef;
border-radius: 4px;
font-weight: bold;
font-size: 14px;
color: #495057;
}
.canvas {
flex: 1;
min-height: 400px;
background: #ffffff;
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 16px;
max-width: 600px;
}
.canvas-empty {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: #adb5bd;
font-size: 16px;
font-family: Arial, sans-serif;
}
.canvas-block {
position: relative;
border: 2px solid transparent;
border-radius: 4px;
margin-bottom: 8px;
transition: border-color 0.15s;
}
.canvas-block:hover {
border-color: #dee2e6;
}
.canvas-block.selected {
border-color: #007bff;
}
.block-actions {
display: none;
position: absolute;
top: 4px;
right: 4px;
gap: 4px;
}
.canvas-block:hover .block-actions {
display: flex;
}
.block-actions button {
padding: 2px 8px;
font-size: 12px;
border: 1px solid #dee2e6;
background: #fff;
border-radius: 3px;
cursor: pointer;
}
.block-actions button:hover {
background: #f8f9fa;
}
.block-editor {
width: 260px;
flex-shrink: 0;
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
}
.block-editor h4 {
margin: 0 0 12px 0;
font-size: 15px;
}
.block-editor label {
display: block;
margin: 8px 0 4px;
font-size: 13px;
color: #555;
}
.block-editor input[type="text"],
.block-editor textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 14px;
font-family: Arial, sans-serif;
box-sizing: border-box;
}
.block-editor input[type="color"] {
width: 48px;
height: 32px;
border: 1px solid #dee2e6;
border-radius: 4px;
cursor: pointer;
}
.editor-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.newsletter-name-input {
font-size: 18px;
padding: 6px 12px;
border: 1px solid #dee2e6;
border-radius: 4px;
flex: 1;
max-width: 400px;
}
.btn-save {
padding: 8px 20px;
background: #007bff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-save:hover {
background: #0056b3;
}
.btn-save:disabled {
background: #6c757d;
cursor: not-allowed;
}
.btn-back {
color: #007bff;
text-decoration: none;
font-size: 14px;
background: none;
border: none;
cursor: pointer;
}
.save-message {
color: #28a745;
font-size: 14px;
}
.btn-new {
display: inline-block;
padding: 10px 20px;
background: #007bff;
color: #fff;
text-decoration: none;
border-radius: 4px;
margin-bottom: 16px;
font-size: 14px;
}
.btn-new:hover {
background: #0056b3;
}
.newsletter-table {
width: 100%;
border-collapse: collapse;
}
.newsletter-table th,
.newsletter-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid #dee2e6;
}
.newsletter-table th {
background: #f8f9fa;
font-size: 13px;
color: #555;
text-transform: uppercase;
}
.newsletter-table a {
color: #007bff;
text-decoration: none;
margin-right: 8px;
}
.newsletter-table button {
padding: 4px 10px;
border: 1px solid #dc3545;
background: transparent;
color: #dc3545;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
margin-right: 4px;
}
.newsletter-table button:hover {
background: #dc3545;
color: #fff;
}
.btn-duplicate {
border-color: #17a2b8 !important;
color: #17a2b8 !important;
}
.btn-duplicate:hover {
background: #17a2b8 !important;
color: #fff !important;
}
.template-form {
background: #f8f9fa;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.template-form h3 {
margin: 0 0 12px 0;
font-size: 16px;
}
.form-row {
margin-bottom: 8px;
}
.form-row label {
display: block;
font-size: 13px;
color: #555;
margin-bottom: 4px;
}
.form-row input[type="text"],
.form-row textarea,
.form-row select {
width: 100%;
max-width: 400px;
padding: 6px 8px;
border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 14px;
font-family: Arial, sans-serif;
box-sizing: border-box;
}
.form-row input[type="color"] {
width: 48px;
height: 32px;
border: 1px solid #dee2e6;
border-radius: 4px;
cursor: pointer;
vertical-align: middle;
margin-right: 12px;
}
.form-actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
What do these styles do?
The .editor-layout class creates a three-column flexbox layout for the palette, canvas, and block editor. The palette and block editor have fixed widths. The canvas fills the remaining space up to 600px.
The .block-actions buttons are hidden by default and only appear on hover, keeping the canvas clean. The .canvas-block.selected class adds a blue border so you can see which block is being edited.
- Save app.css.
- Run the app using Ctrl+F5 in Visual Studio or dotnet watch in the terminal.
- Click New Newsletter in the left navigation menu.
You should see a three-column layout. The palette on the left shows your five seeded component templates (Heading, Text Block, Image, Button, Divider). The center canvas shows a dashed border with "Drag components here to build your newsletter" placeholder text.
- Drag the Heading item from the palette and drop it onto the canvas.
A styled heading block appears on the canvas with the default text "Your Heading Here". The block editor panel on the right shows content, text color, and background color fields.
- Click the heading block on the canvas to select it.
- Type new text in the Content field in the edit panel on the right.
The canvas updates in real time as you type. This is the two-way data binding from @bind:event="oninput" in action.
- Drag a Text Block and a Button onto the canvas to add more blocks.
- Hover over a block and click Up or Down to reorder it.
- Click X on a block to delete it from the canvas.
Drag-and-drop not working?
Make sure your Editor page has @rendermode InteractiveServer at the top. Without it, the page renders as static HTML and no event handlers fire.
If blocks don't appear after dragging, check that Canvas.razor has both @ondragover:preventDefault="true" and @ondrop="HandleDrop" on the canvas div.
If the layout looks stacked instead of side-by-side, confirm you saved the CSS additions to wwwroot/app.css and that .editor-layout has display: flex.
Still stuck? Help me debug why drag-and-drop is not working in my Blazor newsletter editor.
✔️ Awesome, I've got everything!
Great. Double check you've saved all files before moving on.
ⓧ I'd like to double check the full code
Here is BlockEditor.razor:
@using NewsletterBuilder.Data
@if (Block is not null)
{
<div class="block-editor">
<h4>Edit @Block.RenderType</h4>
@if (Block.RenderType == RenderType.Heading || Block.RenderType == RenderType.Text)
{
<label>Content</label>
<textarea @bind="Block.Content" @bind:event="oninput" rows="3"></textarea>
<label>Text Color</label>
<input type="color" @bind="Block.TextColor" />
<label>Background Color</label>
<input type="color" @bind="Block.BackgroundColor" />
}
else if (Block.RenderType == RenderType.Image)
{
<label>Image URL</label>
<input type="text" @bind="Block.ImageUrl" @bind:event="oninput"
placeholder="https://example.com/image.jpg" />
<label>Alt Text</label>
<input type="text" @bind="Block.AltText" @bind:event="oninput"
placeholder="Describe the image" />
<label>Background Color</label>
<input type="color" @bind="Block.BackgroundColor" />
}
else if (Block.RenderType == RenderType.Button)
{
<label>Button Text</label>
<input type="text" @bind="Block.Content" @bind:event="oninput" />
<label>Button URL</label>
<input type="text" @bind="Block.ButtonUrl" @bind:event="oninput"
placeholder="https://example.com" />
<label>Background Color</label>
<input type="color" @bind="Block.BackgroundColor" />
}
else if (Block.RenderType == RenderType.Divider)
{
<p>No editable properties for dividers.</p>
}
</div>
}
@code {
[Parameter] public NewsletterBlock? Block { get; set; }
}
Here is Editor.razor:
@page "/editor/{Id:int?}"
@rendermode InteractiveServer
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
@inject NavigationManager Navigation
<PageTitle>Newsletter Editor</PageTitle>
<div class="editor-header">
<input type="text" @bind="newsletter.Name" @bind:event="oninput"
class="newsletter-name-input" placeholder="Newsletter name..." />
<button class="btn-save" @onclick="SaveNewsletter" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save")
</button>
<a href="/" class="btn-back">Back to List</a>
@if (!string.IsNullOrEmpty(saveMessage))
{
<span class="save-message">@saveMessage</span>
}
</div>
<div class="editor-layout">
<Palette OnBlockDragStarted="HandleDragStarted" />
<Canvas Blocks="newsletter.Blocks"
SelectedBlock="selectedBlock"
SelectedBlockChanged="block => selectedBlock = block"
DraggedTemplateId="draggedTemplateId" />
<BlockEditor Block="selectedBlock" />
</div>
@code {
[Parameter] public int? Id { get; set; }
private Newsletter newsletter = new();
private NewsletterBlock? selectedBlock;
private int? draggedTemplateId;
private bool isSaving;
private string? saveMessage;
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
using var context = await DbFactory.CreateDbContextAsync();
var loaded = await context.Newsletters
.Include(n => n.Blocks.OrderBy(b => b.SortOrder))
.FirstOrDefaultAsync(n => n.Id == Id.Value);
if (loaded is not null)
{
newsletter = loaded;
}
}
}
private void HandleDragStarted(int templateId)
{
draggedTemplateId = templateId;
}
private async Task SaveNewsletter()
{
isSaving = true;
saveMessage = null;
using var context = await DbFactory.CreateDbContextAsync();
for (int i = 0; i < newsletter.Blocks.Count; i++)
{
newsletter.Blocks[i].SortOrder = i;
}
newsletter.UpdatedAt = DateTime.UtcNow;
if (newsletter.Id == 0)
{
context.Newsletters.Add(newsletter);
}
else
{
context.Newsletters.Update(newsletter);
}
await context.SaveChangesAsync();
saveMessage = "Saved!";
isSaving = false;
if (Id is null || Id == 0)
{
Navigation.NavigateTo($"/editor/{newsletter.Id}", replace: true);
}
}
}
Your drag-and-drop editor is working. Next up, you'll connect it to the database so newsletters are saved, loaded, duplicated, and deleted from a management page.
Add Newsletter Management
You now have a fully interactive newsletter editor with drag-and-drop and inline editing. There is one big problem though.
Close the browser and reopen it, and everything you built disappears. In this step, you will walk through how the editor saves and loads newsletters from the database, then build a newsletter list page with edit, duplicate, and delete actions.
In this step, get ready to:
- Understand the Editor's save and load database logic.
- Build a Newsletter List page with edit, duplicate, and delete.
- Test the full newsletter management flow end to end.
Understand the Editor's save and load logic
The Editor page you built in the previous step already has a Save button and a name input in its header. It also has the C# logic to persist newsletters to the database and reload them by ID. Before building the list page, let's understand how these patterns work.
- In Editor.razor, scroll to the @code block and find the OnInitializedAsync method:
protected override async Task OnInitializedAsync()
{
if (Id.HasValue)
{
using var context = await DbFactory.CreateDbContextAsync();
var loaded = await context.Newsletters
.Include(n => n.Blocks.OrderBy(b => b.SortOrder))
.FirstOrDefaultAsync(n => n.Id == Id.Value);
if (loaded is not null)
{
newsletter = loaded;
}
}
}
What does this code do?
This method runs when the page loads. If the URL has an Id route parameter (like /editor/3), it loads that newsletter from the database.
- Include(n => n.Blocks.OrderBy(b => b.SortOrder)) is eager loading. Without it, EF Core would load the newsletter but leave its Blocks list empty. Think of it like a SQL JOIN that fetches related data in one query.
- using var context creates a short-lived DbContext and automatically disposes it when the method finishes. This is C#'s equivalent of Python's with open(...) as f: or a try/finally in JavaScript.
- FirstOrDefaultAsync returns the first matching newsletter or null if none exists.
- Now find the SaveNewsletter method below it:
private async Task SaveNewsletter()
{
isSaving = true;
saveMessage = null;
using var context = await DbFactory.CreateDbContextAsync();
// Update each block's sort order to match its position in the list
for (int i = 0; i < newsletter.Blocks.Count; i++)
{
newsletter.Blocks[i].SortOrder = i;
}
newsletter.UpdatedAt = DateTime.UtcNow;
// New newsletters have Id == 0 (EF Core assigns the real Id after saving)
if (newsletter.Id == 0)
{
context.Newsletters.Add(newsletter);
}
else
{
context.Newsletters.Update(newsletter);
}
await context.SaveChangesAsync();
saveMessage = "Saved!";
isSaving = false;
// For brand-new newsletters, update the URL to include the new Id
if (Id is null || Id == 0)
{
Navigation.NavigateTo($"/editor/{newsletter.Id}", replace: true);
}
}
How does save vs update work?
EF Core assigns auto-incrementing IDs. Before a newsletter is saved for the first time, its Id is 0. After SaveChangesAsync, EF Core fills in the real database ID.
The NavigateTo call with replace: true updates the browser URL from /editor to /editor/3 without adding a new entry to the back button history. The async/await pattern here works identically to JavaScript's async/await.
- Test the save and load flow by running the app.
- Navigate to /editor in your browser.
- Type a name like June Update in the name input.
- Drag a Heading and a Text Block onto the canvas.
- Click Save.
You should see a green "Saved!" message appear next to the button. The browser URL should change to include the newsletter's ID (for example, /editor/1).
Not seeing the Saved message?
- Make sure the app is running with @rendermode InteractiveServer at the top of Editor.razor. Without it, button clicks do nothing.
- Check the terminal for database errors. If you see a migration error, stop the app and run dotnet ef database update again.
Help me debug why my newsletter isn't saving to the database.
Build the Newsletter List page
Now that the editor can save and load newsletters, you need a home page that lists all of them. This page will let you edit, duplicate, and delete newsletters.
- Create a new file called NewsletterList.razor inside the Components/Pages/ folder.
- Add the page directives and markup by pasting this code:
@page "/"
@rendermode InteractiveServer
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
@inject NavigationManager Navigation
<PageTitle>My Newsletters</PageTitle>
<h1>My Newsletters</h1>
<!-- Link to create a brand-new newsletter in the editor -->
<a href="/editor" class="btn-new">Create New Newsletter</a>
@if (newsletters is null)
{
<p>Loading...</p>
}
else if (newsletters.Count == 0)
{
<p>No newsletters yet. Create your first one!</p>
}
else
{
<!-- Table listing all saved newsletters -->
<table class="newsletter-table">
<thead>
<tr>
<th>Name</th>
<th>Blocks</th>
<th>Last Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var nl in newsletters)
{
<tr>
<td>@nl.Name</td>
<td>@nl.Blocks.Count</td>
<td>@nl.UpdatedAt.ToString("g")</td>
<td>
<!-- Edit navigates to the editor with this newsletter's Id -->
<a href="/editor/@nl.Id">Edit</a>
<button class="btn-duplicate" @onclick="() => DuplicateNewsletter(nl.Id)">
Duplicate
</button>
<button @onclick="() => DeleteNewsletter(nl.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
What does this markup do?
- @page "/" makes this the app's home page. When you navigate to the root URL, this page loads.
- The @if/else if/else block handles three states: loading, empty, and populated. This prevents the table from rendering before the data arrives.
- Each row shows the newsletter name, how many blocks it contains, and when it was last updated.
- The Edit link uses a plain <a> tag with the newsletter's ID in the URL. Duplicate and Delete use @onclick handlers since they need to run C# logic.
You won't see a change yet. The next chunk adds the C# logic that powers this page.
- Add the @code block below the closing } of the markup section you just pasted:
@code {
private List<Newsletter>? newsletters;
// Load all newsletters when the page initializes
protected override async Task OnInitializedAsync()
{
await LoadNewsletters();
}
// Fetch newsletters from the database, ordered by most recently updated
private async Task LoadNewsletters()
{
using var context = await DbFactory.CreateDbContextAsync();
newsletters = await context.Newsletters
.Include(n => n.Blocks)
.OrderByDescending(n => n.UpdatedAt)
.ToListAsync();
}
// Create a deep copy of a newsletter with all its blocks
private async Task DuplicateNewsletter(int id)
{
using var context = await DbFactory.CreateDbContextAsync();
var original = await context.Newsletters
.Include(n => n.Blocks)
.FirstOrDefaultAsync(n => n.Id == id);
if (original is null) return;
// Create new Newsletter and new NewsletterBlock objects (new IDs assigned on save)
var copy = new Newsletter
{
Name = original.Name + " (Copy)",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
Blocks = original.Blocks.Select(b => new NewsletterBlock
{
RenderType = b.RenderType,
Content = b.Content,
ImageUrl = b.ImageUrl,
AltText = b.AltText,
ButtonUrl = b.ButtonUrl,
BackgroundColor = b.BackgroundColor,
TextColor = b.TextColor,
SortOrder = b.SortOrder
}).ToList()
};
context.Newsletters.Add(copy);
await context.SaveChangesAsync();
Navigation.NavigateTo($"/editor/{copy.Id}");
}
// Delete a newsletter (cascade delete removes its blocks too)
private async Task DeleteNewsletter(int id)
{
using var context = await DbFactory.CreateDbContextAsync();
var nl = await context.Newsletters.FindAsync(id);
if (nl is not null)
{
context.Newsletters.Remove(nl);
await context.SaveChangesAsync();
}
await LoadNewsletters();
}
}
What does this code do?
- LoadNewsletters fetches all newsletters with their blocks using Include, then sorts by UpdatedAt so the most recently edited newsletter appears first.
- DuplicateNewsletter creates a deep copy. It uses LINQ's Select to create brand-new NewsletterBlock objects (with no IDs), so EF Core assigns fresh IDs on save. The copy gets " (Copy)" appended to its name.
- DeleteNewsletter removes the newsletter. Thanks to the DeleteBehavior.Cascade you configured in NewsletterDbContext, all of the newsletter's blocks are automatically deleted too.
- Save NewsletterList.razor.
- In the browser, click My Newsletters in the left sidebar.
You should see the newsletter you saved earlier ("June Update") in the table with its block count and last updated timestamp.
Seeing a blank page at the root URL?
- Confirm that @page "/" is the first line in NewsletterList.razor. This replaces the default home page.
- Check that @rendermode InteractiveServer is on line 2. Without it, the @onclick handlers and OnInitializedAsync will not run.
- If you see the old default home page, delete the Home.razor file from Components/Pages/. Two pages with @page "/" will conflict.
Why isn't my newsletter list page loading any data?
✔️ Awesome, I've got everything!
Great. Double check you have saved NewsletterList.razor.
ⓧ I'd like to double check the full code
@page "/"
@rendermode InteractiveServer
@using Microsoft.EntityFrameworkCore
@using NewsletterBuilder.Data
@inject IDbContextFactory<NewsletterDbContext> DbFactory
@inject NavigationManager Navigation
<PageTitle>My Newsletters</PageTitle>
<h1>My Newsletters</h1>
<a href="/editor" class="btn-new">Create New Newsletter</a>
@if (newsletters is null)
{
<p>Loading...</p>
}
else if (newsletters.Count == 0)
{
<p>No newsletters yet. Create your first one!</p>
}
else
{
<table class="newsletter-table">
<thead>
<tr>
<th>Name</th>
<th>Blocks</th>
<th>Last Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var nl in newsletters)
{
<tr>
<td>@nl.Name</td>
<td>@nl.Blocks.Count</td>
<td>@nl.UpdatedAt.ToString("g")</td>
<td>
<a href="/editor/@nl.Id">Edit</a>
<button class="btn-duplicate" @onclick="() => DuplicateNewsletter(nl.Id)">
Duplicate
</button>
<button @onclick="() => DeleteNewsletter(nl.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
@code {
private List<Newsletter>? newsletters;
protected override async Task OnInitializedAsync()
{
await LoadNewsletters();
}
private async Task LoadNewsletters()
{
using var context = await DbFactory.CreateDbContextAsync();
newsletters = await context.Newsletters
.Include(n => n.Blocks)
.OrderByDescending(n => n.UpdatedAt)
.ToListAsync();
}
private async Task DuplicateNewsletter(int id)
{
using var context = await DbFactory.CreateDbContextAsync();
var original = await context.Newsletters
.Include(n => n.Blocks)
.FirstOrDefaultAsync(n => n.Id == id);
if (original is null) return;
var copy = new Newsletter
{
Name = original.Name + " (Copy)",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
Blocks = original.Blocks.Select(b => new NewsletterBlock
{
RenderType = b.RenderType,
Content = b.Content,
ImageUrl = b.ImageUrl,
AltText = b.AltText,
ButtonUrl = b.ButtonUrl,
BackgroundColor = b.BackgroundColor,
TextColor = b.TextColor,
SortOrder = b.SortOrder
}).ToList()
};
context.Newsletters.Add(copy);
await context.SaveChangesAsync();
Navigation.NavigateTo($"/editor/{copy.Id}");
}
private async Task DeleteNewsletter(int id)
{
using var context = await DbFactory.CreateDbContextAsync();
var nl = await context.Newsletters.FindAsync(id);
if (nl is not null)
{
context.Newsletters.Remove(nl);
await context.SaveChangesAsync();
}
await LoadNewsletters();
}
}
Test the full management flow
All three pages are now connected. Let's run through the complete workflow to make sure everything works together.
- In the browser, navigate to the root URL (/) to see your newsletter list.
- Click Create New Newsletter.
- Name it Summer Sale and drag a few blocks onto the canvas.
- Click Save.
- Click Back to List in the editor header.
- On the list page, click Duplicate next to Summer Sale.
- Confirm the copy opens in the editor with the name Summer Sale (Copy) and all the same blocks.
- Click Back to List again.
- Click Delete next to the original Summer Sale.
- Verify it disappears from the table. The copy should still be there.
Why does deleting a newsletter also remove its blocks?
In NewsletterDbContext, you configured OnDelete(DeleteBehavior.Cascade) on the Newsletter-to-Blocks relationship. This tells the database to automatically delete all child blocks when their parent newsletter is removed. Without cascade delete, you would get a foreign key constraint error.
Your newsletter builder now has full management capabilities. Next up, you will tackle the Secret Mission and export your newsletters as email-ready HTML.
Secret mission
Export Newsletter as HTML
Your newsletter builder can create and manage newsletters. But what good is a newsletter you can't actually send? In this secret mission, you'll build an HTML exporter that converts your newsletter into copy-paste-ready email HTML with inline styles and table-based layout.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a SQLite database file, so there are no ongoing cloud costs or subscriptions to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- NewsletterBuilder project folder on the Desktop (contains all Razor components, data models, CSS, and configuration files).
- NewsletterBuilder.db SQLite database file (inside the project folder, stores component templates and newsletters).
- dotnet-ef global CLI tool (installed during project setup for running database migrations).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with the newsletter editor.
- Your NewsletterBuilder project folder stays on your Desktop, ready to open in Visual Studio whenever you want.
- The NewsletterBuilder.db SQLite file keeps all your saved newsletters and component templates.
- The dotnet-ef global tool remains installed on your machine for future EF Core projects.
Pause - I'll come back to this later
Shut down the running dev server to free up memory. Your project files stay on your Desktop for next time.
- If you are running the app from Visual Studio, close the browser tab and stop debugging by clicking the stop button in the toolbar.
- If you are running the app with dotnet watch in the terminal, press Ctrl+C to stop the server.
- Your project folder and database file remain on your Desktop. To resume, open the NewsletterBuilder project in Visual Studio and run it again.
Delete - I don't want to use this again
Remove all project files, the database, and the global tool to start fresh.
- If the app is still running, stop it first. In Visual Studio, stop debugging. In the terminal, press Ctrl+C.
- Close the NewsletterBuilder solution in Visual Studio if it is open.
Delete the project folder by running the command for your operating system.
macOS
rm -rf ~/Desktop/NewsletterBuilder
Windows
rmdir /s /q %USERPROFILE%\Desktop\NewsletterBuilder
This removes the entire NewsletterBuilder folder, including the NewsletterBuilder.db SQLite database and all your saved newsletters.
- Uninstall the dotnet-ef global tool by running:
dotnet tool uninstall --global dotnet-ef
Your machine is now back to where it was before the project.
Nice Work!
Nice Work!
You just built a fully functional email newsletter builder using Blazor Web App with Interactive Server rendering on .NET 10. From a drag-and-drop editor to a reusable component library backed by a real database, this is the exact pattern behind production admin panels.
You've learned how to:
- Built Razor components with parameters, EventCallbacks, and two-way data binding to create an interactive drag-and-drop newsletter editor with inline editing.
- Persisted structured data to SQLite using Entity Framework Core with the DbContextFactory pattern, including full CRUD for a component library and newsletter management with save, duplicate, and delete.
- Composed a multi-page app with routing, route parameters, and dependency injection to wire parent and child components together across a newsletter list, editor, and component library.
- Secret Mission: Exported newsletter layouts as email-ready HTML with inline styles and registered a custom service class in Blazor's dependency injection container.
Ready to quiz yourself?