RazorWire
Source of truth
RazorWire lets ASP.NET Core MVC apps update UI by returning Razor fragments from the server instead of building a separate JSON endpoint and client-state rendering loop.
60-Second Quickstart
AppSurface has not published the public v0.1 package set yet, so the copy-paste path today is repo-local:
- Clone this repository and use the .NET 10 SDK.
- Run the MVC sample:
Bashdotnet run --project examples/razorwire-mvc/RazorWireWebExample.csproj
- Open the URL printed in the console and navigate to
/Reactivity.
Wait for the Permanent Island card to load, then click the + button. The Instance Score and Session Score update in place without a full-page reload.
When consuming package builds from a configured feed, reference ForgeTrust.RazorWire first and then continue at Add the Module. Public NuGet install commands will replace this note when the v0.1 publishing path is live.
Hero Proof
examples/razorwire-mvc/Views/Shared/Components/Counter/Default.cshtml
Razor<div id="counter-widget" class="p-4 bg-white border border-slate-100 rounded-xl shadow-sm flex items-center justify-between group">
<div class="flex gap-6">
<div class="space-y-0.5">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Instance Score</span>
<div id="instance-score-value" class="text-2xl font-black text-indigo-600 tabular-nums">@Model</div>
</div>
<div class="space-y-0.5">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Session Score</span>
<div id="session-score-value" class="text-2xl font-black text-indigo-400 tabular-nums">0</div>
</div>
</div>
<form asp-controller="Reactivity" asp-action="IncrementCounter" method="post" rw-active="true" data-counter-form>
<input type="hidden" name="clientCount" id="client-count-input" value="0" />
<button type="submit" aria-label="Increment counter" class="h-10 w-10 bg-indigo-600 text-white rounded-lg flex items-center justify-center hover:bg-indigo-700 active:scale-90 transition-all shadow-sm shadow-indigo-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
</button>
</form>
</div>
examples/razorwire-mvc/Controllers/ReactivityController.cs
C#[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult IncrementCounter([FromForm] int clientCount)
{
CounterViewComponent.Increment();
clientCount++;
if (Request.IsTurboRequest())
{
return this.RazorWireStream()
.Update(
"instance-score-value",
CounterViewComponent.Count.ToString())
.Update("session-score-value", clientCount.ToString())
.ReplacePartial(
"client-count-input",
"_CounterInput",
clientCount)
.BuildResult();
}
// Safe redirect
var referer = Request.Headers["Referer"].ToString();
return Url.IsLocalUrl(referer) ? Redirect(referer) : RedirectToAction(nameof(Index));
}
examples/razorwire-mvc/Views/Reactivity/_CounterInput.cshtml
Razor<input type='hidden' name='clientCount' id='client-count-input' value='@Model' />
Read the focused proof path for the file-by-file walkthrough. If copying this pattern gives you a bare 400 Bad Request, anti-forgery is the first thing to check. See Security & Anti-Forgery.
The source-backed snippets in this README are generated from docs:snippet markers in the sample app. After changing marked sample code, run:
Bash# From the repository root:
dotnet run --project tools/ForgeTrust.AppSurface.MarkdownSnippets/ForgeTrust.AppSurface.MarkdownSnippets.csproj -- generate
For failed submissions, RazorWire also ships a convention-based form UX stack: default form-local fallbacks for unhandled failures, server helpers for validation errors, anti-forgery diagnostics in development, and styling/event hooks for consumers. See Failed Form UX or run the sample and visit /Reactivity/FormFailures.
Generated UI Design Contract
RazorWire should feel like a quiet enhancement inside the host application, not like a separate visual product placed on top of it. Package-owned generated UI follows the RazorWire generated UI design contract.
Use that contract when adding or styling RazorWire-generated nodes such as form feedback, stream status affordances, or package-owned fallback UI. It defines the scope boundary, data-attribute and CSS custom-property styling surface, accessibility baseline, override model, and anti-patterns. It does not apply to app-authored forms, partials, layouts, or RazorDocs chrome.
Add the Module
Once you already reference the RazorWire package in your app, add RazorWireWebModule to your root module:
C#public class MyRootModule : IAppSurfaceWebModule
{
public void RegisterDependentModules(ModuleDependencyBuilder builder)
{
builder.AddModule<RazorWireWebModule>();
}
}Enable TagHelpers and Scripts
RazorWire markup only lights up when your views import the package TagHelpers and your shared layout renders the client scripts once. Without this step, rw:island, rw:stream-source, and rw-active forms fall back to plain HTML behavior.
examples/razorwire-mvc/Views/_ViewImports.cshtml
Razor@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, ForgeTrust.RazorWire
examples/razorwire-mvc/Views/Shared/_Layout.cshtml
Razor<rw:scripts/>
Configure Services (Optional)
You can customize RazorWire behavior via RazorWireOptions:
C#services.AddRazorWire(options =>
{
options.Streams.BasePath = "/custom-stream-path";
options.Forms.FailureMode = RazorWireFormFailureMode.Auto;
options.Forms.DefaultFailureMessage = "We could not submit this form. Check your input and try again.";
});Also Possible
- Keep sidebars and other regions independent with
rw:island, including lazy loading andpermanent="true"persistence across page transitions. - Push live updates to connected clients with
IRazorWireStreamHubandrw:stream-source. - Return form updates from normal MVC controllers with
this.RazorWireStream(), not a separate JSON API. - See the broader RazorWire MVC Example for registration, message publishing, islands, and SSE.
- See Failed Form UX for server failure conventions, customization, and diagnostics.
- See Security & Anti-Forgery for the form-update patterns that matter in production.
Core Concepts
Islands
Islands are isolated regions of a page that can load, reload, or update independently. RazorWire renders them as Turbo Frames, so you can decompose a page into smaller Razor-backed units without introducing a separate frontend app.
Streams and SSE
RazorWire can push Turbo Stream updates to one or more clients over Server-Sent Events. That makes it a good fit for counters, feeds, presence lists, and other UI that should update live while staying server-rendered.
Form Enhancement
Standard HTML forms can return targeted stream updates instead of full reloads or redirect-first flows. The counter example above is the smallest version of that story: submit a normal MVC form, return RazorWire updates, and change only the DOM you care about.
When EnableFailureUx is enabled, form[rw-active] also marks enhanced form posts with X-RazorWire-Form: true and __RazorWireForm=1. That gives the runtime and server adapters enough context to render useful failed-submission UX without every controller hand-rolling client glue.
Security & Anti-Forgery
Handling anti-forgery tokens correctly is critical when updating forms via Turbo Streams. See Security & Anti-Forgery for the detailed patterns and recommendations.
Development anti-forgery failures from RazorWire forms are rewritten into helpful form-local diagnostics when possible. Production responses stay safe and generic. See Failed Form UX.
Development Experience
RazorWire is designed for a fast feedback loop during development:
- Razor Runtime Compilation is automatically enabled in
Development, so you can edit.cshtmlfiles and refresh without rebuilding. - Local scripts and styles automatically receive version hashes for cache busting, even without
asp-append-version="true".
API Reference
RazorWireBridge
Frame(controller, id, viewName, model)returns a partial view wrapped in a<turbo-frame>with the specified ID.FrameComponent(controller, id, componentName)renders a view component inside a<turbo-frame>.
IRazorWireStreamHub
PublishAsync(channel, content)broadcasts a Turbo Stream fragment to every subscriber on a channel.
this.RazorWireStream() (controller extension)
Append(target, content)adds content to the end of the target element.Prepend(target, content)adds content to the beginning.Replace(target, content)replaces the target element entirely.Update(target, content)replaces the inner content of the target.Remove(target)removes the target element.FormError(target, title, message)updates the target with an encoded generated error block and marks the response handled.FormValidationErrors(target, ModelState, title, maxErrors, message)updates the target with a stable MVC validation summary and marks the response handled.BuildResult(statusCode)returns the stream and optionally sets the HTTP status code.
TagHelpers
rw:island
Wraps content in a <turbo-frame>.
id: unique identifier for the island.src: URL to load content from.loading: load strategy such aslazy.permanent: persists the element across Turbo page transitions.swr: enables stale-while-revalidate behavior.client-module: client module path or name to mount for hybrid islands.client-strategy: mount timing such asload,visible, oridle.client-props: JSON payload passed to the client module's mount function.
HTML<rw:island id="sidebar" src="/Reactivity/Sidebar" loading="lazy" permanent="true">
<p>Loading sidebar...</p>
</rw:island>form[rw-active]
Enhances a normal form so Turbo handles the submission and optional frame targeting.
rw-active="true"enables RazorWire form handling.rw-targetsets the target frame when you want to constrain the response.data-rw-form-failure-targetpoints failed-submission UI at a local error container by simple element ID, optionally prefixed with#; selector-like values are ignored.data-rw-form-failure="auto"uses the default fallback UI,manualonly dispatches events, andoffdisables the failure convention for that form.- Generated hidden fields
__RazorWireFormand, when possible,__RazorWireFormFailureTargethelp server-side adapters identify and localize form failures.
HTML<form asp-controller="Reactivity" asp-action="IncrementCounter" method="post" rw-active="true">
<input type="hidden" name="clientCount" value="0" />
<button type="submit" aria-label="Increment counter">+</button>
</form>rw:stream-source
Subscribes the page to a RazorWire stream channel.
channel: required channel name.permanent: keeps the stream source alive across Turbo visits.
HTML<rw:stream-source id="rw-stream-reactivity" channel="reactivity" permanent="true"></rw:stream-source>requires-stream
Marks an element as inactive until a named stream is connected.
HTML<button type="submit" requires-stream="reactivity">Send</button><time rw-type="local">
Localizes UTC timestamps on the client with the browser's Intl APIs.
rw-display:time,date,datetime, orrelative.rw-format:short,medium,long, orfull.
HTML<time datetime="@Model.Timestamp" rw-type="local" rw-display="relative"></time>rw:scripts
Injects the client scripts RazorWire needs, including Turbo and the RazorWire assets.
HTML<rw:scripts />
The script tag also carries failed-form runtime configuration derived from RazorWireOptions.Forms; no inline configuration script is required.
Utilities
StringUtils
ToSafeId(input, appendHash)sanitizes values for DOM IDs or anchors and can append a deterministic hash for uniqueness.
Client-Side Interop
RazorWire also supports hybrid islands where a server-rendered region mounts a client module:
HTML<rw:island id="interactive-chart"
client-module="ChartComponent"
client-strategy="visible"
client-props='{ "data": [1, 2, 3] }'>
</rw:island>
RazorWire serves /_content/ForgeTrust.RazorWire/razorwire/razorwire.js,
razorwire.islands.js, and the package demo assets as normal Razor Class Library
static web assets when the host has a static-web-assets manifest. The same files
are also embedded into the ForgeTrust.RazorWire assembly and mapped as endpoint
fallbacks by RazorWireWebModule, so packaged command-line hosts can serve the
runtime even when only compiled assemblies are present.
Static Export
RazorWire can generate CDN-ready static output with the installable razorwire
.NET tool, or with the short-lived dnx tool execution path. CDN mode is the
default: extensionless internal routes such as /about are emitted as files such
as about.html, and exporter-managed links, frames, scripts, stylesheets, images,
<img> and <source> srcset candidates, and CSS url(...) references are
rewritten to the generated artifact URLs. When the conventional
/_appsurface/errors/404 route is available, it emits 404.html through the same
validation and rewrite path. Use --mode hybrid when the exported directory will
still be served behind infrastructure that resolves application-style extensionless
URLs.
CDN export validates the dependencies it can discover while crawling. Missing
frame routes, unsafe query-bearing frame sources, missing internal assets, and
managed URLs that cannot be rewritten fail the export with RWEXPORT###
diagnostics instead of producing a broken folder. The validation boundary is
deliberate: app-authored JavaScript fetches, form posts, Server-Sent Events, import
maps, and other runtime behavior outside markup/CSS references are not proven static
by the exporter.
Those package-based commands require a published package or an explicit local package source; public package publishing is still manual until the coordinated release automation tracked in #161 lands.
For installation, dnx, local-package, and source-run examples, see the
RazorWire CLI.
Examples
RazorWire helps server-rendered ASP.NET Core apps update page fragments without asking every app to invent a separate frontend runtime. That means RazorWire occasionally owns small generated UI nodes: enhancement markers, form feedback, stream connection affordances, and other package-created fragments that support RazorWire behavior.
RazorWire-enhanced forms now have a convention for server failures. A form with `rw-active="true"` gets request markers, a default form-local fallback UI for unhandled failures, server-side helpers for high-quality validation errors, and development diagnostics for anti-forgery failures.
When using RazorWire Turbo Streams to replace or update parts of a page that contain forms, the original Anti-Forgery token hidden input may be lost. To prevent `400 Bad Request` errors on subsequent form submissions, ensure the token is included in your updated HTML.