Blazor Frontend Architecture
PearDrop frontend uses Blazor WebAssembly with a code-behind pattern, ITCSS styling, CSS Isolation, and TypeScript for client-side logic. This document covers the actual patterns defined in the minimal template.
Quick Reference
- Component Code: Separate
.razor.cscode-behind files (not@codeblocks) - Styling: ITCSS 7-layer architecture + Blazor CSS Isolation (
.razor.cssfiles) - Asset Bundling: Parcel with SCSS compilation and Pug templating
- TypeScript: Organized in 5-layer module structure
- Dependencies: Constructor injection (preferred) not
[Inject]attributes - Radzen Side Dialogs: See Radzen Helpers and Side Dialog Layouts
Project Structure
The client project splits into logical folders:
App.Client/
├── Components/
│ ├── _Imports.razor # Global @using statements
│ ├── App.razor # Root component
│ ├── Routes.razor # Router configuration
│ │
│ ├── Layout/
│ │ └── MainLayout.razor # Primary layout + MainLayout.razor.cs
│ │
│ ├── UserControls/ # Reusable components (2+ pages)
│ │ ├── LoadingSpinner.razor
│ │ ├── LoadingSpinner.razor.cs
│ │ ├── LoadingSpinner.razor.css
│ │ ├── ConfirmDialog.razor
│ │ ├── ConfirmDialog.razor.cs
│ │ ├── ConfirmDialog.razor.css
│ │ ├── PaginationControls.razor
│ │ └── ... other shared components
│ │
│ ├── Pages/ # Routed page components
│ │ ├── Home.razor
│ │ ├── Home.razor.cs
│ │ ├── Home.razor.css
│ │ │
│ │ ├── Equipment/ # Feature folder (organizational)
│ │ │ ├── EquipmentList.razor
│ │ │ ├── EquipmentList.razor.cs
│ │ │ ├── EquipmentList.razor.css
│ │ │ ├── EquipmentDetail.razor
│ │ │ ├── EquipmentDetail.razor.cs
│ │ │ ├── EquipmentDetail.razor.css
│ │ │ ├── EquipmentDialog.razor # Page-specific dialog
│ │ │ └── EquipmentDialog.razor.cs
│ │ │
│ │ ├── Dashboard/
│ │ │ ├── Dashboard.razor
│ │ │ ├── Dashboard.razor.cs
│ │ │ ├── Dashboard.razor.css
│ │ │ ├── DashboardCard.razor # Component within Dashboard page folder
│ │ │ └── DashboardCard.razor.cs
│ │ │
│ │ └── Admin/
│ │ ├── AdminPanel.razor
│ │ ├── AdminPanel.razor.cs
│ │ └── AdminPanel.razor.css
│ │
│ └── Styles/ # Global SCSS (ITCSS architecture)
│ ├── main.scss # Entry point (imports all)
│ ├── 1-Settings/ # Variables, functions, mixins
│ │ ├── _colors.scss
│ │ ├── _typography.scss
│ │ ├── _spacing.scss
│ │ └── _mixins.scss
│ ├── 2-Tools/ # Utility functions
│ │ ├── _flex-grid.scss
│ │ └── _transitions.scss
│ ├── 3-Generic/ # Resets, normalize, base
│ │ ├── _normalize.scss
│ │ └── _base-html.scss
│ ├── 4-Elements/ # HTML element defaults
│ │ ├── _headings.scss
│ │ ├── _buttons.scss
│ │ └── _forms.scss
│ ├── 5-Objects/ # Layout patterns
│ │ ├── _container.scss
│ │ └── _grid.scss
│ ├── 6-Components/ # Design components
│ │ ├── _alert.scss
│ │ ├── _card.scss
│ │ ├── _badge.scss
│ │ └── _navbar.scss
│ ├── 7-Utilities/ # Single-purpose utilities
│ │ ├── _margin.scss
│ │ ├── _padding.scss
│ │ └── _text.scss
│ └── main.css # Compiled output
│
├── Scripts/ # TypeScript source files
│ ├── tsconfig.json
│ ├── 1-Utils/ # Helper functions
│ │ ├── date-helpers.ts
│ │ └── string-helpers.ts
│ ├── 2-Services/ # API, auth, state services
│ │ ├── api-client.ts
│ │ └── auth-service.ts
│ ├── 3-Features/ # Domain-specific logic
│ │ └── equipment/
│ │ └── equipment-service.ts
│ ├── 4-Components/ # Blazor interop
│ │ └── dialog-manager.ts
│ ├── 5-ThirdParty/ # Third-party wrappers
│ │ └── chart-wrapper.ts
│ └── main.ts # Entry point
│
├── Assets/ # Frontend build source
│ ├── index.pug # HTML template (Pug)
│ ├── styles/
│ │ └── app.scss # Additional app-specific styles
│ ├── images/
│ ├── static/ # Files copied directly
│ └── scripts/
│ └── app.ts # Client app initialization
│
├── wwwroot/ # Build output (served by server)
│ ├── app.{hash}.js
│ ├── Assets.{hash}.css
│ ├── index.html
│ └── favicon.png
│
├── Program.cs # Blazor WebAssembly configuration
├── Infrastructure/ # Client-side services, utilities
├── package.json # npm dependencies
└── .parcelrc # Parcel bundler config
Organizational Rules
UserControls folder:
- For components used on 2+ pages or multiple features
- Examples: LoadingSpinner, ConfirmDialog, Pagination, Badges
- Generic, self-contained, configurable via
[Parameter] - No page-specific business logic
Pages folder:
- One subfolder per feature/domain (Equipment, Dashboard, Account)
- Route matches folder:
/equipment→Pages/Equipment/ - Page-specific dialogs/components stay in the same folder as parent
- Not a separate
Dialogs/subfolder structure
Code-Behind Pattern
All component code goes in separate .razor.cs files as partial class, never in @code blocks.
Page Component Example
Pages/Equipment/EquipmentList.razor (markup only):
@page "/equipment"
@implements IAsyncDisposable
<div class="container py-4">
<div class="row mb-4">
<div class="col">
<h1>Equipment</h1>
</div>
<div class="col-auto">
@if (IsLoadingList)
{
<LoadingSpinner />
}
else
{
<button class="btn btn-primary" @onclick="OnOpenAddDialog">
<i class="bi bi-plus"></i> Add Equipment
</button>
}
</div>
</div>
@if (!IsLoadingList && EquipmentList?.Any() == true)
{
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Available</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in EquipmentList)
{
<tr>
<td>@item.Name</td>
<td>@item.Category</td>
<td>
<span class="badge @(item.IsAvailable ? "bg-success" : "bg-danger")">
@(item.IsAvailable ? "Available" : "In Use")
</span>
</td>
<td>
<button class="btn btn-sm btn-info" @onclick="() => OnEditAsync(item.Id)">Edit</button>
<button class="btn btn-sm btn-danger" @onclick="() => OnDeleteAsync(item.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
}
else if (!IsLoadingList)
{
<div class="alert alert-info">No equipment found.</div>
}
</div>
@if (ShowAddDialog)
{
<EquipmentDialog @bind-IsOpen="ShowAddDialog" ItemId="EditingItemId" OnSaved="OnItemSaved" />
}
Pages/Equipment/EquipmentList.razor.cs (code-behind):
using Microsoft.AspNetCore.Components;
using MyApp.Infrastructure.Queries;
using MyApp.Infrastructure.Commands;
using BluQube;
namespace MyApp.Components.Pages.Equipment;
/// <summary>
/// Equipment list page - displays all equipment with add/edit/delete actions
/// Code is split by feature:
/// - EquipmentList.razor.cs (main, list management)
/// - EquipmentList.Add.razor.cs (add feature)
/// - EquipmentList.Edit.razor.cs (edit feature)
/// - EquipmentList.Delete.razor.cs (delete feature)
/// </summary>
public partial class EquipmentList(
IQueryRunner queryRunner,
ICommandRunner commandRunner,
ILogger<EquipmentList> logger) : ComponentBase, IAsyncDisposable
{
// ========== List State ==========
protected List<EquipmentDto> EquipmentList { get; set; } = new();
protected bool IsLoadingList { get; set; }
protected string ListErrorMessage { get; set; } = string.Empty;
// ========== Dialog State ==========
protected bool ShowAddDialog { get; set; }
protected Guid? EditingItemId { get; set; }
protected override async Task OnInitializedAsync()
{
await LoadEquipmentAsync();
}
/// <summary>
/// Loads all equipment from query
/// </summary>
private async Task LoadEquipmentAsync()
{
IsLoadingList = true;
ListErrorMessage = string.Empty;
try
{
var result = await queryRunner.ExecuteAsync(new GetEquipmentListQuery());
EquipmentList = result.GetValueOrDefault() ?? new();
}
catch (Exception ex)
{
ListErrorMessage = $"Failed to load equipment: {ex.Message}";
logger.LogError(ex, "Failed to load equipment");
}
finally
{
IsLoadingList = false;
}
}
protected void OnOpenAddDialog()
{
EditingItemId = null;
ShowAddDialog = true;
}
protected async Task OnItemSaved(Guid savedItemId)
{
ShowAddDialog = false;
await LoadEquipmentAsync();
}
async ValueTask IAsyncDisposable.DisposeAsync()
{
// Cleanup resources
}
}
Feature Partials
For complex pages, split by feature using partial class:
Pages/Equipment/EquipmentList.Add.razor.cs (add feature):
namespace MyApp.Components.Pages.Equipment;
/// <summary>
/// Add equipment feature - handles adding new equipment
/// Includes: dialog state, form data, validation, handlers
/// </summary>
public partial class EquipmentList
{
// ========== Add State ==========
protected AddEquipmentFormData AddFormData { get; set; } = new();
protected Dictionary<string, string> AddFormErrors { get; set; } = new();
protected bool IsAddingEquipment { get; set; }
protected string AddErrorMessage { get; set; } = string.Empty;
/// <summary>
/// Handles adding new equipment
/// </summary>
protected async Task OnAddEquipmentAsync(AddEquipmentFormData formData)
{
IsAddingEquipment = true;
AddErrorMessage = string.Empty;
try
{
var command = new CreateEquipmentCommand(
formData.Name,
formData.Category);
var result = await commandRunner.ExecuteAsync(command);
if (result.IsSuccess)
{
await OnItemSaved(result.Value);
}
else
{
AddErrorMessage = "Failed to add equipment";
}
}
catch (Exception ex)
{
AddErrorMessage = ex.Message;
logger.LogError(ex, "Error adding equipment");
}
finally
{
IsAddingEquipment = false;
}
}
}
Constructor Injection (Preferred)
Always use constructor injection, not [Inject] attributes:
// ✅ GOOD: Constructor injection
public partial class EquipmentList(
IQueryRunner queryRunner,
ICommandRunner commandRunner,
ILogger<EquipmentList> logger) : ComponentBase
{
// Dependencies available immediately
protected override async Task OnInitializedAsync()
{
// Ready to use
}
}
// ❌ AVOID: Property injection
public partial class EquipmentList : ComponentBase
{
[Inject]
public IQueryRunner QueryRunner { get; set; } = null!;
}
Benefits:
- Dependencies visible in constructor
- Easier to test (mock in constructor)
- Compile-time safety
- Standard .NET DI pattern
Styling System
ITCSS Architecture (7 Layers)
Global styles use Inverted Triangle CSS (ITCSS) - from generic to specific:
| Layer | Purpose | Specificity | Example |
|---|---|---|---|
| 1. Settings | Variables, functions, no output | None | _colors.scss, _typography.scss |
| 2. Tools | Utility mixins, functions | None | _mixins.scss, _flex-grid.scss |
| 3. Generic | Normalize, resets, base | Low | _normalize.scss, _base-html.scss |
| 4. Elements | HTML elements (no classes) | Low | _headings.scss, _buttons.scss |
| 5. Objects | Layout patterns, no cosmetics | Low-Medium | _container.scss, _grid.scss |
| 6. Components | Design patterns with cosmetics | Medium-High | _card.scss, _navbar.scss, _alert.scss |
| 7. Utilities | Single-responsibility helpers | High | _margin.scss, _padding.scss, _text.scss |
Main Entry Point: Components/Styles/main.scss
// 1. SETTINGS
@import '1-Settings/colors';
@import '1-Settings/typography';
@import '1-Settings/spacing';
@import '1-Settings/breakpoints';
@import '1-Settings/mixins';
// 2. TOOLS
@import '2-Tools/flex-grid';
@import '2-Tools/transitions';
// 3. GENERIC
@import '3-Generic/normalize';
@import '3-Generic/base-html';
@import '3-Generic/base-body';
// 4. ELEMENTS
@import '4-Elements/headings';
@import '4-Elements/paragraphs';
@import '4-Elements/links';
@import '4-Elements/buttons';
@import '4-Elements/forms';
@import '4-Elements/tables';
// 5. OBJECTS
@import '5-Objects/container';
@import '5-Objects/grid';
@import '5-Objects/layout';
// 6. COMPONENTS
@import '6-Components/alert';
@import '6-Components/badge';
@import '6-Components/card';
@import '6-Components/modal';
@import '6-Components/navbar';
// 7. UTILITIES
@import '7-Utilities/margin';
@import '7-Utilities/padding';
@import '7-Utilities/display';
@import '7-Utilities/text';
@import '7-Utilities/responsive';
Example: Settings Layer
1-Settings/_colors.scss:
// Color palette - no CSS output
$color-primary: #007bff;
$color-success: #28a745;
$color-danger: #dc3545;
$color-white: #ffffff;
$color-gray-50: #f9fafb;
$color-gray-900: #111827;
// Semantic variables
$color-bg-primary: $color-white;
$color-text-primary: $color-gray-900;
$color-border: #e5e7eb;
2-Tools/_mixins.scss:
@mixin respond-to($breakpoint) {
@if $breakpoint == 'md' {
@media (min-width: 768px) { @content; }
}
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin transition($properties: all, $duration: 0.3s) {
transition: $properties $duration ease-in-out;
}
6-Components/_card.scss:
.card {
background-color: $color-bg-primary;
border: 1px solid $color-border;
border-radius: 4px;
padding: $spacing-md;
@include transition;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.card-header {
border-bottom: 1px solid $color-border;
margin-bottom: $spacing-md;
padding-bottom: $spacing-md;
}
.card-body {
padding: $spacing-md 0;
}
CSS Isolation (Component Scoping)
Each component has an optional .razor.css file with scoped styles (only apply to that component):
Pages/Equipment/EquipmentList.razor.css:
/* Scoped to EquipmentList component only */
.equipment-list {
padding: var(--spacing-md);
}
.equipment-list h1 {
color: var(--color-primary);
margin-bottom: var(--spacing-lg);
}
.equipment-table {
width: 100%;
border-collapse: collapse;
}
.equipment-table th {
background-color: var(--color-gray-100);
padding: var(--spacing-sm);
text-align: left;
}
.equipment-table td {
padding: var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
}
Blazor automatically scopes these styles - they won't leak to other components.
Component Parameters
Reusable components accept parameters:
UserControls/ConfirmDialog.razor:
@if (IsOpen)
{
<div class="modal show d-block">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" @onclick="OnCancel"></button>
</div>
<div class="modal-body">
@Message
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="OnCancel">
Cancel
</button>
<button type="button" class="btn btn-danger" @onclick="OnConfirm">
@ConfirmText
</button>
</div>
</div>
</div>
</div>
}
UserControls/ConfirmDialog.razor.cs:
namespace MyApp.Components.UserControls;
public partial class ConfirmDialog : ComponentBase
{
[Parameter]
public bool IsOpen { get; set; }
[Parameter]
public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter]
public string Title { get; set; } = "Confirm";
[Parameter]
public string Message { get; set; } = string.Empty;
[Parameter]
public string ConfirmText { get; set; } = "Confirm";
[Parameter]
public EventCallback OnConfirm { get; set; }
protected async Task OnCancel()
{
await IsOpenChanged.InvokeAsync(false);
}
protected async Task OnConfirm()
{
await OnConfirm.InvokeAsync();
await IsOpenChanged.InvokeAsync(false);
}
}
Usage:
@if (ShowConfirm)
{
<ConfirmDialog
@bind-IsOpen="ShowConfirm"
Title="Delete Equipment?"
Message="Are you sure you want to delete this equipment?"
ConfirmText="Delete"
OnConfirm="HandleConfirmDelete" />
}
TypeScript Organization
Client-side logic organized in 5 layers:
Scripts/
├── 1-Utils/ # Helper functions
│ ├── date-helpers.ts
│ └── string-helpers.ts
├── 2-Services/ # API, auth, storage services
│ ├── api-client.ts
│ └── auth-service.ts
├── 3-Features/ # Domain-specific business logic
│ └── equipment/
│ └── equipment-service.ts
├── 4-Components/ # Blazor interop utilities
│ └── dialog-manager.ts
├── 5-ThirdParty/ # Third-party library wrappers
│ └── chart-wrapper.ts
└── main.ts # Entry point
main.ts initializes app and registers modules:
// Initialize client-side services
import { initializeAPI } from '@services/api-client';
import { initializeAuth } from '@services/auth-service';
import { initializeEquipmentService } from '@features/equipment/equipment-service';
async function initializeApp() {
await initializeAuth();
initializeAPI();
initializeEquipmentService();
console.log('PearDrop Client initialized');
}
initializeApp();
Asset Bundling (Parcel)
Parcel handles compilation and asset optimization:
package.json:
{
"source": "Assets/index.pug",
"targets": {
"default": {
"distDir": "./wwwroot",
"publicUrl": "/"
}
},
"scripts": {
"build": "parcel build",
"watch": "parcel watch",
"dev": "parcel serve"
}
}
Build Process:
- Compiles
Assets/index.pug→wwwroot/index.html - Compiles SCSS in
Components/Styles/→wwwroot/Assets.{hash}.css - Bundles JavaScript →
wwwroot/app.{hash}.js - Copies
Assets/static/→wwwroot/ - Hash-busts assets for cache invalidation
Loading Spinner
While Blazor initializes, shows loading overlay (defined in Assets/index.pug):
<div id="app-loading" class="blazor-loading">
<div class="blazor-spinner"></div>
<div class="blazor-loading-text">Loading...</div>
</div>
<style>
.blazor-loading {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9fafb;
z-index: 9999;
}
.blazor-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(59, 130, 246, 0.2);
border-top-color: #3b82f6;
border-radius: 50%;
animation: blazor-spin 0.8s linear infinite;
}
@keyframes blazor-spin {
to { transform: rotate(360deg); }
}
</style>
Hidden by JavaScript when Blazor runtime loads.
Development Workflow
Local Development
-
Install dependencies:
dotnet restore
npm install -
Start dev server:
dotnet run --project App.Client -
Browser: Open
https://localhost:5001
Changes & Hot Reload
.razorfiles: Blazor hot reload automatically.scssfiles: Parcel recompiles, browser refreshes.razor.csfiles: Require manual restart- TypeScript: Parcel recompiles on save
Best Practices
✅ DO:
- Keep components focused - one responsibility
- Use constructor injection
- Put code in
.razor.csnot@code - Use CSS Isolation for component-specific styles
- Organize global styles by ITCSS layer
- Split complex components into feature partials
- Use reusable UserControls generously
❌ DON'T:
- Mix markup and C# logic in
.razorfiles - Use
[Inject]attributes - Put application logic in components
- Use inline styles in markup
- Create per-page layout files (unless necessary)
- Mix CSS-in-JS with SCSS
Next Steps
- PearDrop CLI - Generate pages and components
- CQRS Pattern - Connect components to domain logic
- Template Docs - See
docs/folder in minimal template for detailed guides