How to Build Your Own Extension
Every official EasyAdminBlazor extension (Approval, Print, Scheduler, Multi-Tenant, WeChat, …) follows the same recipe: a standalone class library plus one AddEasyAdminBlazorXxx() registration method. Add one line in the host project and the feature is installed; don't reference the package and it does not exist at all — the code is not even compiled. This guide breaks the recipe down so you can package your own feature the same way.
1. First: Should It Be an Extension?
| Your situation | Recommendation |
|---|---|
| Used by one project only, tightly coupled to the business | Write it straight into the host project, see Starting a New Module |
| Reused across projects, needs its own version | Make it an extension library |
| Needs to be switched on/off per project or per customer | Make it an extension (not referenced = not present, a compile-time switch) |
One-line rule of thumb: would you be willing to install this feature into another project — if yes, make it an extension.
2. The Six Extension Points
| What you want to do | Extension point | Who calls it |
|---|---|---|
| Create tables, add menus, fix historical data | IExtensionInitializer |
The framework's ExtensionInitializerHostedService awaits every implementation in turn at startup |
| Make pages (Razor components) routable | AddEasyAdminBlazorAssembly(assembly) |
UseEasyAdminBlazor<TApp>() adds these assemblies to AddAdditionalAssemblies |
| Hide menus automatically when the extension is unavailable | IExtensionInitializer.MenuPaths + IsAvailable |
MainLayout filters centrally, the extension writes no UI checks |
| Replace a default/empty framework implementation | RemoveAll<T>() + your own registration |
See the table below |
| Attach an entry point to the admin UI (e.g. the footer) | IAdminExtension |
Footer rendering |
| Contribute items to the admin "System Configuration" page | IConfigSettingsContributor |
Configuration page |
Services where the framework already left a slot (the default implementation is usable but does nothing — an extension takes over):
| Interface | Framework default | Official extension example |
|---|---|---|
ITenantService |
NullTenantService |
EasyAdminBlazor.MultiTenant |
ISchedulerService |
NullSchedulerService |
EasyAdminBlazor.Scheduler |
IPrintService |
Facade implementation inside the framework | EasyAdminBlazor.Print |
IApprovalGateway |
NullApprovalGateway |
EasyAdminBlazor.Approval |
IEmailService |
DefaultEmailService |
EasyAdminBlazor.Mail |
ISmsService |
DefaultSmsService |
EasyAdminBlazor.AliyunSms |
ICacheService |
MemoryCacheService |
EasyAdminBlazor.Redis / FusionCache |
IEditorComponent / IChatComponent |
Placeholder implementations | EasyAdminBlazor.HtmlEditor / Chat |
How to take over: the framework registers its defaults with TryAddXxx, and GetRequiredService<T>() returns the last registration, so a plain AddXxx<T, YourImplementation>() is enough. However, if the framework ever iterates IEnumerable<T>, both implementations would be present — the official extensions (Approval, MultiTenant, …) always call RemoveAll<T>() first, so copy that.
Beyond the table, two more things already have the groundwork laid:
- Provide Controllers: the framework already calls
AddControllers(), so an extension just adds a Controller (the host needsapp.MapControllers(), as the Demo'sProgram.csdoes); - Provide static assets: a Razor class library's
wwwrootis packaged as static web assets and referenced as_content/{PackageId}/..., exactly how the report designer and TinyMCE are shipped.
3. A Minimal Extension in 15 Minutes: Notice Board
The example below is a complete, compilable extension (entity + initializer + page + registration method + language pack). Copy it and rename. The sample was compiled against this repository's project settings with 0 warnings and 0 errors.
1) Create the project
dotnet new razorclasslib -n EasyAdminBlazor.Notice
EasyAdminBlazor.Notice.csproj (mirroring the official extensions: Razor SDK + framework reference + package metadata):
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>EasyAdminBlazor.Notice</PackageId>
<Version>1.0.0</Version>
<Authors>you</Authors>
<Description>EasyAdminBlazor notice extension</Description>
<GeneratePackageOnBuild Condition="'$(Configuration)' == 'Release'">true</GeneratePackageOnBuild>
</PropertyGroup>
<ItemGroup>
<!-- Reference the framework: keep the extension and framework versions in sync -->
<ProjectReference Include="..\..\EasyAdminBlazor\EasyAdminBlazor.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Optional: ship your own language pack -->
<Content Remove="Locales\*.json" />
<EmbeddedResource Include="Locales\*.json" />
</ItemGroup>
</Project>
2) Write the entity
using FreeSql.DataAnnotations;
using System.ComponentModel;
namespace EasyAdminBlazor.Notice;
/// <summary>Notice</summary>
[Table(Name = "notice")]
public partial class Notice : EntityFull
{
[DisplayName("标题")]
[Column(StringLength = 100)]
public string Title { get; set; } = string.Empty;
[DisplayName("内容")]
[Column(StringLength = -2)]
public string Content { get; set; } = string.Empty;
[DisplayName("是否启用")]
public bool IsEnabled { get; set; } = true;
}
Conventions (all existing framework habits): inherit EntityFull (soft delete + create/modify audit), the long primary key with snowflake IDs comes from the base class; use a lowercase, prefixed table name so it cannot collide with host tables; give strings a StringLength, and use -2 for long text.
3) Write the initializer: create table + add menu + declare the menu path
using FreeSql;
using Microsoft.Extensions.Logging;
namespace EasyAdminBlazor.Notice;
public class NoticeInitializer(
MainOrmHandle mainOrmHandle,
ILogger<NoticeInitializer> logger) : IExtensionInitializer
{
/// <summary>Menu path owned by this extension (used for both menu creation and visibility).</summary>
private const string MenuPath = "Admin/Notice";
public IReadOnlyCollection<string> MenuPaths => [MenuPath];
/// <summary>Return false when the extension is unavailable so the menu is hidden automatically.</summary>
public bool IsAvailable => true;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
try
{
var orm = mainOrmHandle.Orm;
// Hosts usually disable AutoSyncStructure in production, so extension tables sync themselves
orm.CodeFirst.SyncStructure<Notice>();
await EnsureMenuAsync(orm);
}
catch (Exception ex)
{
// Never rethrow: an exception here aborts application startup
logger.LogWarning(ex, "公告扩展初始化失败");
}
}
private static async Task EnsureMenuAsync(IFreeSql orm)
{
// Idempotent: select first, insert only when missing — this runs on every startup
var menu = await orm.Select<SysMenu>()
.Where(x => x.Type == SysMenuType.Menu && x.PathLower == MenuPath.ToLower())
.FirstAsync();
if (menu is null)
{
await orm.Insert(new SysMenu
{
Label = "公告管理",
Icon = "fa-bullhorn",
Path = MenuPath,
Sort = 10030,
Type = SysMenuType.Menu,
IsSystem = true
}).ExecuteAffrowsAsync();
menu = await orm.Select<SysMenu>()
.Where(x => x.Type == SysMenuType.Menu && x.PathLower == MenuPath.ToLower())
.FirstAsync();
}
if (menu is null) return;
// Button permissions: paths are add / edit / remove — AdminTable uses them to show/hide buttons
var buttons = new[]
{
(Path: "add", Label: "新增", Sort: 10031),
(Path: "edit", Label: "编辑", Sort: 10032),
(Path: "remove", Label: "删除", Sort: 10033)
};
foreach (var button in buttons)
{
var exists = await orm.Select<SysMenu>()
.Where(x => x.ParentId == menu.Id && x.Type == SysMenuType.Button && x.PathLower == button.Path)
.AnyAsync();
if (exists) continue;
await orm.Insert(new SysMenu
{
Label = button.Label,
Path = button.Path,
Sort = button.Sort,
Type = SysMenuType.Button,
IsSystem = true,
ParentId = menu.Id
}).ExecuteAffrowsAsync();
}
}
}
Three key points:
MenuPaths, the menuPathand the page@pagemust match — the framework usesMenuPathsto decide which menus to hide when an extension is unavailable;- Be idempotent (select before insert): initializers run on every startup;
- Catch exceptions yourself:
ExtensionInitializerHostedServiceis a plainforeach { await ... }, so a thrown exception aborts the whole application startup.
4) Write the page
@page "/Admin/Notice"
@inject IAggregateRootRepository<Notice> _repo
<AdminTable TItem="Notice" TKey="long" SortString="Id desc" IsPagination ShowSearch ShowExtendButtons IsMultipleSelect>
<TableColumns>
<TableColumn @bind-Field="context.Title" Filterable="true" Searchable="true" />
<TableColumn @bind-Field="context.IsEnabled" />
<TableColumn @bind-Field="context.CreatedUserName" />
<TableColumn @bind-Field="context.CreatedTime" Sortable="true" />
</TableColumns>
</AdminTable>
- The
@pageroute must equal the menuPath(Admin/Noticehere): page access is authorized by matching the current path againstSysMenu.PathLower(AdminContext.AuthPath). If the menu does not carry that path, a non-admin user lands on "无权限访问". IAggregateRootRepository<T>is registered as an open generic by the framework, so entities inside your extension work immediately — no repository registration needed.- Ship an
_Imports.razorwith your extension (copy the framework'sEasyAdminBlazor/_Imports.razorand add a@usingfor your entities) so pages can useAdminTable,SwalService,admin,CommonLocalizerdirectly. - Do not name the page file the same as the entity: if the entity is
Noticeand the page is alsoNotice.razor, the Razor component itself is namedNoticeandTItem="Notice"resolves to the page instead (compile errorCS0311). Either write the full nameEasyAdminBlazor.Notice.Noticeor, preferably, use a different file name. The framework's own pages avoid this by never matching their entity name:SysUser's page isUser.razor.
5) Write the registration method
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace EasyAdminBlazor;
public static class ServiceCollectionExtensions
{
/// <summary>Adds the EasyAdminBlazor notice extension.</summary>
public static WebApplicationBuilder AddEasyAdminBlazorNotice(this WebApplicationBuilder builder)
{
// 1) Startup initialization (create table, add menu)
builder.Services.AddSingleton<IExtensionInitializer, Notice.NoticeInitializer>();
// 2) Add the assembly to routing and component discovery (required for the page to open)
builder.AddEasyAdminBlazorAssembly(typeof(ServiceCollectionExtensions).Assembly);
return builder;
}
}
- Put the registration method in
namespace EasyAdminBlazor;(what most official extensions do) so the host needs no extrausing; - Never skip
AddEasyAdminBlazorAssembly: it adds the extension assembly to routing and component discovery — without it the page is a plain 404; - For a switch or settings, do it here:
builder.Services.Configure<NoticeOptions>(builder.Configuration.GetSection("Notice")), then exposeIsAvailable => options.Value.Enabledfrom the initializer so the menu follows the switch.
6) One line in the host
builder.AddEasyAdminBlazor(new EasyAdminBlazorOptions { ... })
.AddEasyAdminBlazorNotice();
7) Package it
dotnet pack -c Release
That produces EasyAdminBlazor.Notice.1.0.0.nupkg; in another project, dotnet add package plus the same one-line registration is all it takes.
4. Hard Rules for Menus and Permissions
| Rule | Notes |
|---|---|
| Path | Always Admin/Xxx. The page @page "/Admin/Xxx" and the menu Path = "Admin/Xxx" must match literally; matching is case-insensitive (the framework AOP fills in PathLower and trims leading/trailing /) |
| Type | SysMenuType.Menu (page menu), Button (button permission), Crud, ExternalLink (external link) |
| Button permissions | <AdminTable> checks the add / edit / remove button paths automatically (admin.AuthButton("add")), so adding three Type=Button child menus is all it takes for roles to be granted those buttons |
| Placement | Without ParentId a menu becomes a top-level item; to nest it, look up the parent menu Id and write it into ParentId |
| Who sees it | A new menu is visible only to administrators (super admins bypass permission checks) until other roles are granted it in "Role Management → Assign Menus" |
| Idempotency | Create menus with "insert only if missing", otherwise every startup adds a duplicate row |
Sort |
Framework built-ins use 1001 / 1002 (groups) and 10001+ (children and buttons); official extensions picked 10010 and 10020. Choose an unused value |
5. Pitfalls
- Initializers are singletons:
AddSingleton<IExtensionInitializer, XxxInitializer>()means the constructor cannot take Scoped services (AdminContext,IAggregateRootRepository<T>, …). UseMainOrmHandlefor the main ORM (the framework'sIFreeSqlis resolved from it as a singleton); if you really need a Scoped service, injectIServiceProviderand callCreateScope(). - Production does not auto-create tables: hosts usually write
UseAutoSyncStructure(env.IsDevelopment()), so extension tables must callorm.CodeFirst.SyncStructure<T>()explicitly — the Approval and Scheduler extensions both do. - Multi-tenancy: a tenant database is a separate FreeSql instance — use
ITenantService.GetTenantFreeSql(code), orAdminContext.Ormto get "the database this request should use" in one step. Extension tables are created in tenant databases byAutoSyncStructuretoo; if you need to pre-create tables or seed data per tenant, run the same logic against the tenant ORM. - Localization: use
CommonLocalizer["Chinese sentence"]for UI text. Language packs are embedded resourcesLocales/{culture}.jsonwith the root nodeEasyAdminBlazor.Common; a missing translation falls back to the Chinese key itself. Your extension can ship its own file like the Demo does (<EmbeddedResource Include="Locales\*.json" />). - Uninstalling an extension leaves menus behind: menu rows never disappear by themselves. Official extension paths are hidden by the framework's fallback list
ExtensionMenuCatalog.Paths; for third-party extensions you either delete the leftover menu rows manually or keep theIExtensionInitializerdeclaration soIsAvailable = falsehides them. - Do not hand-roll security: use
FileSecurityValidator.IsSafeRemoteUri()for remote fetches,FileSecurityValidatorfor upload validation andPBKDF2Encryptfor passwords. - The repository treats warnings as errors (
TreatWarningsAsErrors): a warning failsdotnet build, so keep nullable annotations clean.
6. Read the Official Extensions
| What you want to see | Where to look |
|---|---|
| Smallest extension (page + menu only) | Extensions/EasyAdminBlazor.CodeGenerator/ServiceCollectionExtensions.cs |
| Table + menu + button permissions | Extensions/EasyAdminBlazor.Scheduler/SchedulerInitializer.cs |
| Configuration items on the admin configuration page | Extensions/EasyAdminBlazor.Mail/SmtpConfigSettingsContributor.cs |
Hook into the admin footer (IAdminExtension) |
Extensions/EasyAdminBlazor.Upgrade/Infrastructure/UpgradeExtension.cs |
| Replace a framework service, provide a Controller | Extensions/EasyAdminBlazor.MultiTenant/ServiceCollectionExtensions.cs, Extensions/EasyAdminBlazor.Captcha/CaptchaController.cs |
| Static assets (js/css) | Extensions/EasyAdminBlazor.Print/wwwroot, Extensions/EasyAdminBlazor.HtmlEditor/wwwroot |
| Use the current tenant's database | Extensions/EasyAdminBlazor.MultiTenant/MultiTenantService.cs |
7. Checklist
- An
AddEasyAdminBlazorXxx()method exists, installable with one line in the host -
AddEasyAdminBlazorAssembly(...)is called (the page actually opens) - Menu
Path, page@pageandIExtensionInitializer.MenuPathsall agree - The initializer is idempotent, catches exceptions and never blocks startup
- New tables call
SyncStructureexplicitly (production does not auto-create them) -
IsAvailablereturnsfalsewhen disabled (the menu hides itself) - The page file name differs from the entity name (avoids resolving to the component)
-
dotnet buildreports zero warnings (the repository treats warnings as errors)
Related documentation: Extensions, Starting a New Module, Menu Management, Role Management, Localization, Multi-Tenant