Plugin Development Guide
Complete guide to creating, installing, and configuring plugins for BlazorBlueprint
Overview
BlazorBlueprint uses a modular plugin system that allows you to extend functionality without modifying core code. Plugins are self-contained, independently versioned components.
IPlugin Interface
All plugins must implement the IPlugin interface. For convenience, you can inherit from PluginBase which provides helper methods.
Interface Definition
The interface is deliberately slim: only Manifest and RegisterServicesAsync are required. Every other member is a default-implemented (optional) method, so the smallest possible plugin is just those two. All identity / metadata lives on the Manifest record (not as separate interface properties).
public interface IPlugin
{
// ========================================================================
// REQUIRED
// ========================================================================
// All identity + metadata (id, name, version, icon, capabilities, ...).
// This is the single place an author describes the plugin. REQUIRED.
PluginManifest Manifest { get; }
// Convenience shortcut for Manifest.Id (the host keys/routes plugins by this).
string Id => Manifest.Id;
// Convenience shortcut for Manifest.Slug — the id's last dot-segment, lower-cased
// (the host keys /plugin/{slug} URLs, the launcher, nav and gating by this).
string Slug => Manifest.Slug;
// Register the plugin's services into DI. Runs in EVERY host (Web,
// ApplicationService, BackgroundWorker). Repositories, application services,
// settings binding, BSON class maps, and DI-resolved contribution providers
// (IPluginSectionNavProvider, IChromeProvider, IPublicRouteAllowList) go here.
// Example: services.AddScoped<IMyService, MyService>();
// The one method every plugin must implement. REQUIRED.
Task RegisterServicesAsync(IServiceCollection services, IConfiguration configuration);
// ========================================================================
// OPTIONAL LIFECYCLE — all default-implemented; override only what you need
// ========================================================================
// Register recurring background jobs (IHostedService). Runs ONLY in the
// BackgroundWorker host, so AddHostedService here can't double-fire on Web/API.
Task RegisterBackgroundServicesAsync(IServiceCollection services, IConfiguration configuration)
=> Task.CompletedTask;
// Wire into the WEB request pipeline — middleware, SignalR hubs, endpoints.
// Web host only. (Routed pages come from each component's @page directive.)
// Example: app.MapHub<MyHub>("/hubs/myhub");
Task ConfigureWebAppAsync(WebApplication app) => Task.CompletedTask;
// One-off work right after the host starts, in every host. Keep it CHEAP —
// anything per-tenant or heavy belongs in SetupForOrganisationAsync.
Task OnStartupAsync(IServiceProvider services, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
// Seed the plugin's PLATFORM-WIDE defaults (e.g. its single platform ApiModel).
// Called at process boot AND after initial setup — must be idempotent.
Task SeedPlatformDefaultsAsync(IServiceProvider services, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
// Set the plugin up FOR ONE organisation — per-org seed data, role definitions,
// its settings document. Runs when a new org is provisioned AND when an admin
// installs the plugin into an existing org (hence "for", not "new").
Task<bool> SetupForOrganisationAsync(Organisation organisation, string? adminUserId = null, IServiceProvider? services = null)
=> Task.FromResult(true);
// ========================================================================
// OPTIONAL UI CONTRIBUTIONS — the plugin's own pages / widgets
// ========================================================================
// Cross-cutting contributions (section nav, chrome, public routes) are
// registered as DI providers in RegisterServicesAsync instead — there is no
// GetNavigationItems method any more.
// Pages in the org-admin area, under /admin/plugin/{slug}/… (admin nav Plugins group).
IEnumerable<PluginAdminPage> GetAdminPages() => [];
// Pages in the platform-admin area, under /platform/plugin/{slug}/….
IEnumerable<PluginAdminPage> GetPlatformPages() => [];
// Components rendered into MainLayout slots: Header (sticky-friendly, top of the
// page-content scroll), Footer (below the page body), or Floating (outside the
// layout — for position:fixed overlays like a floating action button).
IEnumerable<LayoutComponentRegistration> GetLayoutComponents(IServiceProvider? services = null) => [];
// Route metadata for gating + launcher tile role resolution. Actual page routing
// is driven by each component's @page directive — this is declaration only.
IEnumerable<PluginRoute> GetRoutes() => [];
// The concrete settings classes the plugin stores under its key in the
// OrganisationPlugins / PlatformPluginInstall dictionaries. Declaring the types here
// (instead of registering persistence class maps directly) keeps the plugin free of
// any database-driver reference; the active backend registers whatever it needs.
IEnumerable<Type> GetPolymorphicSettingsTypes() => [];
}All identity / presentation / declared-capability fields live on the PluginManifest record. Because it's a record with sensible defaults, new fields can be added over time without breaking any existing plugin:
public sealed record PluginManifest
{
public required string Id { get; init; } // "BlazorBlueprint.Plugins.MyPlugin" — keyed/routed by the last dot-segment
public required string Name { get; init; } // display name shown on tiles, nav, plugin management
public string Version { get; init; } = "1.0.0";
public string Description { get; init; } = "";
public string Author { get; init; } = "";
public string Category { get; init; } = "General"; // grouping label, e.g. "Marketing"
// FluentUI icon name (e.g. "Briefcase", "Star") for the launcher tile / nav heading.
// Unknown/blank falls back to a generic puzzle-piece glyph.
public string Icon { get; init; } = "PuzzlePiece";
public bool IsPremium { get; init; } // gated behind a subscription plan when billing is on
public PluginScope Scope { get; init; } = PluginScope.Tenant; // all orgs vs. platform org only
// Host capabilities this plugin uses — an HONEST DECLARATION for operator review,
// NOT an enforced sandbox (plugins are full-trust in-process code). List exactly
// what the plugin touches.
public IReadOnlyList<PluginCapability> Capabilities { get; init; } = [];
// Ids of other plugins this one needs loaded + enabled.
public IReadOnlyList<string> Dependencies { get; init; } = [];
}
public enum PluginCapability
{
TenantData, // reads/writes the current org's data via IRepository<T>
PlatformData, // reads the platform DB / other tenants via IPlatformRepository<T> — review carefully
BackgroundJobs, // registers recurring IHostedService jobs in the worker
Payments, // takes payments through the host IPaymentService
Sms, // sends SMS through the host ISmsService
InboundEmail, // receives inbound email (e.g. a Mailgun inbound-route handler)
OutboundWebhooks, // delivers outbound webhooks to third parties
Ai, // calls AI models through the host IApiRequestDispatcher pipeline
FileStorage, // stores/serves files through the host IFileStorage
PublicRoutes, // serves anonymous public routes (declared via IPublicRouteAllowList)
ChromeSuppression, // can suppress the platform header/nav (via IChromeProvider)
}💡 Tip: Instead of implementing IPlugin directly, inherit from PluginBase. It provides virtual hooks you can override plus route/page/settings helper methods that make plugin development much easier!
Creating Your First Plugin
Step 1: Create Plugin Project
# Create new Razor Class Library project
dotnet new razorclasslib -n BlazorBlueprint.Plugins.MyPlugin
cd BlazorBlueprint.Plugins.MyPlugin
# Add project references
dotnet add reference ..\..\Core\BlazorBlueprint.Application\BlazorBlueprint.Application.csproj
dotnet add reference ..\..\Core\BlazorBlueprint.Domain\BlazorBlueprint.Domain.csprojStep 2: Implement Plugin Class (Recommended: Use PluginBase)
💡 Tip: Inherit from PluginBase instead of implementing IPlugin directly. It provides helper methods and default implementations.
// Import necessary namespaces
using BlazorBlueprint.Application.Plugins; // PluginBase class
using BlazorBlueprint.Application.Interfaces; // IPlugin, PluginManifest, PluginCapability
using BlazorBlueprint.Domain.Constants; // ApplicationRoleConstants for authorization
using Microsoft.Extensions.DependencyInjection; // IServiceCollection for DI
using Microsoft.Extensions.Configuration; // IConfiguration for settings
namespace BlazorBlueprint.Plugins.MyPlugin;
/// <summary>
/// Main plugin class - inherits from PluginBase for convenience.
/// PluginBase provides virtual hooks plus route/page/settings helper methods.
/// Only Manifest and RegisterServicesAsync are required; everything else is optional.
/// </summary>
public class MyPlugin : PluginBase
{
// ========================================================================
// MANIFEST (REQUIRED) - all identity + metadata lives here
// ========================================================================
/// <summary>
/// Everything the plugin says about itself: id, display name, version, icon,
/// and the host capabilities it touches (declared for operator review).
/// </summary>
public override PluginManifest Manifest { get; } = new()
{
// Unique id. Convention: "BlazorBlueprint.Plugins.{PluginName}".
// The host keys/gates/routes the plugin by the LAST dot-segment
// ("myplugin" here → /plugin/myplugin). Keep it unique.
Id = "BlazorBlueprint.Plugins.MyPlugin",
// Display name shown on launcher tiles, nav, and plugin management.
Name = "My Plugin",
Version = "1.0.0",
Description = "A sample plugin that demonstrates basic functionality",
Author = "Your Name",
Category = "Utility", // grouping label in the UI
// FluentUI icon name for the launcher tile / section-nav heading.
// Unknown/blank falls back to a generic puzzle-piece glyph.
Icon = "Settings",
IsPremium = false, // true = gated behind a subscription plan when billing is on
// Honest declaration of what this plugin touches, for operator review
// (NOT an enforced sandbox). List exactly what you actually use.
Capabilities = [PluginCapability.TenantData],
};
// ========================================================================
// REGISTER SERVICES (REQUIRED METHOD)
// ========================================================================
/// <summary>
/// Registers services your plugin needs in dependency injection.
/// Runs in EVERY host (Web, ApplicationService, BackgroundWorker).
/// Services registered here can be injected into your pages and components.
/// </summary>
public override async Task RegisterServicesAsync(IServiceCollection services, IConfiguration configuration)
{
// Register your plugin's services here.
// AddScoped means a new instance is created for each HTTP request.
// Other options: AddSingleton (one instance for entire app), AddTransient (new instance every time)
services.AddScoped<IMyService, MyService>();
// Cross-cutting host contributions are registered as DI providers here too,
// so the host discovers them without referencing your plugin. The in-plugin
// section nav (the sidebar on /plugin/myplugin/*) is one — see below.
services.AddSingleton<IPluginSectionNavProvider, MyPluginSectionNavProvider>();
// You can also register:
// - Background services: override RegisterBackgroundServicesAsync (worker host only)
// - Options/configuration: services.Configure<MyOptions>(options => { ... });
// - Repositories, other services, etc.
await Task.CompletedTask; // Required for async method
}
// ========================================================================
// ROUTES (OPTIONAL METHOD) - metadata for gating + tile role resolution
// ========================================================================
/// <summary>
/// Declares the routes (pages) your plugin provides. This is METADATA only
/// (gating + launcher tile role resolution) — actual page routing is driven by
/// each component's @page directive.
/// </summary>
public override IEnumerable<PluginRoute> GetRoutes()
{
// 1. Main plugin landing page (the "app dashboard")
// This is where users go when they click the plugin tile in /apps.
// Plugin's main entry — renders under MainLayout with the host's
// PluginSectionNav sidebar (replaces the platform NavMenu while inside the
// plugin). Settings + admin-only pages live under /admin/plugin/{pluginname}/*.
yield return new PluginRoute
{
Route = "/plugin/myplugin", // Canonical dashboard URL
ComponentType = typeof(Web.Pages.Admin.Index), // The Razor component to render
RequiresAuthentication = true, // Must be logged in
RequiredRole = ApplicationRoleConstants.ORGANISATIONADMIN // Must have ORGANISATIONADMIN role
};
// 2. Public route - accessible to everyone
// Route: /plugin/myplugin/mypage
// CreatePublicRoute is a helper method from PluginBase.
// It automatically constructs the route: /plugin/{pluginname}/mypage
yield return CreatePublicRoute("mypage", typeof(Web.Pages.MyPage));
// 3. Admin route - settings page
// Only ORGANISATIONADMIN users can access this.
// Route: /admin/plugin/myplugin/settings — renders under AdminLayout
// because it has a "deeper" path segment after the plugin name.
// CreateAdminRoute is a helper that constructs: /admin/plugin/{pluginname}/settings
yield return CreateAdminRoute("settings", typeof(Web.Pages.Admin.Settings), ApplicationRoleConstants.ORGANISATIONADMIN);
}
// ========================================================================
// ADMIN PAGES (OPTIONAL METHOD)
// ========================================================================
/// <summary>
/// Defines admin pages that appear in the admin nav's Plugins group.
/// This method is optional - omit it if your plugin doesn't have admin pages.
/// </summary>
public override IEnumerable<PluginAdminPage> GetAdminPages()
{
// These admin pages appear in the admin nav's Plugins group.
// Direct URL: /admin/plugin/myplugin/{pagename} — renders under AdminLayout.
// Only list genuinely admin / configuration pages here — user-facing dashboard
// pages live at /plugin/myplugin/* and are reached via the host PluginSectionNav.
// Settings page - order 100 (appears first)
yield return CreateAdminPage(
title: "Settings", // Display name
pageName: "settings", // URL segment (becomes /admin/plugin/myplugin/settings)
icon: "Settings", // Icon name for display
order: 100 // Display order (lower = appears first)
);
}
}💡 Why PluginBase? It provides helper methods like CreatePublicRoute(), CreateAdminRoute(), and CreateAdminPage() that handle route conventions automatically, plus settings helpers and virtual hooks for every optional method. Much easier than manually constructing routes!
Section navigation (the sidebar on /plugin/{name}/*)
The left sidebar shown while a user is inside your plugin is contributed through a DI extension point — IPluginSectionNavProvider — not a method on IPlugin. You register one provider per plugin in RegisterServicesAsync (see the registration line above); the host discovers it by slug and renders it in place of the platform NavMenu, with no per-plugin knowledge of its own.
using BlazorBlueprint.Application.Interfaces;
namespace BlazorBlueprint.Plugins.MyPlugin;
/// <summary>
/// Supplies the rows for this plugin's in-plugin section nav. The host can't
/// reference the plugin assembly, so it discovers this provider via DI and matches
/// it by PluginSlug. This nav is static (no tenant data), so a cached list is fine;
/// a per-org nav can query inside GetItemsAsync instead.
/// </summary>
public sealed class MyPluginSectionNavProvider : IPluginSectionNavProvider
{
private static readonly IReadOnlyList<PluginSectionNavItem> Items = new List<PluginSectionNavItem>
{
// PluginSectionNavItem(Label, Href, Icon, MatchAll = false, RequiredRole = null, Section = null)
// Icons are FluentUI Size20 names. MatchAll = exact-match highlighting (use it
// on the dashboard row so it isn't lit on every nested route).
new("Dashboard", "/plugin/myplugin", "Home", MatchAll: true),
new("My page", "/plugin/myplugin/mypage", "DocumentText"),
};
// Plugin slug as it appears in /plugin/{slug}/* (lower-case).
public string PluginSlug => "myplugin";
// Heading shown above the section nav (null = host falls back to the slug).
public string? Title => "My Plugin";
// Runs when the sidebar renders for the matching slug — keep it cheap.
public Task<IReadOnlyList<PluginSectionNavItem>> GetItemsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Items);
}💡 No host edit needed: a new plugin's sidebar appears simply by registering its IPluginSectionNavProvider — the same extension-point pattern as IPublicRouteAllowList and IChromeProvider.
Step 3: Add to Solution
# Add plugin to solution
dotnet sln add BlazorBlueprint.Plugins.MyPluginStep 4: Reference in Web Project
Edit Web/BlazorBlueprint.Web/BlazorBlueprint.Web.csproj:
<ItemGroup>
<ProjectReference Include="..\..\Plugins\BlazorBlueprint.Plugins.MyPlugin\BlazorBlueprint.Plugins.MyPlugin.csproj" />
</ItemGroup>✅ That's it! Your plugin will be automatically discovered and loaded when you run the application.
Installation & Configuration
Method 1: Project Reference (Development)
For development, add the plugin as a project reference in Web/BlazorBlueprint.Web/BlazorBlueprint.Web.csproj:
<ItemGroup>
<ProjectReference Include="..\..\Plugins\MyPlugin\MyPlugin.csproj" />
</ItemGroup>Method 2: Folder-Based (Production)
For production or distribution, place plugin assemblies in the application's Plugins/ folder (next to the running Web app).
When running from source, the loader also attempts to find Web/BlazorBlueprint.Web/Plugins/.
Each plugin must live in its own sub-folder (the loader scans for Plugins/<PluginName>/*.dll, top-level DLLs only):
Web/BlazorBlueprint.Web/
└── Plugins/
└── MyPlugin/
├── MyPlugin.dll
├── MyPlugin.deps.json
└── [dependencies]Plugins are discovered at startup (from already-loaded assemblies and from the folder scan). Use "Rescan Plugins Folder" in Plugin Management to make newly added plugin assemblies show up as available for installation.
Note: rescan registers plugin types in the registry only — their services are not added to DI, and plugin routes/hubs/middleware are wired up only during app startup via
ConfigurePluginsAsync. A Razor page that injects a rescanned plugin's services will fail at construction until you restart. To activate a newly discovered plugin's services, routes, and hub endpoints, restart the app.
Per-plugin flags in appsettings.json
The host reads four independent flags per plugin from appsettings.json, each keyed under
Plugins:{full plugin id}. All default to false (absent), so simply dropping a plugin DLL into your app
does NOT silently auto-install it, restrict it to the platform org, or gate it behind a subscription. The last two override the
plugin's own manifest (Scope and IsPremium).
"Plugins": {
"BlazorBlueprint.Plugins.MyPlugin": {
"AutoInstall": true, // run SetupForOrganisationAsync for every new org created via
// initial-setup or /Org/Select. false = the plugin is loaded
// and visible in /Admin/PluginManagement, but org admins must
// click Install manually per org.
"Disabled": false, // emergency kill switch. true = RegisterServicesAsync is
// skipped, ConfigureWebAppAsync never runs, services are not in
// DI. Existing orgs' OrganisationPlugins blob is preserved
// but functionally inert. Disable a misbehaving plugin via
// config + restart, no redeploy required.
"PlatformOnly": false, // true = surfaces only on the platform organisation: hidden from
// every tenant org's plugin management, launcher and nav, never
// auto-installed into a tenant, public routes 404 on tenant hosts.
// Overrides the manifest's Scope.
"RequiresSubscription": false // true = premium app, gated behind a subscription plan (only
// enforced when platform billing is on). Overrides the manifest's
// IsPremium; an org needs an entitled plan to open it.
}
}| Configuration | Auto-install on new orgs | Visible in PluginManagement | Manual install button |
|---|---|---|---|
| DLL present, no appsettings entry | No | Yes | Yes |
"AutoInstall": true |
Yes | Yes | Yes (for orgs that pre-existed) |
"Disabled": true |
No | No | No |
Per-organisation install (PluginManagement admin)
- Navigate to Plugin Management
- Plugins listed under "Available" have not yet been installed for the current organisation. Plugins under "Installed" are already active.
- Click Install on an Available plugin to run its
SetupForOrganisationAsyncfor the current org — the plugin's per-org settings, role definitions, and any seeded entities are created. - Plugins flagged
"AutoInstall": trueappear under "Installed" automatically for orgs created after the flag was set; existing pre-flag orgs still need a manual Install click here. - The Disable button on an installed plugin removes the per-org entry from
OrganisationPluginsbut keeps the plugin globally loaded. Use the appsettings"Disabled": trueflag for a global kill switch.
Best Practices
1. Plugin Isolation
- Don't modify core code - plugins should be self-contained
- Use interfaces for communication with core services
- Store all settings in
Organisation.InstalledPlugins(keyed by plugin Id)
2. Error Handling
- Wrap all plugin operations in try-catch blocks
- Don't let plugin errors crash the application
- Log errors with appropriate log levels
3. Route Conventions
- User-side pages:
/plugin/{pluginname}for the main entry and/plugin/{pluginname}/{route}for every daily-use page. Render underMainLayoutwith the host'sPluginSectionNavsidebar in place of the platformNavMenu. Anonymous-accessible plugin surfaces (e.g./plugin/myplugin/publicpage/{id}) live in the same namespace. - Admin / settings pages:
/admin/plugin/{pluginname}/{route}— settings, setup wizards (when they fit the admin model), and configuration pages. Render underAdminLayoutand are reached from the admin nav. - Cross-section rule: pages under
/plugin/{name}/*must not link to/admin/plugin/{name}/*. Reference admin pages by name in copy ("Admin → Plugins → My Plugin → Plugin settings") instead of an<a href>so the section split stays clean. - Use kebab-case for route names.
4. Service Lifetime
- Use
Scopedfor request-specific services - Use
Singletonfor shared state or cache - Create scopes in background services using
IServiceProvider
5. Data Access Conventions (Org vs Platform)
- Organisation-scoped data: use the normal tenant-scoped repository pattern (e.g.
IRepository<T>) so reads/writes land in the current tenant database. - Switch tenant context in background jobs: when a background service needs to iterate across organisations, it should:
1)fetch organisation metadata from the platform database (e.g.IPlatformRepository<Organisation>),2)setITenantContextSetterto the target organisation, then3)run tenant-scoped queries usingIRepository<T>. - Plugin settings storage: per-site settings are stored in
Organisation.InstalledPlugins, keyed by plugin Id. Presence of a key means the plugin is both installed and enabled for the org.
Putting It All Together
Everything a Plugin Can Do
A full-featured plugin combines the extension points above into a single self-contained app. Across its Manifest and lifecycle hooks, one plugin can provide:
- ✅ In-plugin section navigation via
IPluginSectionNavProvider - ✅ Admin pages with different authorization levels
- ✅ Public and authenticated routes
- ✅ SignalR hubs with real-time communication (wired in
ConfigureWebAppAsync) - ✅ Plugin settings (per-organisation configuration)
- ✅ Background services (
RegisterBackgroundServicesAsync, worker host only) - ✅ Service registration and dependency injection
- ✅ Per-organisation setup and initialization
- ✅ Layout components (header, footer, or a floating overlay)
💡 Start small: only Manifest and RegisterServicesAsync are required — add each of the above only when your plugin actually needs it.
The Walkthrough Above
The Creating Your First Plugin section builds a complete MyPlugin example from scratch with full code. It covers:
- ✅ The
MyPluginplugin class and manifest from scratch - ✅ Service interface and implementation examples
- ✅ Public and admin page routes with full code
- ✅ Section navigation integration
- ✅ Settings helpers via
PluginBase - ✅ Solution and Web-project reference setup
Ready to Download?
Build custom plugins and extend the Blazor Blueprint starter template. Download the full product free — use it, including commercially, while you're under £100k/year revenue; a commercial licence (from £499) applies above that.
🔓 Source-available on GitHub • Free under £100k/yr revenue • Commercial licence (from £499) above that • Full source code included