The ASP.NET Core adapter for Inertia.js — build modern SPAs with Vue, React, or Svelte without building a separate API. Inspired by inertia-rails.
InertiaSharp lets you build single-page applications using your ASP.NET Core controllers or Minimal api as the backend, while rendering your frontend in Vue 3, React, or Svelte. No REST API, no token auth, no separate frontend repo needed.
Browser → ASP.NET Core Controller → return this.Inertia("Dashboard", props)
or
Browser → ASP.NET Core Minimal api → return Results.Extensions.Inertia("Dashboard", props);
↓
First visit: full HTML page
Subsequent: JSON page object (XHR)
↓
Vue/React/Svelte renders
| Step | What happens |
|---|---|
| First visit | Browser makes a normal GET. Server returns full HTML with <div id="app" data-page="{...}">. Vite/Vue boots from data-page. |
| Subsequent clicks | Inertia intercepts <Link> clicks, sends XHR with X-Inertia: true. Server returns JSON page object instead of HTML. |
| Asset version mismatch | Server returns 409 Conflict with X-Inertia-Location, client does a full reload to pick up new assets. |
| Partial reloads | Client sends X-Inertia-Partial-Data header. Server only includes requested props. |
| POST redirects | Middleware converts 302 → 303 so browsers re-GET instead of re-POST. |
InertiaSharp/
├── src/
│ └── InertiaSharp/ # 📦 NuGet Package
│ ├── InertiaOptions.cs # Configuration
│ ├── InertiaPage.cs # Page object model
│ ├── InertiaService.cs # Shared props (scoped)
│ ├── InertiaResult.cs # IActionResult implementation
│ ├── Middleware/
│ │ └── InertiaMiddleware.cs # Version check + 302→303
│ ├── Extensions/
│ │ ├── ControllerExtensions.cs # this.Inertia(...)
│ │ └── ServiceCollectionExtensions.cs
│ └── TagHelpers/
│ └── InertiaTagHelper.cs # <inertia /> → <div id="app">
│
└── sample/
└── InertiaSharp.Sample/ # 🎯 Demo app (ASP.NET Core 10)
├── Controllers/
│ ├── AuthController.cs # Login, Register, Logout
│ ├── DashboardController.cs # Protected dashboard
│ └── ProfileController.cs # Profile + password change
├── Models/AppUser.cs # Extended Identity user
├── Data/AppDbContext.cs # EF Core + Identity
├── Permissions/ # Roles + policy constants
├── Views/Shared/App.cshtml # Inertia shell view
└── ClientApp/ # Vue 3 + Vite frontend
└── src/
├── app.ts # Inertia + Vue bootstrap
├── Pages/
│ ├── Auth/Login.vue
│ ├── Auth/Register.vue
│ ├── Dashboard.vue
│ └── Profile/Edit.vue
└── Layouts/
├── AppLayout.vue # Sidebar for auth pages
└── GuestLayout.vue # Centered card for auth
dotnet add package InertiaSharpusing InertiaSharp.Extensions;
builder.Services.AddInertia(opt =>
{
opt.RootView = "App"; // → Views/Shared/App.cshtml
opt.Version = "1.0.0"; // bump on asset changes
});
// ...
app.UseRouting();
app.UseInertia(); // ← after UseRouting, before UseAuth
app.UseAuthentication();
app.UseAuthorization();@addTagHelper *, InertiaSharp
<!DOCTYPE html>
<html>
<head>
<meta name="csrf-token" content="@Antiforgery.GetAndStoreTokens(Context).RequestToken" />
<script type="module" src="http://localhost:5173/src/app.ts"></script>
</head>
<body>
<inertia /> <!-- renders: <div id="app" data-page="..."> -->
</body>
</html>public class EventsController : Controller
{
// GET /events
public IActionResult Index()
=> this.Inertia("Events/Index", new { events = _db.Events.ToList() });
// POST /events — validation errors sent back as props
[HttpPost]
public IActionResult Store([FromForm] CreateEventDto dto)
{
if (!ModelState.IsValid)
{
var errors = ModelState.ToDictionary(
k => k.Key,
v => v.Value?.Errors.First().ErrorMessage ?? "");
return this.Inertia("Events/Create", new { errors });
}
// ...
return Redirect("/events");
}
}// In a base controller or middleware:
public class InertiaBaseController : Controller
{
protected readonly InertiaService _inertia;
public InertiaBaseController(InertiaService inertia)
{
_inertia = inertia;
}
public override void OnActionExecuting(ActionExecutingContext context)
{
_inertia.Share("auth", new
{
user = HttpContext.User.Identity?.IsAuthenticated == true
? new { name = HttpContext.User.Identity.Name }
: null
});
}
} <!--
This target runs `npm run build` before publishing.
During development, Vite dev server handles assets (hot reload).
-->
<Target Name="PublishFrontend" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
<Exec WorkingDirectory="ClientApp" Command="npm ci" />
<Exec WorkingDirectory="ClientApp" Command="npm run build" />
</Target>Run both servers simultaneously with app.UseViteDevelopmentServer():
app.UseViteDevelopmentServer(); // always before app.Run()
app.Run();Or script .sh:
# From repo root:
./run-dev.shOr manually in two terminals:
# Terminal 1 — Vite dev server (HMR for .vue files)
cd sample/InertiaSharp.Sample/ClientApp
npm install
npm run dev
# Terminal 2 — ASP.NET Core (auto-restarts on C# changes)
cd sample/InertiaSharp.Sample
dotnet watch run- Vue changes → instant hot module replacement (no page reload)
- C# changes →
dotnet watchrestarts the backend, Inertia triggers a full page reload
Visit: https://localhost:5001
Demo credentials: admin@demo.com / Password123!
cd sample/InertiaSharp.Sample
# Create initial migration
dotnet ef migrations add InitialCreate --output-dir Data/Migrations
# Apply (also runs automatically on startup via MigrateAsync)
dotnet ef database updateUser → Login → Cookie issued → Role assigned (Admin/Editor/Viewer)
↓
DashboardController.Index()
reads roles → passes `permissions` prop
↓
Dashboard.vue conditionally renders
sections based on permissions
Policy-based authorization on controllers:
[Authorize(Policy = Policies.CanManageUsers)]
public IActionResult UserManagement() => this.Inertia("Admin/Users");# Build image
docker build -t inertia-sharp-app .
# Run with persistent database volume
docker run -d \
-p 8080:8080 \
-v inertia_data:/data \
--name inertia-app \
inertia-sharp-app
# Visit http://localhost:8080# Build frontend first
cd sample/InertiaSharp.Sample/ClientApp
npm ci && npm run build
# Publish .NET app
cd ..
dotnet publish -c Release -o ./publish
# Run
cd publish
./InertiaSharp.SampleThe Dockerfile works on any container platform. Set these environment variables:
| Variable | Value |
|---|---|
ConnectionStrings__Default |
Your connection string |
ASPNETCORE_ENVIRONMENT |
Production |
ASPNETCORE_URLS |
http://+:8080 |
Program.cs automatically computes the asset version from the Vite manifest:
var viteManifestPath = Path.Combine(env.WebRootPath, ".vite", "manifest.json");
var assetVersion = File.Exists(viteManifestPath)
? Convert.ToHexString(MD5.HashData(File.ReadAllText(viteManifestPath)))
: "1";
builder.Services.AddInertia(opt => { opt.Version = assetVersion; });When you deploy a new frontend build, the version changes and all connected clients automatically reload to pick up new assets (409 Conflict flow).
InertiaSharp supports Minimal APIs first-class via InertiaHttpResult (IResult) alongside the traditional MVC InertiaResult (IActionResult). Both share the exact same rendering engine — props merging, partial reloads, version checking — so you can mix and match freely or use Minimal APIs exclusively.
Pattern 1 — Results.Extensions.Inertia() — the standard idiomatic way:
app.MapGet("/dashboard", async (UserManager<AppUser> users, ClaimsPrincipal user) =>
{
var appUser = await users.GetUserAsync(user)!;
return Results.Extensions.Inertia("Dashboard", new
{
name = appUser.FullName,
email = appUser.Email,
});
})
.RequireAuthorization();Pattern 2 — app.MapInertia() — zero-boilerplate static pages:
// Component receives no dynamic props — perfect for About, Terms, 404, etc.
app.MapInertia("/about", "Marketing/About");
app.MapInertia("/403", "Errors/Forbidden").AllowAnonymous();
// With static props
app.MapInertia("/features", "Marketing/Features", new
{
plans = new[] { "Free", "Pro", "Enterprise" }
});Pattern 3 — app.MapInertiaGroup() — grouped + authorized, with shared middleware:
// All routes in the group inherit .RequireAuthorization()
// The endpoint filter runs on every request to share auth props
var authenticated = app
.MapInertiaGroup("/app")
.RequireAuthorization()
.AddEndpointFilter(async (ctx, next) =>
{
var inertia = ctx.HttpContext.RequestServices.GetRequiredService<InertiaService>();
inertia.Share("auth", new { user = ctx.HttpContext.User.Identity!.Name });
return await next(ctx);
});
authenticated.MapGet("dashboard", () =>
Results.Extensions.Inertia("Dashboard"));
authenticated.MapGet("profile", () =>
Results.Extensions.Inertia("Profile/Edit"));
// Fine-grained policy per route
authenticated
.MapGet("admin/users", async (UserManager<AppUser> users) =>
Results.Extensions.Inertia("Admin/Users", new { users = users.Users.ToList() }))
.RequireAuthorization(Policies.CanManageUsers);| MVC Controllers | Minimal APIs | |
|---|---|---|
| Return type | InertiaResult : IActionResult |
InertiaHttpResult : IResult |
| Syntax | this.Inertia("Page", props) |
Results.Extensions.Inertia("Page", props) |
| Static pages | Custom action | app.MapInertia(route, component) |
| Grouped routes | Base controller + [Route] |
app.MapInertiaGroup(prefix) |
| Shared props | Inject InertiaService |
Same — inject InertiaService |
| Rendering engine | InertiaPageRenderer ✓ |
InertiaPageRenderer ✓ (shared) |
The server-side package is framework-agnostic. Only the client bootstrap changes.
React:
npm install @inertiajs/react react react-dom// src/app.tsx
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
return pages[`./Pages/${name}.tsx`]
},
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />)
},
})Svelte:
npm install @inertiajs/svelte svelte// src/app.ts
import { createInertiaApp } from '@inertiajs/svelte'
createInertiaApp({
resolve: name => import(`./Pages/${name}.svelte`),
setup({ el, App, props }) {
new App({ target: el, props })
},
})InertiaSharp ships an interactive CLI tool (InertiaSharp.Cli) that scaffolds full starter projects in seconds. It is distributed as a .NET global tool under the command name inertiasharp.
dotnet tool install -g InertiaSharp.Cliinertiasharp [project-name]
inertiasharp --help
inertiasharp --versionIf you omit the project name, the wizard will ask for it interactively.
Running inertiasharp (or inertiasharp MyApp) opens a step-by-step prompt:
| Step | Options |
|---|---|
| API style | MVC Controllers · Minimal API |
| Database | SQLite · PostgreSQL · SQL Server |
| Frontend | Vue 3 + Reka UI (shadcn-vue) · React + shadcn/ui · Svelte + shadcn-svelte |
| Auth | ASP.NET Core Identity (login, register, profile, roles) · Simple home page with a DB query |
After confirming, the CLI:
- Generates the full ASP.NET Core project (
.csproj,Program.cs, controllers / endpoints, EF Core context, shell Razor view,appsettings.json,run-dev.sh,.gitignore). - Generates the frontend (
ClientApp/) configured for the chosen framework and Vite. - Runs
npm installinsideClientApp/. - Runs
dotnet restorein the project root.
MyApp/
├── MyApp.csproj
├── Program.cs
├── appsettings.json
├── run-dev.sh # starts both Vite dev server and dotnet watch
├── Controllers/ # (MVC style only)
│ ├── HomeController.cs
│ ├── AuthController.cs # (with auth)
│ ├── DashboardController.cs # (with auth)
│ ├── ProfileController.cs # (with auth)
│ └── AdminController.cs # (with auth)
├── Models/
├── Data/AppDbContext.cs
├── Permissions/ # Roles + Policies (with auth)
├── Contracts/ # Request DTOs (with auth)
├── Views/Shared/App.cshtml # Inertia shell
├── wwwroot/
├── Migrations/
└── ClientApp/ # Vite + Vue / React / Svelte
└── src/
├── app.ts (or .tsx)
├── Pages/
└── Layouts/
cd MyApp
bash run-dev.shIf you included auth, run migrations on first start (applied automatically):
dotnet ef migrations add InitialCreate
# migrations are auto-applied on startupDemo credentials (auth template): admin@demo.com / Password123!
MIT