Skip to content

The BrouterRoutes generator

Bit.Brouter.Generators is a Roslyn source generator that scans your .razor files and emits BrouterRoutes - one compile-time-safe URL builder per route. Rename a parameter or change a constraint and every stale caller becomes a build error instead of a broken link.

What it scans

The generator reads route declarations textually from every .razor file: @page directives, @attribute [Route(...)], and literal <Broute Path="..."> tags - including nested trees, where a child's template is composed onto its ancestors' prefixes. A dynamic path (Path="@expr") can't be known at compile time, so that tag and everything beneath it is skipped.

# Add the router and the generator to your Blazor project:
dotnet add package Bit.Brouter
dotnet add package Bit.Brouter.Generators

<!-- ...or declare them in your .csproj. Bit.Brouter.Generators is an analyzer-only
     package (it ships no runtime assembly), so PrivateAssets="all" keeps it out of
     your own package's dependencies: -->
<PackageReference Include="Bit.Brouter" Version="10.6.0-pre-03" />
<PackageReference Include="Bit.Brouter.Generators" Version="10.6.0-pre-03" PrivateAssets="all" />

Nothing else is wired up by hand: the package drops the generator into the compiler, and the very next build emits BrouterRoutes from the routes already declared in your project. No tool to run, no file to check in.

The output is a single static partial class BrouterRoutes in your project's root namespace (BrouterRoutes.g.cs). Duplicate templates across files are de-duplicated; declaring the same template with two different Names raises the BRT001 warning instead of silently picking one.

From template to typed method

Route parameters become method parameters typed by the template's last type constraint ({init:int}int init; validation-only constraints like min(1) keep the raw string). Optionals become optional arguments, {action=Index} emits its declared default when omitted, and a catch-all becomes an optional path string. Every method also takes an optional query.

@* The declaration in AppRouter.razor... *@
<Broute Name="counter" Path="/counter/{init:int}">

// ...becomes this method in BrouterRoutes.g.cs:
public static string Counter(int init, string? query = null) { ... }

// Callers get IntelliSense, type checking, and refactoring safety:
<BrouterLink Href="@BrouterRoutes.Counter(1234)">Counter</BrouterLink>
Declared templateGenerated signature
/counter/{init:int}Counter(int init, string? query = null)
/profile/{username?}ProfileByUsername(string? username = null, string? query = null)
/blog/{action=Index}BlogByAction(string? action = null, string? query = null)
/posts/{**slug}Posts(string? slug = null, string? query = null)

Live output

These URLs are produced right now by the generated methods - note the emitted default in /blog/Index, the per-segment escaping of the catch-all, and the query appended with or without a leading ?.

ExpressionResult
BrouterRoutes.Counter(42) /counter/42 open →
BrouterRoutes.Counter(42, query: "step=5") /counter/42?step=5 open →
BrouterRoutes.ProfileByUsername("saleh") /profile/saleh open →
BrouterRoutes.ProfileByUsername() /profile open →
BrouterRoutes.BlogByAction() /blog/Index open →
BrouterRoutes.BlogByAction("archive") /blog/archive open →
BrouterRoutes.Posts("2026/07/hello world") /posts/2026/07/hello%20world open →

Method naming & the Names class

An explicit Name="..." owns its method name (Name="counter"Counter(...), even if another template would derive the same identifier - the other one gets a numeric suffix). Unnamed routes derive a name from their literal segments plus a By{Param} suffix per parameter: /profile/{username?}ProfileByUsername. The bare root template becomes Root().

Named routes additionally get a constant in BrouterRoutes.Names, so name-based APIs need no magic strings:

// Resolve or navigate by route name, without hardcoding "counter" anywhere:
brouter.ResolveUrl(BrouterRoutes.Names.Counter, new Dictionary<string, object?> { ["init"] = 7 });
brouter.NavigateToName(BrouterRoutes.Names.Counter, new Dictionary<string, object?> { ["init"] = 777 });
resolved: /counter/7

Fidelity & edge cases

  • Values are formatted with the router's own invariant rules (booltrue/false, DateTime → round-trip "o") and escaped with Uri.EscapeDataString, so a generated URL always round-trips through its template - byte-identical to what ResolveUrl produces.
  • Catch-all values are split on / and escaped per segment: Posts("2026/07/hello world")/posts/2026/07/hello%20world.
  • A middle optional (/products/{culture?}/list) is required at match time, so the generated method requires that argument too - runtime parity over template appearance.
  • Complex segments (/files/{name}.{ext?}) and dynamic Path="@expr" subtrees are skipped: no builder is generated rather than risk generating a wrong one.