InertiaNet 3.0.2
dotnet add package InertiaNet --version 3.0.2
NuGet\Install-Package InertiaNet -Version 3.0.2
<PackageReference Include="InertiaNet" Version="3.0.2" />
<PackageVersion Include="InertiaNet" Version="3.0.2" />
<PackageReference Include="InertiaNet" />
paket add InertiaNet --version 3.0.2
#r "nuget: InertiaNet, 3.0.2"
#:package InertiaNet@3.0.2
#addin nuget:?package=InertiaNet&version=3.0.2
#tool nuget:?package=InertiaNet&version=3.0.2
InertiaNet
A compatibility-first ASP.NET Core server adapter for Inertia.js v3.
Includes support for deferred props, merge props, once props, infinite scroll, history encryption, flash data, SSR with Vite hot mode, fragment preservation, prefetch support, and testing utilities.
Targets: .NET 8, .NET 9, .NET 10
Installation
dotnet add package InertiaNet
Getting Started
1. Register services
// Program.cs
builder.Services.AddInertia();
// Optionally register the Vite helper (enables <vite-input> tag helpers)
builder.Services.AddViteHelper();
2. Add the middleware
app.UseInertia(); // after UseSession / UseAuthentication
Session & TempData
Flash data and validation error forwarding across redirects require ASP.NET Core session middleware:
builder.Services.AddSession();
// ...
app.UseSession();
app.UseInertia(); // must come after UseSession
Without session middleware, flash data and validation forwarding will not persist across redirects.
3. Create the root layout
Add Views/Shared/App.cshtml:
@addTagHelper *, InertiaNet
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
<inertia-head />
</head>
<body>
<vite-react-refresh />
<inertia />
<vite-input src="src/main.tsx" />
</body>
</html>
The recommended frontend workspace lives under ClientApp/:
YourApp/
ClientApp/
package.json
vite.config.ts
tsconfig.json
src/
app.tsx
pages/
Program.cs
Views/
wwwroot/
Configuration
All options are configured via AddInertia():
builder.Services.AddInertia(options =>
{
options.RootView = "App"; // Razor view for initial HTML render
options.EncryptHistory = false; // Enable history encryption globally
options.WithAllErrors = false; // Return all validation errors per field
options.ExposeSharedPropKeys = true; // Include sharedProps key list in responses
options.PrefetchCacheMaxAge = 10; // Default Cache-Control max-age for prefetch (seconds)
options.Version = ManifestVersionStrategy.FromViteOrMix(webRootPath); // Asset versioning
// Page component validation (catches typos during development)
options.Pages.EnsurePagesExist = true;
options.Pages.Paths = ["ClientApp/src/pages"]; // source tree (HMR/dev mode)
options.Pages.ManifestPaths = ["wwwroot/build/manifest.json"]; // build output (production)
});
SSR
builder.Services.AddInertiaWithSsr(options =>
{
options.Ssr.Url = "http://127.0.0.1:13714"; // Node.js SSR server
options.Ssr.ThrowOnError = false; // Fall back to CSR on failure
options.Ssr.ExcludePaths = ["/admin/*"]; // Skip SSR for matching routes
});
Recommended deployment model:
- Development: run ASP.NET Core, Vite, and the Node SSR server together;
vite-inputuses thehotfile and Inertia SSR posts to__inertia_ssron the Vite dev server. - Production: serve built frontend assets from
wwwroot/build, run the Node SSR server separately, and pointoptions.Ssr.Urlat that long-lived process. - Fallback mode: keep
ThrowOnError = falseunless SSR failures should fail the whole request; this allows production SSR to degrade cleanly to CSR.
Skip SSR for the current response:
app.MapGet("/reports", (IInertiaService inertia) =>
{
inertia.WithoutSsr();
return inertia.Render("Reports/Index");
});
Vite Helper
builder.Services.AddViteHelper(options =>
{
options.PublicDirectory = "wwwroot"; // Web root
options.BuildDirectory = "build"; // Vite output sub-directory
options.ManifestFilename = "manifest.json";
options.HotFile = "hot"; // Written by laravel-vite-plugin in dev
});
InertiaNet expects the same Vite conventions in development and production:
- development uses
PublicDirectory/HotFileto discover the active dev server - production uses
PublicDirectory/BuildDirectory/ManifestFilenameto resolve hashed assets - SSR hot mode uses the same hot-file location, so keep Vite and ASP.NET aligned on
PublicDirectory - Vite itself is expected to run from
ClientApp/, while built assets still land inwwwroot/build
Request Lifecycle
At a high level, InertiaNet processes requests in four stages:
InertiaMiddlewareprepares the request. It shares global props, restores flash data and validation errors from TempData, resolves request-scopedVersionandRootView, and handles version mismatches before the endpoint runs.- Your endpoint returns
inertia.Render(...). This produces anInertiaResultthat works for both MVC and Minimal APIs. PropsResolverresolves the page props. Shared props, page props, deferred props, merge props, once props, scroll metadata, and event handlers are all applied here.- The response is written.
Inertia XHR requests receive JSON. Initial page loads render the root Razor view, optionally with SSR markup injected via
<inertia-head />and<inertia />.
This split is intentional:
- middleware owns request concerns such as versioning, TempData, and shared request state
InertiaResultowns page construction and response writing- tag helpers own HTML embedding of the already-built page and SSR payload
Shared Props & Middleware
Subclass InertiaMiddleware to share props on every request — the equivalent of Laravel's HandleInertiaRequests:
public class HandleInertiaRequests : InertiaMiddleware
{
protected override Task Share(HttpContext context, IInertiaService inertia)
{
inertia.Share("auth", new
{
user = context.User.Identity?.Name,
});
return Task.CompletedTask;
}
protected override string? GetVersion(HttpContext context)
=> ManifestVersionStrategy.Hash("wwwroot/build/manifest.json");
}
Register it:
builder.Services.AddInertia<HandleInertiaRequests>();
app.UseInertia<HandleInertiaRequests>();
Conditional Props
Use When() / Unless() to conditionally share props:
inertia.When(user.IsAdmin, "adminSettings", adminSettings);
inertia.Unless(user.IsGuest, "notifications", async (sp, ct) =>
await sp.GetRequiredService<INotificationService>().GetAsync(ct));
CSRF/XSRF Token
When IAntiforgery is registered, InertiaNet automatically shares an xsrfToken prop on every request. Override ShareCsrfToken in your middleware subclass to customise or disable:
protected override Task ShareCsrfToken(HttpContext context, IInertiaService inertia)
{
// Disable automatic CSRF sharing
return Task.CompletedTask;
}
Navigation Context
RenderContext (passed to IProvidesInertiaProperties) and PropertyContext (passed to IProvidesInertiaProperty) expose navigation state properties:
IsInertiaRequest— true for Inertia XHR requestsIsPartialReload— true when this is a partial reload for the current component (RenderContext only)Referer— the Referer header valuePartialComponent— the partial-reload component name
Rendering
Minimal API
app.MapGet("/", (IInertiaService inertia) => inertia.Render("Home"));
// First-class Minimal API result helper
app.MapGet("/dashboard", () => InertiaResults.Inertia("Dashboard", new { ready = true }));
// Request-aware props without resolving IInertiaService manually
app.MapGet("/account", (HttpContext ctx) => InertiaResults.Inertia("Account", new
{
path = ctx.Request.Path.Value,
user = ctx.User.Identity?.Name,
}));
// Static route shorthand
app.MapInertia("/about", "About");
// Request-aware static route shorthand
app.MapInertia("/settings", "Settings", ctx => new { tab = ctx.Request.Query["tab"].ToString() });
// SPA fallback for frontend-driven routes
app.MapInertiaFallback("AppShell");
Initial HTML rendering for Minimal APIs still requires Razor view services and a root view.
Register AddControllersWithViews() or AddRazorPages() in addition to AddInertia(...).
MVC Controller
public class PostsController : Controller
{
public IActionResult Index()
=> this.Inertia("Posts/Index", new { posts = _db.Posts.ToList() });
}
ControllerBase.Inertia(...) remains the primary MVC surface.
If you need data for the root Razor view that should not be exposed to the frontend, use WithViewData(...) on the returned InertiaResult.
Analyzers
InertiaNet.Analyzers adds Roslyn diagnostics for common InertiaNet and Pathfinder mistakes.
Current diagnostics:
INERTIA001invalid Inertia component namesINERTIA002JsonSerializerOptionsnaming policies that do not affect the Inertia envelopeINERTIA003missing page component files when page validation is enabledPATHFINDER001Minimal API route templates that Pathfinder cannot resolve staticallyPATHFINDER002Minimal API method-group handlers that Pathfinder does not currently support
Add it to your app project like any other analyzer package:
<ItemGroup>
<PackageReference Include="InertiaNet.Analyzers" Version="0.1.0-alpha.1" PrivateAssets="all" />
</ItemGroup>
Templates
InertiaNet.Templates ships starter templates for React and Vue.
Available templates:
inertianet-reactinertianet-vue
Install and use them:
dotnet new install InertiaNet.Templates
dotnet new inertianet-react -n MyReactApp
dotnet new inertianet-vue -n MyVueApp
Each starter includes:
- an ASP.NET Core app configured with
AddInertia,AddViteHelper, and a root Razor view - a
ClientApp/frontend workspace with a minimal entrypoint andHomepage - a
.devcontainer/configuration with .NET and Node ready to go - a committed
wwwroot/via.gitkeep, so Vite and static asset output have a stable home from day one - Vite configuration wired to
wwwroot MapInertiaplus a fallback route for SPA-style navigation
For prerelease publishing and trusted publishing setup, see docs/releasing.md.
Props
Always Props
Included in every response, even partial reloads that would otherwise skip it:
inertia.Render("Dashboard", new
{
errors = inertia.Always(new {}),
});
Optional Props
Excluded from the initial load; only included when explicitly requested via only:
inertia.Render("Users/Index", new
{
users = inertia.Optional(async (sp, ct) =>
await sp.GetRequiredService<IUserService>().GetAllAsync(ct)),
});
Deferred Props
Excluded from the initial load. The client fetches them asynchronously after the first render. Group related props to batch the follow-up request:
inertia.Render("Dashboard", new
{
revenue = inertia.Defer(async (sp, ct) => await GetRevenueAsync()),
topClients = inertia.Defer(async (sp, ct) => await GetTopClientsAsync(), group: "charts"),
topProducts = inertia.Defer(async (sp, ct) => await GetTopProductsAsync(), group: "charts"),
});
Merge Props
The resolved value is merged (appended) into the client's existing data instead of replacing it:
inertia.Render("Feed", new
{
posts = inertia.Merge(async (sp, ct) => await GetPageAsync()),
});
// Deep merge
posts = inertia.DeepMerge(async (sp, ct) => await GetPageAsync());
Once Props
Resolved once and remembered by the client across navigations. Ideal for shared reference data:
inertia.Render("Billing", new
{
plans = inertia.Once(async (sp, ct) => await GetPlansAsync()),
});
// With expiry and custom cache key
plans = inertia.Once(async (sp, ct) => await GetPlansAsync())
.Until(TimeSpan.FromHours(1))
.As("billingPlans");
Share once-props globally:
// In HandleInertiaRequests.ShareOnce():
protected override Task ShareOnce(HttpContext context, IInertiaService inertia)
{
inertia.ShareOnce("countries", async (sp, ct) =>
await sp.GetRequiredService<ICountryService>().GetAllAsync(ct));
return Task.CompletedTask;
}
Combining Modifiers
Modifiers are chainable:
// Deferred + merge + once
inertia.Defer(async (sp, ct) => await GetActivityAsync()).Merge().Once();
// Optional + once
inertia.Optional(async (sp, ct) => await GetCategoriesAsync()).Once();
Flash Data
Send one-time data that is not persisted in browser history state. Automatically survives redirects:
inertia.Flash("message", "User created successfully!");
return RedirectToAction("Index");
Access client-side via page.flash.message.
Navigation
External Redirect
Forces a full-page navigation (bypasses the Inertia client router):
return inertia.Location("https://example.com");
History Encryption
Encrypt page data in browser history to prevent sensitive data leaking via the back button:
// Globally in options
options.EncryptHistory = true;
// Per-request
inertia.EncryptHistory();
// Per-route (MVC filter)
[EncryptHistory]
public IActionResult Dashboard() => ...
// Per-route (Minimal API)
app.MapGet("/secure", handler).AddEndpointFilter<EncryptHistoryEndpointFilter>();
Clear History
Rotate the encryption key so old history entries can no longer be decrypted:
inertia.ClearHistory();
Validation
InertiaValidationFilter automatically serializes ModelState errors to TempData when a controller action redirects with invalid state. The middleware restores them as the errors prop on the next request:
[HttpPost]
public async Task<IActionResult> Store([FromBody] CreateUserRequest request)
{
if (!ModelState.IsValid)
return RedirectToAction("Create"); // errors auto-forwarded via TempData
await _userService.CreateAsync(request);
return RedirectToAction("Index");
}
Minimal API Validation
For Minimal API endpoints, use the endpoint filter and SetInertiaValidationErrors extension:
app.MapPost("/users", (HttpContext ctx, CreateUserRequest request) =>
{
var errors = Validate(request);
if (errors.Count > 0)
{
ctx.SetInertiaValidationErrors(errors);
return Results.Redirect("/users/create");
}
// ...
return Results.Redirect("/users");
}).WithInertiaValidation();
SetInertiaValidationErrors(...) also accepts ValidationProblemDetails and ModelStateDictionary:
app.MapPost("/users", (HttpContext ctx, CreateUserRequest request) =>
{
var problem = new ValidationProblemDetails(new Dictionary<string, string[]>
{
["email"] = ["Email is required"],
});
ctx.SetInertiaValidationErrors(problem, bag: "createUser");
return Results.Redirect("/users/create");
}).WithInertiaValidation();
Error Bags
Scope errors for pages with multiple forms:
router.post('/users', data, { errorBag: 'createUser' });
Error Handling
Use HandleExceptionsUsing to render custom error pages for Inertia requests:
builder.Services.AddInertia(options =>
{
options.HandleExceptionsUsing = (exception, context) =>
InertiaResults.Inertia("Errors/ServerError", new
{
message = context.RequestServices.GetRequiredService<IHostEnvironment>().IsDevelopment()
? exception.Message
: "Something went wrong.",
});
});
The handler may also return a normal ASP.NET Core result when that is more appropriate:
options.HandleExceptionsUsing = (exception, context) =>
Results.StatusCode(StatusCodes.Status500InternalServerError);
This hook runs for both MVC and Minimal API Inertia responses.
Precognition
Precognition lets the frontend validate form data against server-side rules in real-time without submitting the form. When the client sends Precognition: true, the server runs model binding and validation only:
- Valid → 204 No Content +
Precognition-Success: true - Invalid → 422 Unprocessable Entity with validation errors as JSON
[HttpPost]
public IActionResult Store([FromBody] CreateUserRequest request)
{
// Action body never executes for precognition requests —
// the InertiaPrecognitionFilter short-circuits after validation.
await _userService.CreateAsync(request);
return RedirectToAction("Index");
}
The InertiaPrecognitionFilter is registered globally and runs before other filters.
Event Hooks
Implement IInertiaEventHandler to hook into the render pipeline:
public class MyEventHandler : IInertiaEventHandler
{
public Task OnAfterResolveProps(HttpContext context, Dictionary<string, object?> props)
{
// Modify resolved props before the page object is built
return Task.CompletedTask;
}
public Task OnBeforeRender(HttpContext context, InertiaPage page)
{
// Inspect or modify the page object before the response is written
return Task.CompletedTask;
}
}
Register with:
builder.Services.AddInertiaEventHandler<MyEventHandler>();
Multiple handlers can be registered — they run in registration order.
Vite Tag Helpers
Add @addTagHelper *, InertiaNet to _ViewImports.cshtml, then use in your layout:
These tag helpers assume your app has Razor view support enabled and that your configured root view renders <inertia />.
| Tag | Description |
|---|---|
<inertia /> |
Renders the root <div id="app"> and the page data <script> |
<inertia-head /> |
Renders SSR-generated <head> tags (meta, title, links) |
<vite-input src="..." /> |
Renders the correct <script> / <link> tags for a Vite entry-point |
<vite-react-refresh /> |
Injects the React Fast Refresh preamble in HMR mode (no-op in production) |
Vue example
<inertia-head />
<inertia />
<vite-input src="src/app.ts" />
React example
<inertia-head />
<vite-react-refresh />
<inertia />
<vite-input src="src/main.tsx" />
The vite-input tag helper:
- HMR mode (when
PublicDirectory/HotFileexists): injects the Vite dev-server client and the requested module. - Production: reads
wwwroot/build/manifest.json, resolves the hashed filename, and emits<script type="module">for JS entries plus any associated<link rel="stylesheet">CSS chunks.
Vite config
Use laravel-vite-plugin (or any plugin that writes a hot file):
// ClientApp/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: ['src/main.tsx'],
publicDirectory: '../wwwroot',
}),
react(),
],
});
The entry paths used by <vite-input> remain relative to the frontend workspace root, so a ClientApp/vite.config.ts file can still expose entries like src/app.tsx or src/app.ts.
Asset Versioning
Wire up automatic asset version checking so clients reload when you deploy new assets:
builder.Services.AddInertia(options =>
{
options.Version = ManifestVersionStrategy.FromViteOrMix(
builder.Environment.WebRootPath);
});
ManifestVersionStrategy hashes the Vite manifest (or Mix manifest) with xxHash128. When the hash the server sends differs from the version the client has, the middleware returns 409 Conflict + X-Inertia-Location and the client performs a full-page reload.
Testing
InertiaNet ships a testing API modelled after Laravel's assertInertia helpers:
// In an xUnit / NUnit integration test
var response = await _client.GetAsync("/posts");
var page = await response.AssertInertiaAsync();
page.HasComponent("Posts/Index")
.HasProp("posts")
.HasUrl("/posts");
var redirect = await _client.GetAsync("/stale-assets", HttpCompletionOption.ResponseHeadersRead);
redirect.AssertVersionRedirect().To("/stale-assets");
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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
- System.IO.Hashing (>= 9.0.0)
-
net8.0
- System.IO.Hashing (>= 9.0.0)
-
net9.0
- System.IO.Hashing (>= 9.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.2 | 151 | 4/3/2026 |
| 3.0.1 | 103 | 4/3/2026 |
| 3.0.0 | 102 | 4/3/2026 |
| 3.0.0-alpha.7 | 62 | 4/3/2026 |
| 3.0.0-alpha.6 | 55 | 4/3/2026 |
| 3.0.0-alpha.5 | 66 | 4/3/2026 |
| 3.0.0-alpha.2 | 61 | 4/2/2026 |
| 3.0.0-alpha.1 | 59 | 4/2/2026 |