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 needs app.MapControllers(), as the Demo's Program.cs does);
  • Provide static assets: a Razor class library's wwwroot is 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:

  1. MenuPaths, the menu Path and the page @page must match — the framework uses MenuPaths to decide which menus to hide when an extension is unavailable;
  2. Be idempotent (select before insert): initializers run on every startup;
  3. Catch exceptions yourself: ExtensionInitializerHostedService is a plain foreach { 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 @page route must equal the menu Path (Admin/Notice here): page access is authorized by matching the current path against SysMenu.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.razor with your extension (copy the framework's EasyAdminBlazor/_Imports.razor and add a @using for your entities) so pages can use AdminTable, SwalService, admin, CommonLocalizer directly.
  • Do not name the page file the same as the entity: if the entity is Notice and the page is also Notice.razor, the Razor component itself is named Notice and TItem="Notice" resolves to the page instead (compile error CS0311). Either write the full name EasyAdminBlazor.Notice.Notice or, preferably, use a different file name. The framework's own pages avoid this by never matching their entity name: SysUser's page is User.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 extra using;
  • 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 expose IsAvailable => options.Value.Enabled from 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>, …). Use MainOrmHandle for the main ORM (the framework's IFreeSql is resolved from it as a singleton); if you really need a Scoped service, inject IServiceProvider and call CreateScope().
  • Production does not auto-create tables: hosts usually write UseAutoSyncStructure(env.IsDevelopment()), so extension tables must call orm.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), or AdminContext.Orm to get "the database this request should use" in one step. Extension tables are created in tenant databases by AutoSyncStructure too; 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 resources Locales/{culture}.json with the root node EasyAdminBlazor.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 the IExtensionInitializer declaration so IsAvailable = false hides them.
  • Do not hand-roll security: use FileSecurityValidator.IsSafeRemoteUri() for remote fetches, FileSecurityValidator for upload validation and PBKDF2Encrypt for passwords.
  • The repository treats warnings as errors (TreatWarningsAsErrors): a warning fails dotnet 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 @page and IExtensionInitializer.MenuPaths all agree
  • The initializer is idempotent, catches exceptions and never blocks startup
  • New tables call SyncStructure explicitly (production does not auto-create them)
  • IsAvailable returns false when disabled (the menu hides itself)
  • The page file name differs from the entity name (avoids resolving to the component)
  • dotnet build reports zero warnings (the repository treats warnings as errors)

Related documentation: Extensions, Starting a New Module, Menu Management, Role Management, Localization, Multi-Tenant