Tombatron.Turbo
1.0.0-alpha.6
dotnet add package Tombatron.Turbo --version 1.0.0-alpha.6
NuGet\Install-Package Tombatron.Turbo -Version 1.0.0-alpha.6
<PackageReference Include="Tombatron.Turbo" Version="1.0.0-alpha.6" />
<PackageVersion Include="Tombatron.Turbo" Version="1.0.0-alpha.6" />
<PackageReference Include="Tombatron.Turbo" />
paket add Tombatron.Turbo --version 1.0.0-alpha.6
#r "nuget: Tombatron.Turbo, 1.0.0-alpha.6"
#:package Tombatron.Turbo@1.0.0-alpha.6
#addin nuget:?package=Tombatron.Turbo&version=1.0.0-alpha.6&prerelease
#tool nuget:?package=Tombatron.Turbo&version=1.0.0-alpha.6&prerelease
Tombatron.Turbo
Hotwire Turbo for ASP.NET Core with SignalR-powered real-time streams.
Features
- Turbo Frames: Partial page updates with automatic
Turbo-Frameheader detection - Turbo Streams: Real-time updates via SignalR with targeted and broadcast support
- Source Generator: Compile-time strongly-typed partial references
- Form Validation: HTTP 422 support for inline validation errors within Turbo Frames
- Minimal API Support: Return partials from Minimal API endpoints with
TurboResults - Simple Architecture: Check for
Turbo-Frameheader, return partial or redirect - Zero JavaScript Configuration: Works out of the box with Turbo.js
Installation
NuGet (ASP.NET Core server package):
dotnet add package Tombatron.Turbo
NuGet (Source generator for strongly-typed partials, optional):
dotnet add package Tombatron.Turbo.SourceGenerator
npm (JavaScript client library):
npm install @tombatron/turbo-signalr
Quick Start
1. Add Turbo Services
// Program.cs
builder.Services.AddTurbo();
// Or with import map configuration:
builder.Services.AddTurbo(options =>
{
options.ImportMap.Pin("@hotwired/stimulus",
"https://unpkg.com/@hotwired/stimulus@3.2.2/dist/stimulus.js", preload: true);
options.ImportMap.Pin("controllers/hello", "/js/controllers/hello_controller.js");
});
2. Use Turbo Middleware
// Program.cs
app.UseRouting();
app.UseTurbo();
app.MapRazorPages();
app.MapTurboHub(); // For Turbo Streams
3. Add Tag Helpers
@* _ViewImports.cshtml *@
@addTagHelper *, Tombatron.Turbo
4. Create a Turbo Frame with a Partial
Create a partial view for your frame content:
<turbo-frame id="cart-items">
@foreach (var item in Model.Items)
{
<div>@item.Name - @item.Price</div>
}
</turbo-frame>
Use the partial in your page:
<h1>Shopping Cart</h1>
<partial name="_CartItems" model="Model" />
5. Handle Frame Requests in Your Page Model
public class CartModel : PageModel
{
public List<CartItem> Items { get; set; }
public void OnGet()
{
Items = GetCartItems();
}
public IActionResult OnGetRefresh()
{
Items = GetCartItems();
// For Turbo-Frame requests, return just the partial
if (HttpContext.IsTurboFrameRequest())
{
return Partial("_CartItems", this);
}
// For regular requests, redirect to the full page
return RedirectToPage();
}
}
6. Link to the Handler
<turbo-frame id="cart-items" src="/Cart?handler=Refresh">
Loading...
</turbo-frame>
<a href="/Cart?handler=Refresh" data-turbo-frame="cart-items">
Refresh Cart
</a>
Turbo Streams (Real-Time Updates)
Send Updates to a Stream
public class CartController : Controller
{
private readonly ITurbo _turbo;
public CartController(ITurbo turbo)
{
_turbo = turbo;
}
[HttpPost]
public async Task<IActionResult> AddItem(int itemId)
{
// Add item to cart...
// Send update to the user's stream
await _turbo.Stream($"user:{User.Identity.Name}", builder =>
{
builder.Update("cart-total", $"<span>${cart.Total}</span>");
});
return Ok();
}
}
Broadcast to All Connected Clients
// Send updates to every connected client
await _turbo.Broadcast(builder =>
{
builder.Update("active-users", $"<span>{count}</span>");
});
Render Partials in Streams
Use the async overload to render Razor partials directly in stream updates:
await _turbo.Stream($"room:{roomId}", async builder =>
{
await builder.AppendAsync("messages", "_Message", message);
});
With the source generator, you get strongly-typed partial references:
await _turbo.Stream($"room:{roomId}", async builder =>
{
await builder.AppendAsync("messages", Partials.Message, message);
});
Include the Client Scripts
<turbo-scripts />
<turbo-scripts mode="Importmap" />
The <turbo-scripts> tag helper automatically includes Turbo.js and the SignalR bridge.
In Traditional mode (default), it renders standard <script> tags.
In Importmap mode, it renders a <script type="importmap"> block with module preloads.
Configure additional modules (e.g. Stimulus) via ImportMap.Pin() in Program.cs:
builder.Services.AddTurbo(options =>
{
options.ImportMap.Pin("@hotwired/stimulus",
"https://unpkg.com/@hotwired/stimulus@3.2.2/dist/stimulus.js", preload: true);
});
Loading the SignalR Bridge from a CDN
If you prefer to load the SignalR bridge from a CDN instead of the bundled NuGet static files, you can use the @tombatron/turbo-signalr npm package via jsDelivr.
Traditional script tags:
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8/dist/turbo.es2017-esm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@8/dist/browser/signalr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tombatron/turbo-signalr/dist/turbo-signalr.js"></script>
Import maps:
<script type="importmap">
{
"imports": {
"@hotwired/turbo": "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8/dist/turbo.es2017-esm.min.js",
"@microsoft/signalr": "https://cdn.jsdelivr.net/npm/@microsoft/signalr@8/dist/browser/signalr.js",
"@tombatron/turbo-signalr": "https://cdn.jsdelivr.net/npm/@tombatron/turbo-signalr/dist/turbo-signalr.esm.js"
}
}
</script>
<script type="module">
import "@hotwired/turbo";
import "@tombatron/turbo-signalr";
</script>
Via Program.cs (recommended when using <turbo-scripts mode="Importmap" />):
The default import map pins turbo-signalr to the bundled static file from the NuGet package. Override it to point at CDN URLs instead:
builder.Services.AddTurbo(options =>
{
// Override the default Turbo.js pin (optional — the default already uses unpkg)
options.ImportMap.Pin("@hotwired/turbo",
"https://cdn.jsdelivr.net/npm/@hotwired/turbo@8/dist/turbo.es2017-esm.min.js", preload: true);
// SignalR must be in the import map so the ESM build can resolve it
options.ImportMap.Pin("@microsoft/signalr",
"https://cdn.jsdelivr.net/npm/@microsoft/signalr@8/dist/browser/signalr.js");
// Replace the bundled bridge with the CDN ESM build
options.ImportMap.Pin("turbo-signalr",
"https://cdn.jsdelivr.net/npm/@tombatron/turbo-signalr/dist/turbo-signalr.esm.js", preload: true);
});
When using CDN imports, the non-bundled ESM build (turbo-signalr.esm.js) expects @microsoft/signalr as a peer dependency resolved through the import map. The UMD build (turbo-signalr.js) expects SignalR to be loaded as a global via a separate <script> tag.
Subscribe to Streams in Your View
<turbo stream="notifications"></turbo>
<turbo-stream-source-signalr stream="user:@User.Identity.Name" hub-url="/turbo-hub">
</turbo-stream-source-signalr>
<div id="cart-total">$0.00</div>
Stream Actions
await _turbo.Stream("notifications", builder =>
{
builder
.Append("list", "<div>New item</div>") // Add to end
.Prepend("list", "<div>First</div>") // Add to beginning
.Replace("item-1", "<div>Updated</div>") // Replace element
.Update("count", "42") // Update inner content
.Remove("old-item") // Remove element
.Before("btn", "<div>Before</div>") // Insert before
.After("btn", "<div>After</div>"); // Insert after
});
Minimal API Support
Use TurboResults to return partials from Minimal API endpoints:
app.MapGet("/cart/items", (HttpContext ctx) =>
{
if (ctx.IsTurboFrameRequest())
{
return TurboResults.Partial("_CartItems", model);
}
return Results.Redirect("/cart");
});
Form Validation
When a form inside a <turbo-frame> fails validation, return HTTP 422 and Turbo will replace the frame content in-place with your error markup — no full page reload.
Minimal API:
app.MapPost("/contact", (string? name, string? email) =>
{
if (string.IsNullOrWhiteSpace(name))
{
return TurboResults.ValidationFailure("_ContactForm", new { Errors = "Name is required." });
}
return TurboResults.Partial("_ContactSuccess");
});
Razor Pages:
public IActionResult OnPostSubmit()
{
if (Errors.Count > 0)
{
Response.StatusCode = 422;
return Partial("_ContactForm", this);
}
return Partial("_ContactSuccess", this);
}
See the Form Validation Guide for lazy-loaded frames, detecting request types, and a complete walkthrough.
Source Generator
The Tombatron.Turbo.SourceGenerator package scans your _*.cshtml partial views at compile time and generates an internal Partials static class with strongly-typed references:
// Generated from _Message.cshtml with @model ChatMessage
internal static PartialTemplate<ChatMessage> Message { get; }
= new("/Pages/Shared/_Message.cshtml", "Message");
Use them for compile-time safety instead of magic strings:
await builder.AppendAsync("messages", Partials.Message, message);
Configuration
builder.Services.AddTurbo(options =>
{
options.HubPath = "/turbo-hub";
options.AddVaryHeader = true;
});
Helper Extensions
Check if a request is a Turbo Frame request:
if (HttpContext.IsTurboFrameRequest())
{
return Partial("_MyPartial", Model);
}
// Or check for a specific frame
if (HttpContext.IsTurboFrameRequest("cart-items"))
{
return Partial("_CartItems", Model);
}
// Or check for a prefix (dynamic IDs)
if (HttpContext.IsTurboFrameRequestWithPrefix("item_"))
{
return Partial("_CartItem", Model);
}
// Get the raw frame ID
string? frameId = HttpContext.GetTurboFrameId();
// Check if the request is a Turbo Stream request
if (HttpContext.IsTurboStreamRequest())
{
return Content(html, "text/vnd.turbo-stream.html");
}
How It Works
- User clicks a link or submits a form targeting a
<turbo-frame> - Turbo.js sends a request with the
Turbo-Frameheader - Your page handler checks for the header and returns a partial view
- Turbo.js extracts the matching frame from the response and updates the DOM
This approach is simple, explicit, and gives you full control over what content is returned.
Documentation
Guides
- Turbo Frames Guide - Partial page updates
- Turbo Streams Guide - Real-time updates
- Authorization Guide - Securing streams
- Testing Guide - Testing strategies
- Form Validation Guide - HTTP 422 and lazy frames
- Troubleshooting - Common issues
API Reference
- ITurbo - Main service interface
- ITurboStreamBuilder - Stream builder
- TurboOptions - Configuration
- Tag Helpers - Razor tag helpers
Migration Guides
Sample Applications
The repository includes two sample applications:
Tombatron.Turbo.Sample - Turbo Frames for partial page updates, Turbo Streams for real-time notifications, a shopping cart with add/remove operations, and a form validation demo with HTTP 422 and lazy-loaded frames.
Tombatron.Turbo.Chat - Full-featured real-time chat with cookie authentication, SQLite persistence, public rooms, direct messaging, unread indicators, and the source-generated Partials class for strongly-typed partial rendering.
Run any sample:
cd samples/Tombatron.Turbo.Sample
dotnet run
Requirements
- .NET 10.0 or later
- ASP.NET Core
- Turbo.js 8.x (client-side)
- SignalR (for Turbo Streams)
Publishing / Releases
Both the NuGet and npm packages are published automatically when a version tag is pushed:
git tag v1.2.3
git push origin v1.2.3
This triggers the Release workflow which builds, tests, and publishes:
The npm package can also be published independently via the manual workflow.
License
MIT License - see LICENSE for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Tombatron.Turbo:
| Package | Downloads |
|---|---|
|
Tombatron.Turbo.Stimulus
Stimulus controller support for Tombatron.Turbo. Runtime controller discovery, dynamic import map generation, and automatic integration with turbo-scripts. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-alpha.6 | 0 | 2/21/2026 |
| 1.0.0-alpha.5 | 37 | 2/18/2026 |
| 1.0.0-alpha.4 | 37 | 2/17/2026 |
| 1.0.0-alpha.3 | 40 | 2/15/2026 |
| 1.0.0-alpha.2 | 39 | 2/15/2026 |
| 1.0.0-alpha.1 | 39 | 2/15/2026 |