EF Core 10 Migrations do more than shape a schema — they give you a disciplined home for the data every environment needs before anyone can sign in. In an enterprise Blazor Server application, reference data like roles, lookup tables, and default settings must exist on day one, and they must be identical across development, staging, and production. This article shows how to seed that data cleanly, from declarative HasData to a custom seeding service.
We will look at the two seeding strategies that work with EF Core 10 Migrations, the trade-offs between them, and how to run seeding safely inside a Blazor Server startup. The examples are written for a vertical-slice architecture with a single AppDbContext, which is exactly how Indotalent products are structured.
Where EF Core 10 Migrations and Seed Data Meet
Seed data is data that must exist in every environment: application roles, order statuses, payment methods, a default admin user. EF Core 10 gives you two main tools for seeding, and they complement each other.
- HasData — declarative rows embedded in the migration itself, versioned with the schema.
- Custom seeding — imperative application code that inserts data at startup, free to call business logic.
- Both can coexist — HasData for reference tables, custom seeding for data that depends on runtime state.
The choice matters because each strategy behaves differently across environments. HasData follows the migration chain, so it is identical everywhere and applied exactly once. Custom seeding can check what already exists and fill only the gaps.
There is a third consideration: when each strategy runs. HasData ships with the schema, so it is present the moment a fresh environment is created by a script — even before the application is deployed. Custom seeding waits for the first startup, which means a database restored from a backup gets seeded only when the app next runs. Knowing which strategy satisfies which timeline keeps your environments consistent; for a demo or sandbox that must be self-sufficient the moment it is provisioned, HasData wins, while data that must reflect configuration at startup time leaves you no honest choice but custom seeding.
HasData: Declarative Seeding Inside EF Core 10 Migrations
HasData is configured inside OnModelCreating and materializes as INSERT statements in the generated migration. Because it lives in the migration, it is applied in order with the rest of the schema and recorded in the migration history.
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<ApplicationRole>().HasData(
new ApplicationRole { Id = "1", Name = "Admin", NormalizedName = "ADMIN" },
new ApplicationRole { Id = "2", Name = "User", NormalizedName = "USER" });
}
There is one constraint worth learning: every HasData row needs a primary key so EF Core can track it, and changing a row later means changing its key. For reference data that never changes, that is perfect. For anything editable, use custom seeding instead — otherwise you will be writing migrations just to change a label.
Use explicit values rather than letting the database generate them. When HasData inserts roles or statuses, the IDs you assign become the contract — foreign keys, menu wiring, and authorization checks all reference those values. Deterministic IDs are why seeding can run once and never drift: the same code produces the same rows in every environment, which is precisely what an enterprise Blazor deployment depends on.
Custom Seeding for Enterprise Blazor Apps
When seeding must call business logic — hashing a password, checking an email, creating relationships between entities — a custom seeder is the right tool. A small DbInitializer service inspects the database and fills the gaps.
public class DbInitializer(AppDbContext db)
{
public async Task InitializeAsync()
{
if (!await db.Currencies.AnyAsync())
{
db.Currencies.AddRange(
new Currency { Code = "USD", Symbol = "$" },
new Currency { Code = "EUR", Symbol = "€" });
await db.SaveChangesAsync();
}
}
}
Note the guard clause: the seeder checks before it writes, so it is safe to run on every startup. That makes custom seeding naturally idempotent, which is exactly the property you want in an application that restarts often. For Blazor Server specifically, remember the DbContext is scoped — resolve it from a scope, never share it between circuits.
Keep the initializer small and focused. One service that handles every seed concern grows into a tangle; instead, let it delegate to small seeders per domain — a role seeder, a settings seeder, a demo-data seeder — each idempotent on its own. In a vertical-slice codebase, this mirrors the feature folders and keeps every seed change reviewable in isolation, exactly like the migrations themselves.
Seed Data in a Blazor Server Startup
The startup path for a Blazor Server application is Program.cs. You register the initializer as a scoped service, build the app, then run it once inside a scope before app.Run().
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<DbInitializer>()
.InitializeAsync();
}
app.Run();
Running seeding inside a using scope guarantees one DbContext per operation and clean disposal. Because Blazor Server keeps long-lived SignalR circuits, seeding must never run inside a component lifecycle method — startup is the only safe place, and the scope keeps the change tracker from leaking into request handling.
For applications that apply migrations at startup — common on the first deployment of an enterprise Blazor app — run the migration before the seeding. context.Database.MigrateAsync() followed by the initializer means the schema exists before any insert runs, and the two steps share one startup boundary. Once the app is live, move both steps to the deployment pipeline and keep startup read-only, so a restart can never wedge a running system.
Key Takeaways
- HasData embeds static reference data into the migration; custom seeding fills the rest at startup.
- HasData rows need stable primary keys and are perfect for lookup tables.
- A custom initializer with guard clauses is naturally idempotent.
- Run seeding once in Program.cs inside a service scope, never in a component.
- EF Core 10 Migrations plus seeding gives every environment the same schema and the same baseline data.
FAQ
Should I use HasData or a custom initializer?
Use HasData for static lookup data that is versioned with the schema, and a custom initializer for data that requires logic or can change. Most apps use both.
Is it safe to call SaveChanges during seeding?
Yes, inside the initializer's own scope. Just never share a DbContext across circuits or components in Blazor Server, and keep seeding out of the request pipeline.
Can I seed tenant data in a multi-tenant Blazor app?
Yes. Run the global seed once for shared reference data, then loop through registered tenants and run per-tenant seeding with a tenant-scoped connection, using the same initializer pattern.
Where can I see seeding done right in a real Blazor app?
Every Indotalent product ships complete .NET 10 source code with EF Core 10 Migrations, HasData seeding, and a ready-to-run schema — $21 each.