# Inertia.Net documentation
> Inertia.Net is a .NET 10 server adapter for Inertia.js v3. ASP.NET Core endpoints (Minimal APIs, MVC or FastEndpoints) return a page component name and props; React, Vue or Svelte renders them. There is no separate JSON API or client-side router.
---
# Introduction
URL: https://inertia-dotnet.temtem.dev/docs/introduction
> Inertia.Net is a .NET 10 server adapter for Inertia.js v3. Build React, Vue and Svelte apps without a separate API.
Inertia.Net connects ASP.NET Core to the Inertia.js v3 client. Your endpoints return pages and props; the client renders them as React, Vue or Svelte components. The first visit is a normal HTML response, and later visits are Inertia requests that update the page without a full reload.
It is an adapter, not a framework. Routing, authentication, authorization, validation and persistence stay in ASP.NET Core. There is no separate JSON API to design and no client-side router to keep in sync.
## How it works
```csharp
app.MapGet("/", () => Render("Home", new InertiaProps
{
["greeting"] = "Hello from .NET",
}));
```
```tsx
export default function Home({ greeting }: { greeting: string }) {
return
{greeting}
}
```
The first request returns HTML with the page object embedded. Inertia links and form submissions request the next page in the background and swap the component while the browser keeps its state.
Your .NET app still owns the request. The adapter adds the protocol middleware, prop resolution, redirect semantics, flash data and asset tags.
## What you get
Minimal APIs, MVC controllers and FastEndpoints share the same core package and protocol behavior.
Lazy, optional, deferred, merged, once, cached, rescued and infinite-scroll props.
CSRF cookies, encrypted state, asset versioning, proxy support and SSR supervision.
Props are written through System.Text.Json metadata. No reflection over your page objects.
## Feature matrix
| | Minimal API | MVC controllers | FastEndpoints |
|---|:---:|:---:|:---:|
| Package | `Inertia.Net` | `Inertia.Net` + `Inertia.Net.Mvc` | `Inertia.Net` + `Inertia.Net.FastEndpoints` |
| Render / Back / Location | `Render(...)` returns `IResult` | same, return `IResult` from an action | `Send.InertiaAsync(...)` or return the result |
| Validation redirect with errors | `.WithInertiaValidation()` | `ModelState` filter (global) | `c.ConfigureInertia()` pre-processor |
| Precognition | `.WithInertiaPrecognition()` | not available | `c.ConfigureInertia()` |
| Razor root view | no (`app.html` template) | optional (tag helpers) | no (`app.html` template) |
| Native AOT | yes | no (MVC itself is not AOT) | yes (with the FastEndpoints source generator) |
The client packages are `@inertiajs/react`, `@inertiajs/vue3` and `@inertiajs/svelte`, all version 3. Inertia.Net does not support v2 clients.
## This documentation site
This site is an Inertia.Net app. The sidebar, search, docs pages and theme switcher are React components rendered by an ASP.NET Core Minimal API. Navigation uses Inertia links, and the source is in `docs/Inertia.Net.Docs` so it doubles as a real example.
## Requirements
- .NET 10 SDK
- JavaScript frontend build tools with npm, Bun, pnpm or Yarn; managed SSR requires Node.js or Bun at runtime
- An Inertia.js v3 client package for React, Vue or Svelte
Use the starter template: `dotnet new install Inertia.Net.Templates`, then `dotnet new inertia -n MyApp --framework react`. It includes working pages, a form with validation, flash messages, CSRF and Vite configuration.
## Next steps
- [Why Inertia?](/docs/why-inertia) compares it with a React, Vue or Svelte app plus a Web API.
- [First 10 minutes](/docs/first-10-minutes) takes you from the template to a form test.
- [Install the packages](/docs/installation) in an existing app.
- [Follow the quick start](/docs/quick-start) for a minimal end-to-end setup.
- [Read the props reference](/docs/props) when you are ready to model page data.
---
# Why Inertia?
URL: https://inertia-dotnet.temtem.dev/docs/why-inertia
> What Inertia is, how it compares to React, Vue or Svelte with a Web API, and when to choose it.
If you build a React, Vue or Svelte frontend on top of ASP.NET Core today, you probably have two projects: a Web API that returns JSON, and a single-page app (SPA) that calls it. Inertia lets you keep the modern frontend and drop the second half of that setup.
## The idea in one minute
With Inertia, your ASP.NET Core endpoint does not return JSON. It returns the **name of a frontend page** and the **data that page needs**:
```csharp
app.MapGet("/users", (UserStore users) =>
Render("Users/Index", new InertiaProps { ["users"] = users.All() }));
```
The matching component receives that data as ordinary props:
```tsx
export default function Index({ users }: { users: { id: number; name: string }[] }) {
return
{users.map((u) =>
{u.name}
)}
}
```
The browser loads your app once. After that, clicking a link or submitting a form asks the server for the next page's props in the background, and Inertia swaps the component without a full reload. It feels like an SPA, but the server still decides what URL maps to what page.
## Inertia vs a SPA plus a Web API
| | SPA + Web API | Inertia.Net |
|---|---|---|
| Projects to build and deploy | A JSON API and a separate client app, often with different hosts and CORS rules | One ASP.NET Core app that also builds your frontend |
| Routing | A server router for the API and a client router for pages, kept in sync by hand | ASP.NET Core routes only. Each route renders a page |
| Data loading | Fetch calls, loading states and caching in each component | Props arrive with the page. Slow data can be [deferred](/docs/deferred-props) |
| Authentication | Tokens or cookies shared across two apps, plus client-side route guards | Your normal ASP.NET Core cookie auth and `[Authorize]` |
| Validation | Return an error shape from the API and map it to forms in the client | Redirect back with errors, available as `form.errors` ([validation](/docs/validation-and-errors)) |
| API contract | You design, version and document JSON endpoints | The props you pass are the contract. [TypeScript types](/docs/typescript) can be generated |
| Frontend | React, Vue or Svelte | React, Vue or Svelte |
You still write components, hooks and styles. What goes away is the glue: the API layer, the client router, and the code that keeps state in sync with the server.
## Why a .NET developer might like it
- **Controllers, Minimal APIs and FastEndpoints stay in charge.** Model binding, authorization, validation attributes and dependency injection work as they always have.
- **One deployment.** `dotnet publish` produces the server and the built frontend assets together.
- **Security defaults you already know.** CSRF protection, cookies and policies are ASP.NET Core's, with [antiforgery wired up](/docs/csrf-and-antiforgery) for you.
- **Pages are real URLs.** The first visit is a server-rendered HTML response, so links, refresh, back and bookmarks behave. Optional [server-side rendering](/docs/server-side-rendering) renders the first visit's HTML on the server.
## When it is a good fit
Inertia suits applications where the frontend and backend ship together: dashboards, admin tools, SaaS products, internal apps, content sites with accounts. If one team owns both halves, it removes a lot of duplicated work.
## When it is not
- **You need a public API anyway.** Mobile apps, third-party integrations or several different clients need real JSON endpoints. You can still build those next to your Inertia pages, but Inertia will not replace them.
- **The frontend is a separate product.** If another team deploys the frontend on its own schedule, a versioned API is the cleaner boundary.
- **Heavy offline use.** Inertia expects the server for every page change.
- **You want no JavaScript at all.** For server-rendered HTML with a little interactivity, Razor Pages or Blazor may be simpler.
## What it is not
Inertia is not a framework and not a new frontend library. It is a small protocol between your server and the official [Inertia.js](https://inertiajs.com) client. Inertia.Net is the ASP.NET Core side of it. You keep using React, Vue or Svelte exactly as documented.
## Try it
- [First 10 minutes](/docs/first-10-minutes) creates an app from the starter template and walks through a validated form.
- [Quick start](/docs/quick-start) adds Inertia.Net to an existing ASP.NET Core app.
- [Introduction](/docs/introduction) lists what the adapter provides.
---
# Installation
URL: https://inertia-dotnet.temtem.dev/docs/installation
> Install Inertia.Net, the optional MVC, FastEndpoints or SignalR package, and the testing helpers.
Inertia.Net is split into small packages. Install the core package and one optional backend integration.
| Package | Install when |
|---|---|
| `Inertia.Net` | always. The core adapter, Minimal API support, Vite, SSR, state store and antiforgery |
| `Inertia.Net.Mvc` | you use MVC controllers: `ModelState` filter, Razor root view and tag helpers |
| `Inertia.Net.FastEndpoints` | you use FastEndpoints: `Send` extensions, validation pre-processor and Precognition |
| `Inertia.Net.Live.SignalR` | you deliver [live props](/docs/live-props) over SignalR: hub, channel authorization and broadcaster |
| `Inertia.Net.EntityFrameworkCore` | you page EF Core queries with numbered or cursor pagination |
| `Inertia.Net.Testing` | in your test project: fluent assertions over Inertia responses |
| `Inertia.Net.Templates` | `dotnet new install Inertia.Net.Templates`: start a complete app |
```sh
dotnet add package Inertia.Net --version 1.7.0
dotnet add package Inertia.Net.Mvc --version 1.7.0 # MVC only
dotnet add package Inertia.Net.FastEndpoints --version 1.7.0 # FastEndpoints only
dotnet add package Inertia.Net.EntityFrameworkCore --version 1.7.0 # EF Core pagination
dotnet add package Inertia.Net.Testing --version 1.7.0 # test projects
dotnet add package Inertia.Net.Live.SignalR --version 1.7.0 # live props over SignalR
```
Use the same version for every Inertia.Net package. The package version and the Inertia.js protocol version are separate numbers: Inertia.Net 1.7.0 implements Inertia.js v3.
## Requirements
- .NET 10 SDK and an `Microsoft.NET.Sdk.Web` project.
- JavaScript frontend build tools with npm, Bun, pnpm or Yarn. Managed SSR requires Node.js or Bun at runtime.
- An Inertia.js v3 client: `@inertiajs/react`, `@inertiajs/vue3` or `@inertiajs/svelte`.
## Start from the template
```sh
dotnet new install Inertia.Net.Templates::1.7.0
dotnet new inertia -n MyApp --framework react
cd MyApp
```
Choose `--framework vue` or `--framework svelte` for another client. The template includes working pages, a Todos form, validation, flash messages, CSRF protection and Vite configuration.
Install dependencies, then start Vite using the package manager chosen for your app. All four are supported:
| Package manager | Install dependencies | Start Vite | Build assets |
|---|---|---|---|
| npm | `npm install` | `npm run dev` | `npm run build` |
| Bun | `bun install` | `bun run dev` | `bun run build` |
| pnpm | `pnpm install` | `pnpm run dev` | `pnpm run build` |
| Yarn | `yarn install` | `yarn run dev` | `yarn run build` |
Keep Vite running and start ASP.NET Core in another terminal with `dotnet watch`. For production, run your row's build command,
then `dotnet publish -c Release`. Keep the lockfile for the package manager you choose.
## Namespace and global usings
The namespace is `Inertia.Net` and the static factory class is `Inertia.Net.Inertia`. C# resolves a leading `Inertia` to the namespace, so `Inertia.Render(...)` does not compile outside the `Inertia.Net` namespace.
The package's `buildTransitive` props file adds both global usings when `enable` is set, which is the default in new projects:
```csharp
global using Inertia.Net;
global using static Inertia.Net.Inertia;
```
Write `Render(...)`, `Defer(...)`, `Once(...)`, `Back()` and `Location(...)` unqualified.
Set `false` and add `using static Inertia.Net.Inertia;` yourself, or call `Inertia.Net.Inertia.Render(...)`. `HttpContext.Inertia()` is an extension method and works as written.
## Next steps
Add the four pieces every Inertia app needs: services, middleware, a root template and a client.
Install React, Vue or Svelte and configure Vite for development and production.
---
# First 10 minutes
URL: https://inertia-dotnet.temtem.dev/docs/first-10-minutes
> Start a template app, try its validated form, and test the redirect.
Create the React starter (choose `vue` or `svelte` for another client):
```sh
dotnet new install Inertia.Net.Templates::1.7.0
dotnet new inertia -n MyApp --framework react
cd MyApp
npm install
npm run dev
```
Use `bun install` and `bun run dev`, `pnpm install` and `pnpm run dev`, or `yarn install` and `yarn run dev` if you prefer. In a second terminal run `dotnet run`. Open the address printed by `dotnet run` (`http://localhost:5180` for the template) and visit `/todos`; submit an empty title to see the validation message. The template includes Vite, antiforgery and a complete form.
Vite prints `http://localhost:5173` when it starts, but that server only supplies JavaScript, CSS and hot reload to your app. It has no pages, so opening it shows a blank page. Your app is the ASP.NET Core address from `dotnet run`: `http://localhost:5180`.
The handler in `Program.cs` is:
```csharp
app.MapPost("/todos", (HttpContext http, TodoStore todos, NewTodo input) =>
{
todos.Add(input.Title!);
http.Inertia().Flash("success", "Todo added");
return Results.Redirect("/todos");
}).WithInertiaValidation();
```
`NewTodo.Title` has `[Required, StringLength(100)]`; invalid requests redirect with errors before the handler runs. The React template's `ClientApp/pages/Todos/Index.tsx` uses `useForm` to submit and display the error:
```tsx
import { useForm } from '@inertiajs/react'
import type { FormEvent } from 'react'
// Inside the page component:
const form = useForm({ title: '' })
const submit = (e: FormEvent) => {
e.preventDefault()
form.post('/todos', { onSuccess: () => form.reset() })
}
// Inside its return:
```
To test the round trip, add `Inertia.Net.Testing` and `Microsoft.AspNetCore.Mvc.Testing` to a test project, expose `public partial class Program;` in the app, and create a cookie-preserving `WebApplicationFactory` client with `AllowAutoRedirect = false`:
```csharp
using System.Net;
using System.Net.Http.Json;
using Inertia.Net.Testing;
client.DefaultRequestHeaders.Accept.ParseAdd("text/html");
using var page = await client.GetAsync("/todos");
using var request = new HttpRequestMessage(HttpMethod.Post, "/todos")
{
Content = JsonContent.Create(new { title = "" }),
};
request.Headers.Add("X-Inertia", "true");
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
request.Headers.Referrer = new Uri(client.BaseAddress!, "/todos");
request.WithXsrfToken(page);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
using var redirected = await client.GetAsync("/todos");
await redirected.AssertInertiaAsync(p => p.HasErrors().HasError("title"));
```
See [Testing](/docs/testing) for named error bags and custom XSRF names, and [Quick start](/docs/quick-start) for an existing application.
---
# Client setup
URL: https://inertia-dotnet.temtem.dev/docs/client-setup
> Configure Vite and the React, Vue or Svelte client for an Inertia.Net app.
Inertia.Net speaks the Inertia.js v3 protocol. Install a v3 client package; v2 clients are not supported.
## Install the client
Run client setup commands from the ASP.NET Core project directory (the one containing your `.csproj`). npm, Bun, pnpm and
Yarn are supported. The framework snippets use npm spelling; replace the command with the equivalent for your package manager:
| Package manager | Add packages | Add development packages |
|---|---|---|
| npm | `npm install ` | `npm install -D ` |
| Bun | `bun add ` | `bun add -D ` |
| pnpm | `pnpm add ` | `pnpm add -D ` |
| Yarn | `yarn add ` | `yarn add -D ` |
Replace `` with the names and version ranges from your framework's snippet. If the project has no `package.json`,
create one first with `npm init -y`, `bun init -y`, `pnpm init` or `yarn init`.
```sh
npm install react react-dom @vitejs/plugin-react @inertiajs/react@^3
npm install -D vite typescript @types/node @types/react @types/react-dom
```
```sh
npm install vue @vitejs/plugin-vue @inertiajs/vue3@^3
npm install -D vite typescript @types/node
```
```sh
npm install svelte @sveltejs/vite-plugin-svelte @inertiajs/svelte@^3
npm install -D vite typescript @types/node
```
`@types/node` types the `node:fs` import and `process` in the Vite config. Add `vite/client` to `tsconfig.json` so `import.meta.glob` is typed:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client", "node"]
},
"include": ["resources/js", "vite.config.ts"]
}
```
Use `"jsx": "preserve"` or omit it for Vue and Svelte.
The starter templates use `ClientApp/pages`. The manual examples below use `resources/js/Pages`. Keep the resolver, file paths and Vite entry consistent with the layout you choose.
The templates also ship `ClientApp/lib/inertia-signalr.ts`, the client transport for [live props over SignalR](/docs/live-props#signalr). Uncomment `live: signalr()` in `ClientApp/app.tsx` (or `app.ts`) to turn it on.
## Vite configuration
The same Vite config works for every client; only the framework plugin changes.
```ts
// vite.config.ts
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react' // Vue: @vitejs/plugin-vue, Svelte: @sveltejs/vite-plugin-svelte
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
// Writes the dev server URL to wwwroot/hot. Inertia.Net reads it in Development
// and renders the dev-server tags instead of the manifest tags.
function hotFile(): Plugin {
const file = 'wwwroot/hot'
return {
name: 'inertia-net-hot-file',
apply: 'serve',
configureServer(server) {
server.httpServer?.once('listening', () => {
const address = server.httpServer!.address()
if (address && typeof address === 'object') {
mkdirSync('wwwroot', { recursive: true })
writeFileSync(file, `http://localhost:${address.port}`)
}
})
const clean = () => rmSync(file, { force: true })
process.on('exit', clean)
process.on('SIGINT', () => process.exit())
process.on('SIGTERM', () => process.exit())
},
}
}
export default defineConfig(({ command }) => ({
plugins: [react(), hotFile()],
// Built assets live at /build/. The dev server serves from the root because
// Inertia.Net emits {origin}/@vite/client and {origin}/{entry}.
base: command === 'build' ? '/build/' : '/',
server: { port: 5173, strictPort: true, origin: 'http://localhost:5173' },
build: {
outDir: 'wwwroot/build',
emptyOutDir: true,
manifest: true,
rollupOptions: { input: 'resources/js/app.tsx' },
},
}))
```
Add these fields to `package.json`:
```json
{ "type": "module", "scripts": { "dev": "vite", "build": "vite build" } }
```
The manifest key of the entry (`resources/js/app.tsx`) is what you pass to `@vite("...")`.
## Client entry
The optional `@inertiajs/vite` plugin resolves pages and mounts the app for you. With it, the entry is just:
```ts
import { createInertiaApp } from '@inertiajs/react' // or @inertiajs/vue3, @inertiajs/svelte
createInertiaApp()
```
Without the plugin, provide the resolver and setup yourself.
```tsx
// resources/js/app.tsx
import { createInertiaApp, type ResolvedComponent } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob<{ default: ResolvedComponent }>('./Pages/**/*.tsx', { eager: true })
return pages[`./Pages/${name}.tsx`]
},
setup({ el, App, props }) {
if (!el) throw new Error('Inertia root element not found')
createRoot(el).render()
},
})
```
```ts
// resources/js/app.ts
import { createApp, h, type DefineComponent } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob<{ default: DefineComponent }>('./Pages/**/*.vue', { eager: true })
return pages[`./Pages/${name}.vue`]
},
setup({ el, App, props, plugin }) {
if (!el) throw new Error('Inertia root element not found')
createApp({ render: () => h(App, props) }).use(plugin).mount(el)
},
})
```
```ts
// resources/js/app.ts
import { mount } from 'svelte'
import { createInertiaApp, type ResolvedComponent } from '@inertiajs/svelte'
createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob('./Pages/**/*.svelte', { eager: true })
return pages[`./Pages/${name}.svelte`]
},
setup({ el, App, props }) {
if (!el) throw new Error('Inertia root element not found')
mount(App, { target: el, props })
},
})
```
## Pages
A page file receives the props from `Render(...)` as normal component props.
```tsx
// resources/js/Pages/Users/Index.tsx
import { Link } from '@inertiajs/react'
export default function Index({ users }: { users: { id: number; name: string }[] }) {
return (
{users.map((u) => (
{u.name}
))}
)
}
```
```vue
{{ u.name }}
```
```svelte
{#each users as u (u.id)}
{u.name}
{/each}
```
Inertia.Net can write TypeScript declarations for your page props, shared props and flash data. See [TypeScript types](/docs/typescript).
## Development workflow
```sh
npm run dev # or bun/pnpm/yarn run dev; Vite writes wwwroot/hot
dotnet watch # terminal 2: the app, ASPNETCORE_ENVIRONMENT=Development
```
Open the ASP.NET Core URL, not the Vite URL. Two servers run side by side:
| URL | What it is | Open it? |
|---|---|---|
| `http://localhost:5180` (the template's default) | Your app. ASP.NET Core routes the request, runs your endpoint and renders the Inertia page. | Yes |
| `http://localhost:5173` | The Vite dev server. It only serves JavaScript, CSS and hot-reload updates to the page above. | No. It has no pages, so it shows a blank page. |
Vite prints `localhost:5173` prominently when it starts, but that address is for the browser's asset requests, not for you. In Development the app reads `wwwroot/hot` and renders the Vite tags; in production it reads the manifest.
For production:
```sh
npm run build # or bun/pnpm/yarn run build
dotnet publish -c Release
```
Node is needed to build the assets and only needed at runtime when SSR is enabled.
## Using @inertiajs/vite
The plugin also contributes the SSR dev endpoint and page resolution. Add it before the hot-file plugin:
```ts
import inertia from '@inertiajs/vite'
export default defineConfig({
plugins: [
react(),
inertia({ ssr: { entry: 'resources/js/ssr.tsx', host: '127.0.0.1', port: 13714 } }),
hotFile(),
],
})
```
Keep the SSR listener on loopback: the renderer trusts its output and has no authentication. The [SSR guide](/docs/server-side-rendering) covers the full setup.
---
# Quick start
URL: https://inertia-dotnet.temtem.dev/docs/quick-start
> Add Inertia.Net to an ASP.NET Core app, create the root template and render your first page.
Already using the starter template? Its services, middleware, root template and frontend are configured for you. These steps are for adding Inertia to an existing app or building one manually.
Every app needs the same four things:
## 1. Register the services
Choose one backend style. All three use the same protocol middleware and the same `Render(...)` result.
```csharp
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation(); // .NET 10 source-generated validation
builder.Services.AddAntiforgery(o => o.HeaderName = InertiaHeaders.XsrfToken);
builder.Services.AddInertia(o =>
{
o.Share("appName", "Demo");
o.Pages.Directory = "ClientApp/pages";
});
var app = builder.Build();
app.UseStaticFiles(); // serves wwwroot/build (Vite output)
app.UseInertia(); // before the endpoints
app.UseInertiaAntiforgery(); // XSRF-TOKEN cookie; 419 for unsafe requests without it
app.MapGet("/", () => Render("Home", new InertiaProps
{
["greeting"] = "Hello from .NET",
}));
app.Run();
```
```csharp
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInertia();
builder.Services.AddControllersWithViews().AddInertiaMvc(); // add .UseRazorRootView() here if you want Razor
builder.Services.AddAntiforgery(o => o.HeaderName = InertiaHeaders.XsrfToken);
var app = builder.Build();
app.UseStaticFiles();
app.UseInertia();
app.UseInertiaAntiforgery();
app.MapControllers();
app.Run();
```
```csharp
using Microsoft.AspNetCore.Mvc;
public sealed class HomeController : Controller
{
[HttpGet("/")]
public IResult Index() => Render("Home", new InertiaProps
{
["greeting"] = "Hello from .NET",
});
}
```
```csharp
// Program.cs
using FastEndpoints;
using Inertia.Net.FastEndpoints;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInertia();
builder.Services.AddFastEndpoints();
builder.Services.AddAntiforgery(o => o.HeaderName = InertiaHeaders.XsrfToken);
var app = builder.Build();
app.UseStaticFiles();
app.UseInertia();
app.UseInertiaAntiforgery();
app.UseFastEndpoints(c => c.ConfigureInertia()); // validation redirect + Precognition
app.Run();
```
```csharp
using FastEndpoints;
using Inertia.Net.FastEndpoints;
public sealed class HomeEndpoint : EndpointWithoutRequest
{
public override void Configure() => Get("/");
public override Task ExecuteAsync(CancellationToken ct) =>
Task.FromResult(Render("Home", new InertiaProps { ["greeting"] = "Hello from .NET" }));
}
```
`AddValidation()` enables the Minimal API integration used by `.WithInertiaValidation()`. `UseInertiaAntiforgery()` issues the `XSRF-TOKEN` cookie and validates every unsafe request, so forms work from the first commit.
See [Precognition](/docs/precognition) for live form validation.
## 2. Add the root template
The root template is an HTML file in the content root. The default name is `app.html`.
```html
My app
@inertiaHead
@viteReactRefresh
@vite("ClientApp/app.tsx")
@inertia
```
Remove `@viteReactRefresh` for Vue and Svelte. `@inertia` is required. The other tokens are described in [Pages and responses](/docs/pages-and-responses#the-root-template).
Make sure `app.html` is copied to the output and publish directory:
```xml
```
## 3. Create the page component
The page name passed to `Render` must match the client resolver. With `@inertiajs/vite` the resolver maps `ClientApp/pages/**` automatically.
```tsx
// ClientApp/pages/Home.tsx
export default function Home({ greeting }: { greeting: string }) {
return
{greeting}
}
```
The client entry only needs to start Inertia:
```tsx
// ClientApp/app.tsx
import { createInertiaApp } from '@inertiajs/react'
createInertiaApp()
```
Pass your own `resolve` and `setup` functions to `createInertiaApp`. The [client setup](/docs/client-setup) page shows both forms for React, Vue and Svelte.
## 4. Run it
Install dependencies and start Vite from the project directory with your package manager:
| Package manager | Install dependencies | Start Vite | Build assets |
|---|---|---|---|
| npm | `npm install` | `npm run dev` | `npm run build` |
| Bun | `bun install` | `bun run dev` | `bun run build` |
| pnpm | `pnpm install` | `pnpm run dev` | `pnpm run build` |
| Yarn | `yarn install` | `yarn run dev` | `yarn run build` |
Keep Vite running in terminal 1. Start ASP.NET Core in terminal 2:
```sh
dotnet watch # Development environment
```
Open the URL printed by `dotnet watch` (not the Vite URL). The first visit to `/` should display “Hello from .NET”.
For production, run the build command from your row, then publish the app:
```sh
dotnet publish -c Release
```
Deploy `wwwroot/build` and `app.html` with the published app.
## Where to go next
Vite configuration and the React, Vue and Svelte entries.Lazy, deferred, merged and cached page data.Redirects, error bags and Precognition.Render the first visit on Node.
---
# AI agents
URL: https://inertia-dotnet.temtem.dev/docs/ai-agents
> Give coding agents the context and conventions they need to build Inertia.Net pages correctly.
An Inertia.Net app is a good fit for coding agents. The backend and the frontend live in one project, a feature follows one request path (endpoint, props, page component), and there is no API contract or client router for the agent to keep in sync. The main thing an agent needs is to know the conventions. This page gives you that context in a form you can paste into your project.
## Point the agent at the docs
The documentation is published as plain text for language models:
| URL | Contents |
|---|---|
| [`/llms.txt`](/llms.txt) | An index of every page with a one-line summary, following the [llms.txt](https://llmstxt.org) format. |
| [`/llms-full.txt`](/llms-full.txt) | The whole documentation in one Markdown file. |
Agents that can fetch URLs can read these directly. For agents that cannot, download `llms-full.txt` into your repository (for example as `docs/inertia-net.md`) and reference it from your agent instructions.
## Add an AGENTS.md section
Most coding agents read a project instruction file: `AGENTS.md` for Codex and many others, `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot and `.cursor/rules` for Cursor. Copy this section into yours and adjust the paths and framework to match your app.
````md
## Inertia.Net
This app uses Inertia.Net (Inertia.js v3 for ASP.NET Core). Docs: https://inertia-dotnet.temtem.dev/llms.txt
- Pages are rendered from the server. An endpoint returns `Render("Users/Index", props)`;
the component lives in `ClientApp/pages/Users/Index.tsx`. Names must match exactly, including case.
- Do not add JSON API endpoints or fetch calls for page data. Pass data as props instead.
Use Inertia ``, `router` and `useForm` for navigation and forms, not a client-side router.
- `Render`, `Back`, `Location`, `Defer`, `Optional`, `Always`, `Merge` and `Once` are global usings
from `Inertia.Net.Inertia`. Write them unqualified.
- Props: use `Defer(ct => ...)` for slow data, `Optional(() => ...)` for data loaded only on
partial reloads, `Merge(...)` for paginated lists and `Once(...)` for data the client can keep.
- Forms: POST/PUT/PATCH/DELETE endpoints validate with `.WithInertiaValidation()` (Minimal API),
the MVC filter or `c.ConfigureInertia()` (FastEndpoints), then redirect. On success return a redirect
or `Back()`. Report custom errors with `Back().WithErrors(...)`. Use `Back().WithFlash(...)` for messages.
- Keep antiforgery on: `app.UseInertiaAntiforgery()` is required for unsafe requests.
- Use typed page props (a record per page) for new pages and register them with
`o.TypeScript.Page("Component")` so the TypeScript types stay generated, not hand written.
- Test pages with `Inertia.Net.Testing`: `client.InertiaGetAsync(url)` then
`response.AssertInertiaAsync(page => page.Component(...).Has(...))`.
- Before finishing: `dotnet build`, `dotnet test`, and the client build script (`npm run build`, `bun run build`, `pnpm run build` or `yarn run build`) (type-checks the client).
````
If you use Vue or Svelte, change the component path and extension.
## Asking for a feature
Agents do better with a request that names both ends of the feature. For example:
> Add a `/projects` page. The endpoint returns `Projects/Index` with a paginated `projects` prop using `Merge` and the `InfiniteScroll` component, and a deferred `stats` prop. Add a create form that posts to `/projects`, validates `CreateProjectInput` with `.WithInertiaValidation()`, and redirects back with a `toast` flash message. Add a test with `Inertia.Net.Testing` for the page component and props.
A request like this tells the agent which [props](/docs/props) to use, how [validation](/docs/validation-and-errors) is wired, and how to check the result with [testing helpers](/docs/testing).
## Let the agent check its own work
Agents are most reliable when they can verify a change. Inertia.Net gives them a few cheap checks:
- **Compile-time types.** With [generated TypeScript types](/docs/typescript), a prop renamed in C# breaks the client build, so the client build script (`npm run build`, `bun run build`, `pnpm run build` or `yarn run build`) catches mismatches.
- **Response tests.** `Inertia.Net.Testing` asserts the component name and props without a browser. Ask the agent to add one test per page.
- **Missing components.** In Development, [DevTools checks](/docs/devtools) report a `Render` call whose page component does not exist.
`dotnet new inertia -n MyApp --framework react` creates an app with pages, a validated form, flash messages, CSRF and Vite already set up. Starting an agent from the template gives it working examples of every convention above.
---
# Pages and responses
URL: https://inertia-dotnet.temtem.dev/docs/pages-and-responses
> Render components, use MapInertia, and understand the root template and response status codes.
An Inertia page is a component name plus a set of props. The server writes the page object into the response; the client resolves the component and renders it.
## Rendering a page
`Render(component, props)` returns an `InertiaResult`. The component name must match your client-side page resolver exactly, including case.
```csharp
app.MapGet("/users", () => Render("Users/Index", new InertiaProps
{
["users"] = db.Users.ToList(),
}));
```
`Render` accepts an `InertiaProps` (an ordered string-keyed dictionary), any `IDictionary`, or a typed object whose properties are the props. Nested `InertiaProps` and dictionaries are walked at any depth.
```csharp
public sealed record UsersPage(List Users, string Title);
app.MapGet("/users", () => Render("Users/Index", new UsersPage(users, "Users")));
```
Every page has an `errors` prop (an empty object when there are no errors). Key order is `errors`, shared props, then page props.
## Per-response options
`InertiaResult` builds on the response without changing the component:
```csharp
return Render("Admin/Home")
.WithRootView("admin.html")
.WithViewData("title", "Admin");
return Render("Errors/NotFound").WithStatusCode(404);
```
| Method | Effect |
|---|---|
| `.WithStatusCode(int)` | Sets the HTTP status of the page response |
| `.WithRootView(string)` | Uses another root template for this response |
| `.WithViewData(key, value)` | Makes a value available to `@viewData("key")` |
| `.WithSsr(SsrMode)` / `.WithoutSsr()` | Overrides the SSR mode for this response |
| `.WithSsrCache(duration, staleFor?)` | Reuses a server-rendered response for the same page JSON |
## Pages without a handler
`MapInertia` maps GET and HEAD to a rendered page, like Laravel's `Route::inertia`:
```csharp
app.MapInertia("/about", "About", new InertiaProps
{
["team"] = team,
});
```
## The root template
On the first visit the response is an HTML document. Inertia.Net reads `app.html` from the content root (configurable with `RootView`), parses it once, and replaces these tokens:
| Token | Output |
|---|---|
| `@inertia` | Without SSR: the page JSON script and the root element. With SSR: the server-rendered body. **Required.** |
| `@inertiaHead` | The SSR `head` tags joined with newlines. Empty without SSR. |
| `@vite("resources/js/app.tsx")` | Vite tags for one or more comma-separated entries. |
| `@viteReactRefresh` | The React Fast Refresh preamble while the dev server runs. Put it before `@vite`. |
| `@viewData("title")` | An HTML-encoded value passed with `WithViewData`. |
| `@@` | A literal `@`. |
Other `@` characters, such as CSS at-rules and e-mail addresses, are left alone. Arguments accept single or double quotes.
The page JSON is escaped with `\uXXXX` sequences, so a prop containing `` cannot break out of the script element.
```html
My app
@inertiaHead
@viteReactRefresh
@vite("ClientApp/app.tsx")
@inertia
```
`RootElementId` (default `app`) changes both the root element id and the `data-page` value. The client finds the page script by that id.
`app.html` is read from disk at runtime. Add `CopyToOutputDirectory` and `CopyToPublishDirectory` so it is present after `dotnet publish`.
### Razor root view (MVC)
With `Inertia.Net.Mvc`, the first-visit HTML can be a Razor view:
```csharp
builder.Services.AddControllersWithViews().AddInertiaMvc(o => o.UseRazorRootView("App"));
```
`"App"` finds `Views/Shared/App.cshtml`. The view receives an `InertiaRootViewContext` model and uses tag helpers:
```cshtml
@model InertiaRootViewContext
@ViewData["title"]
```
```cshtml
@* Views/_ViewImports.cshtml *@
@addTagHelper *, Inertia.Net.Mvc
```
`` takes comma-separated entries. SSR runs at most once per request and is shared by `` and ``. `WithRootView("Other")` takes Razor view names when the Razor root view is enabled.
### A custom root view
Implement `IInertiaRootView` and register it before `AddInertia`:
```csharp
builder.Services.AddSingleton();
builder.Services.AddInertia();
public sealed class MyRootView : IInertiaRootView
{
public async ValueTask RenderAsync(InertiaRootViewContext context)
{
var writer = context.HttpContext.Response.BodyWriter;
writer.Write(""u8);
context.WriteBody(writer); // or context.GetBodyHtml()
writer.Write(""u8);
await writer.FlushAsync(context.HttpContext.RequestAborted);
}
}
```
## Page URL and path base
The page object's `url` is the request PathBase, Path and QueryString. Behind a proxy that rewrites paths, set `o.UrlResolver` to return the URL the browser sees:
```csharp
builder.Services.AddInertia(o => o.UrlResolver = ctx => ctx.Request.GetEncodedUrl());
```
`Back()` and the Vite asset URLs also respect the path base.
---
# Props
URL: https://inertia-dotnet.temtem.dev/docs/props
> Lazy, optional, deferred, merged, once, cached and rescued page data.
Props are the data a page receives. `Render(component, props)` takes an `InertiaProps`, a string-keyed dictionary, or a typed object. Nested `InertiaProps` and dictionaries are walked, so props can be nested at any depth.
Every page has an `errors` prop. Its key order is `errors`, shared props, then page props.
## The prop factories
| C\# | Behavior |
|---|---|
| `["title"] = "Hi"` | A plain value. Sent on every response that includes the key. |
| `Prop(() => ...)` | Lazy: the loader runs only when the prop is part of the response. |
| `Always(value)` or `Always(() => ...)` | Sent even when a partial reload does not ask for it. |
| `Optional(() => ...)` | Never sent on the first visit, only when a partial reload asks for it. |
| `Defer(() => ..., group: "name")` | Left out of the first response. The client loads it right away with its group. |
| `Merge(value)` | The client appends the new value to its current one. |
| `DeepMerge(value)` | The client deep merges the value. |
| `Once(() => ...)` | Resolved once and remembered by the client. |
| `Scroll(page, wrapper: "data", metadata)` | An infinite-scroll prop with paging metadata. |
Every lazy factory (`Prop`, `Always`, `Optional`, `Defer`, `Merge`, `DeepMerge`, `Once`, `Scroll`) accepts five loader shapes:
```csharp
Prop(() => value) // Func
Prop(() => repo.GetAsync()) // Func>
Prop(() => repo.GetValueTaskAsync()) // Func>
Prop(ct => repo.GetAsync(ct)) // Func>
Prop(ct => repo.GetValueTaskAsync(ct)) // Func>
```
The token is `HttpContext.RequestAborted`. An `async` lambda always binds to the awaiting overload, never to “a prop whose value is a Task”.
Fluent methods mutate and return the same instance: `.Once(key?, until?)`, `.As(key)`, `.Until(ttl)`, `.Fresh()`, `.Append(path?, matchOn?)`, `.Prepend(path?, matchOn?)`, `.DeepMerge()`, `.MatchOn(params keys)`, `.Defer(group)`, `.WithGroup(group)`, `.Rescue()`, `.Parallel()`, `.Cache(key, duration)` and `.Cache(key, duration, staleFor?, tags?)`.
`Fresh()` makes a prop a once prop, and `WithGroup()` makes it deferred. Repeated `MatchOn()` calls add keys. Root `Append()` and `Prepend()` are mutually exclusive and combining them throws. Fluent methods mutate the prop, so create prop instances per request; do not share them across requests.
## Resolution order
Loaders run sequentially by default, in prop order, and only when their prop is included in the response. That makes a scoped EF Core `DbContext` safe to use in several loaders.
```csharp
return Render("Users/Index", new InertiaProps
{
["title"] = "Users",
["users"] = Prop(ct => db.Users.ToListAsync(ct)),
["filters"] = Always(new { search = "ann" }),
});
```
```json
{
"component": "Users/Index",
"props": { "errors": {}, "title": "Users", "users": [ { "id": 1, "name": "Ann" } ], "filters": { "search": "ann" } },
"url": "/users?search=ann",
"version": "3f9a1c0d5b7e2a64c8d1e0f9b3a57c21",
"sharedProps": ["errors"]
}
```
The page is buffered before it is sent, so a loader that throws never produces a half-written `200`.
### Parallel props
Mark independent async loaders with `.Parallel()` to start them together:
```csharp
return Render("Dashboard", new InertiaProps
{
["stats"] = Prop(ct => statsService.GetAsync(ct)).Parallel(),
["activity"] = Defer(ct => activityService.RecentAsync(ct)).Parallel(),
["user"] = currentUser, // values and sync loaders resolve in turn
});
```
- Only `Task` and `ValueTask` loaders can be parallel. `Parallel()` throws for values and synchronous loaders.
- At most `InertiaOptions.Props.MaxConcurrency` loaders run at once per render (default 4).
- Top-level props and props inside literal objects (`InertiaProps` and other string-keyed dictionaries) start early. Elsewhere they resolve in turn.
- Loaders of props that are not part of the response never start.
- If a loader fails, the others are cancelled and awaited, and the render fails with the first failure in prop order. A `Rescue()`d prop fails alone.
A parallel loader must not use the request's `DbContext` or other non-thread-safe scoped services, and must not touch `HttpContext`. Give each loader its own context through `IDbContextFactory` or its own scope.
```csharp
["orders"] = Prop(async ct =>
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Orders.CountAsync(ct);
}).Parallel(),
```
## Deferred props
`Defer` leaves the prop out of the first response and lists it under `deferredProps`. The client asks for each group as soon as the page is mounted.
```csharp
["stats"] = Defer(ct => stats.GetAsync(ct)), // group "default"
["feed"] = Defer(ct => feed.GetAsync(ct), group: "sidebar"),
```
```json
{ "props": { "errors": {} }, "deferredProps": { "default": ["stats"], "sidebar": ["feed"] } }
```
See [Deferred props](/docs/deferred-props) for the client component and the request rules.
## Merge props
```csharp
["posts"] = Merge(posts), // append
["messages"] = Prop(() => messages).Prepend(), // prepend
["settings"] = DeepMerge(settings),
["rows"] = Merge(rows).MatchOn("id"), // replace items with the same id
["chat"] = Merge(chat).Append("messages", matchOn: "id"), // append at a nested path
```
```json
{
"mergeProps": ["posts", "chat.messages"],
"prependProps": ["messages"],
"deepMergeProps": ["settings"],
"matchPropsOn": ["rows.id", "chat.messages.id"]
}
```
The full behavior is in [Merging props](/docs/merging-props).
## Once props
```csharp
["plans"] = Once(ct => plans.AllAsync(ct)).Until(TimeSpan.FromHours(1)).As("billing-plans"),
```
```json
{
"props": { "errors": {}, "plans": [ { "id": "pro" } ] },
"onceProps": { "billing-plans": { "prop": "plans", "expiresAt": 1760000000000 } }
}
```
When the client already holds the key it sends `X-Inertia-Except-Once-Props`; the value is left out while the `onceProps` entry stays. See [Once props](/docs/once-props).
## Cached props
`.Cache(key, duration)` keeps a prop's value as JSON in the cache (the app's `IMemoryCache` by default). A hit costs neither the query nor serialization.
```csharp
return Render("Dashboard", new InertiaProps
{
["stats"] = Prop(ct => stats.ComputeAsync(ct)).Cache("dashboard-stats", TimeSpan.FromMinutes(5)),
["orders"] = Defer(ct => orders.RecentAsync(userId, ct)).Cache($"orders:{userId}", TimeSpan.FromMinutes(1), tags: [$"user:{userId}"]),
["catalog"] = Optional(() => catalog.All()).Cache("catalog", TimeSpan.FromHours(1), staleFor: TimeSpan.FromHours(23)),
});
```
Every request that uses a key gets the same value. Put the user, tenant or anything else the value depends on in the key. Keys live in their own cache space and cannot collide with the app's own entries.
Only loading changes: optional, deferred, merged, once and scroll props keep their behavior, and their metadata is written from the cache too. A parallel cached prop does not start its loader on a hit. A rescued failure is not cached.
**Removing and replacing values.** Inject `IInertiaCache` where the data changes:
```csharp
app.MapPost("/orders", async (OrderInput input, IInertiaCache cache, CancellationToken ct) =>
{
var order = await orders.CreateAsync(input, ct);
await cache.RemoveByTagAsync($"user:{order.UserId}", ct);
await cache.RemovePropAsync("dashboard-stats", ct);
await cache.SetPropAsync("catalog", await catalog.AllAsync(ct), TimeSpan.FromHours(1), cancellationToken: ct);
return Back();
});
```
`ClearPropsAsync()` removes every cached prop and `ClearSsrAsync()` every cached render. `SetPropAsync` serializes the value as a prop of its type would, and takes the same `staleFor` and `tags`.
**Stale-while-revalidate.** `staleFor` keeps serving a value after its duration. The first request that reads a stale value gets it at once, and the loader runs after the response has been sent. Past `duration + staleFor` the loader runs during the request again.
- Response completion enqueues refreshes on a bounded background worker; it does not wait for the loader. Loaders using `staleFor` must use singleton dependencies or create an independent scope inside the loader with `IServiceScopeFactory.CreateAsyncScope()` or `IDbContextFactory`. Do not capture request-scoped services or `HttpContext`.
- The worker accepts 64 pending refreshes and cancels on host shutdown. A full queue drops the refresh and logs a warning, leaving the stale value at its existing expiration. Monitor `inertia.cache.refresh.dropped`, tagged with `inertia.cache.kind=prop|ssr`.
- One refresh runs per key and app instance at a time. A refresh that finds the value fresh again skips its loader.
- A refresh that fails is logged as a warning and keeps the stale value; the next stale read tries again.
**Another store.** Props and SSR renders live in an `IInertiaCacheStore`. Register your own singleton before or after `AddInertia()`:
```csharp
builder.Services.AddSingleton();
```
The store gets and sets byte arrays by `InertiaCacheKey`, removes by key and by tag, and reads unknown formats as a miss. While a page renders, a store that throws is logged (at most once every 30 seconds) and counts as a miss; `IInertiaCache` lets the exception through.
## Typed page props
A typed object is AOT-friendly: it is walked through its `JsonTypeInfo`, and members of type `InertiaProp` are resolved by Inertia.Net while the rest is serialized normally.
```csharp
public sealed class UsersPage
{
public required IReadOnlyList Users { get; init; }
public required InertiaProp Stats { get; init; } // resolved by Inertia.Net
}
app.MapGet("/users", (AppDb db) => Render("Users/Index", new UsersPage
{
Users = db.Users.Select(u => new UserDto(u.Id, u.Name)).ToList(),
Stats = Defer(ct => db.StatsAsync(ct)),
}));
```
Register `UsersPage` and its value types in your `JsonSerializerContext` under Native AOT. Anonymous types work only when reflection-based serialization is enabled, so not under AOT.
## Dot keys
Cached props under dot keys use the cache too. A top-level key containing dots is unpacked. `["user.name"] = "Ann"` becomes `"user": { "name": "Ann" }`.
## BigInteger safety
With `o.PreserveBigIntegers = true`, integers beyond ±(2^53 − 1) are written as `{"$bigint":"9007199254740993"}` and the page gets `"preserveBigIntegers": true`.
```csharp
builder.Services.AddInertia(o => o.PreserveBigIntegers = true);
```
## Rescue
`.Rescue()` keeps a page alive when one prop fails:
```csharp
["recommendations"] = Defer(ct => recs.GetAsync(ct)).Rescue(),
```
If the loader throws, the exception is logged, the prop is left out of `props`, and its path goes to `"rescuedProps": ["recommendations"]`. Without `.Rescue()` the exception propagates normally. Cancellation of the request is never rescued.
---
# Shared data
URL: https://inertia-dotnet.temtem.dev/docs/shared-data
> Share props with every page, statically or per request, and control the shared prop key list.
Shared props are merged into every page. A page prop with the same key wins.
## Register shared values
```csharp
builder.Services.AddInertia(o =>
{
o.Share("appName", "Demo"); // static value
o.Share("auth", ctx => new AuthInfo(ctx.User.Identity?.Name)); // per request
});
```
Share values from anywhere with an `HttpContext`, such as middleware or a handler:
```csharp
app.Use((context, next) =>
{
context.Inertia().Share("locale", CultureInfo.CurrentUICulture.Name);
return next(context);
});
```
The value can be any prop, including another factory:
```csharp
o.Share("permissions", ctx => Once(() => LoadPermissions(ctx)));
o.Share("plans", Once(() => plans.All())); // like Laravel's shareOnce
```
`plans` is resolved once and kept by the client across visits.
## The shared prop key list
The top-level shared keys are listed in the page object so the client can keep them across navigations:
```json
{
"props": { "errors": {}, "appName": "Demo", "auth": { "name": "Ada" } },
"sharedProps": ["errors", "appName", "auth"]
}
```
`errors` is always shared, so the list always contains at least `"errors"`. Turn the list off with:
```csharp
builder.Services.AddInertia(o => o.ExposeSharedPropKeys = false);
```
## When to use shared props
Use shared props for values that every page needs and that are cheap to compute:
- the signed-in user or tenant name
- the application name and locale
- feature flags
- a once-per-client value such as a plan catalogue
Keep large or page-specific data in page props. Shared props are considered on every page response, including partial reloads, so keep them cheap or wrap an expensive value in `Once`.
Wrapping a shared value in `Once` is the equivalent of Laravel's `shareOnce`. The client keeps the value across visits and sends `X-Inertia-Except-Once-Props`; the loader does not run again until the value expires.
## TypeScript
The generated declarations infer shared props from the values passed to `o.Share(...)`. Factories and per-request shares are typed as `unknown`; use `o.TypeScript.SharedProps()` when you want an explicit shape. See [TypeScript types](/docs/typescript).
---
# Validation
URL: https://inertia-dotnet.temtem.dev/docs/validation-and-errors
> Redirect back with validation errors for Minimal APIs, MVC and FastEndpoints, and add Precognition.
Inertia forms post, expect a redirect, and read validation errors from `props.errors` on the next page. Inertia.Net performs the redirect and carries the errors across it.
- A field maps to its **first** message. With `o.WithAllErrors = true` it maps to an array of all messages (the client's `form.withAllErrors()` expects that).
- Error keys use dotted indices, for example `items.0.email`. Names use JSON member aliases when metadata exposes them and the applicable naming policy for other members. Dictionary keys are preserved. Minimal API and MVC use the page serializer’s HTTP JSON options; FastEndpoints uses its binding serializer policy. Custom resolvers that omit member metadata and paths without a model schema may fall back to naming-policy conversion; align field names and serializer settings.
- **Error bags.** The client sends `X-Inertia-Error-Bag: createUser` when the form has `errorBag="createUser"`. Errors arrive as `errors: { createUser: { email: "..." } }`. Without the header, `errors` is the flat object `{ email: "..." }`. A bag stored under a name other than `default` is always nested by bag name.
```ts
router.post('/users', data, { errorBag: 'createUser' })
```
## Validation libraries
| Backend | Validation |
|---|---|
| Minimal API | .NET 10 source-generated DataAnnotations through `AddValidation()`; `ValidationProblem` results; call FluentValidation `IValidator` manually and return `WithErrors` |
| MVC | MVC model validation and `ModelState` |
| FastEndpoints | FastEndpoints validators, including FluentValidation |
## Minimal API
`WithInertiaValidation()` applies to a route or route group and requires `services.AddValidation()`. Without validation resolvers, it throws `InvalidOperationException` when the endpoint is constructed. Routing may construct endpoints lazily, so this need not happen during application startup.
```csharp
builder.Services.AddValidation();
app.MapPost("/users", (CreateUserInput input, AppDb db) =>
{
db.Add(input);
return Results.Redirect("/users");
}).WithInertiaValidation();
app.MapGroup("/admin").WithInertiaValidation()
.MapPost("/roles", (RoleInput input) => Results.Redirect("/admin/roles"));
```
For an Inertia request that is not a GET, a failure becomes a redirect back (303 for PUT/PATCH/DELETE) with the errors. Every other client still gets the normal `400` validation problem. A handler that returns `TypedResults.ValidationProblem(...)`, `Results.ValidationProblem(...)` or an `HttpValidationProblemDetails` is converted the same way.
The built-in .NET 10 validation filter is added automatically and always runs outermost: it writes the 400 itself before any filter you add can see it. `WithInertiaValidation()` disables it on the endpoint and runs the same source-generated validation itself. Do not combine it with your own `.DisableValidation()`.
To report errors yourself, without attribute validation:
```csharp
app.MapPost("/login", (LoginInput input, SignInService auth) =>
auth.TryLogin(input)
? Results.Redirect("/")
: Back().WithErrors(new Dictionary
{
["email"] = "These credentials do not match.",
}));
```
`Back().WithErrors(IDictionary errors, string bag = "default")` also takes arrays of messages. FluentValidation is not automatically integrated into Minimal API `AddValidation()`. Call a registered `IValidator` yourself and map its failures:
```csharp
app.MapPost("/users", async (CreateUserInput input, IValidator validator) =>
{
var result = await validator.ValidateAsync(input);
if (!result.IsValid)
{
var errors = result.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return Back().WithErrors(errors);
}
return Results.Redirect("/users");
});
```
Import `FluentValidation` and register your validator. Supply client JSON field names in the dictionary when they differ from its property names.
## MVC
`AddInertiaMvc()` registers a global filter. An Inertia non-GET request with an invalid `ModelState` is redirected back with the errors before the action runs, in the bag from `X-Inertia-Error-Bag` (default `default`). `[ApiController]` controllers work too: their automatic 400 is replaced by the same redirect for Inertia requests.
```csharp
[HttpPost("/users")]
public IActionResult Store([FromBody] CreateUserInput input)
{
if (!ModelState.IsValid) return ValidationProblem(ModelState); // non-Inertia clients only
db.Add(input);
return Redirect("/users");
}
```
Binding failures get the generic message `The value is invalid.`, because their exception messages can expose internals.
## FastEndpoints
`c.ConfigureInertia()` adds a global pre-processor. A validation failure of an Inertia non-GET request becomes a redirect back with the errors; other requests keep FastEndpoints' 400 JSON. FluentValidation validators work as usual.
```csharp
public sealed class CreateUserValidator : Validator
{
public CreateUserValidator() => RuleFor(x => x.Email).NotEmpty().EmailAddress();
}
app.UseFastEndpoints(c => c.ConfigureInertia(ep => ep.AllowAnonymous()));
```
`c.ConfigureInertia()` composes with an existing `c.Endpoints.Configurator`. Its optional callback runs after Inertia setup. The pre-processor runs after an endpoint's own pre-processors, so one that already answered (a 403, say) wins.
## Precognition (live validation)
[Precognition](/docs/precognition) validates a request without running the handler. Minimal APIs use `.WithInertiaPrecognition()` instead of `.WithInertiaValidation()`; FastEndpoints enables it with `ConfigureInertia()`. MVC does not support it.
## Error bags in the client
```tsx
const form = useForm({ name: '', email: '' })
```
## All errors for a field
```csharp
builder.Services.AddInertia(o => o.WithAllErrors = true);
```
With the option on, `errors.email` is an array of every message instead of the first.
## State and redirects
Validation errors travel in the state store across the redirect. By default that is an encrypted cookie; it can be a session or MVC TempData instead. See [Flash data](/docs/flash-data) for the store choices and size limits.
---
# Flash data
URL: https://inertia-dotnet.temtem.dev/docs/flash-data
> Carry a one-time message, toast or typed value across a redirect.
Flash data is carried across the redirect (and any further redirects) and consumed by the next response that is not a redirect. It appears as `page.flash`, only when there is some.
```csharp
app.MapPost("/users", (CreateUserInput input, HttpContext context) =>
{
context.Inertia().Flash("toast", new { type = "success", message = "Saved" });
return Results.Redirect("/users");
});
// or on Back():
return Back().WithFlash("toast", "Saved");
```
```json
{
"component": "Users/Index",
"props": { "errors": {} },
"flash": { "toast": { "type": "success", "message": "Saved" } }
}
```
Read it with `usePage().flash` or the `onFlash` visit callback:
```tsx
const { flash } = usePage()
{flash.toast && }
```
The TypeScript generator can write a `Flash` type for you. Call `o.TypeScript.FlashData()`; see [TypeScript types](/docs/typescript).
Inertia forms keep their input on the client after `Back().WithErrors(...)`; they do not need a `WithInput()` helper. For a full-page form submission, choose only safe fields to flash before redirecting:
```csharp
return Back().WithFlash("old", new InertiaProps { ["email"] = email });
```
On the next page, read `usePage().flash.old` and use the email to refill the field. Never flash passwords, tokens or other secrets.
## Where state is kept
Flash data, errors, `clearHistory` and `preserveFragment` set during a request that ends in a redirect (or a `Location()` / fragment `409`) are saved in an `IInertiaStateStore` and loaded at the start of the next request. If that request redirects again, the state is carried on, so `Back()` to a URL that itself redirects still shows the flash. Otherwise its response consumes it. The state is also kept when the response is the version-mismatch `409`.
### Encrypted cookie (default)
The default store is an encrypted cookie named `.Inertia.State`. It is protected with ASP.NET Core Data Protection, `HttpOnly`, `SameSite=Lax`, and works without sessions and under Native AOT. Tampered or undecryptable cookies are ignored and deleted. It logs a warning above about 4 KB because browsers drop larger cookies.
### Session store
```csharp
builder.Services.AddSession();
builder.Services.AddInertia(o => o.State.UseSession());
app.UseSession(); // before UseInertia
app.UseInertia();
```
Use the session store when flash data can be large or must not reach the browser, and you already run sessions.
### MVC TempData store
```csharp
builder.Services.AddInertiaMvc(o => o.UseTempDataStateStore());
```
This keeps the state in MVC TempData, so it follows your TempData provider: the default cookie provider, or `AddSessionStateTempDataProvider()` with `AddSession()` and `app.UseSession()` before `app.UseInertia()`. It works for Minimal API endpoints too, because the store saves TempData itself. Not Native AOT compatible.
### Your own store
Implement `IInertiaStateStore` and register it before `AddInertia`:
```csharp
public interface IInertiaStateStore
{
ValueTask LoadAsync(HttpContext httpContext);
ValueTask SaveAsync(HttpContext httpContext, byte[] state);
ValueTask ClearAsync(HttpContext httpContext);
}
```
The methods move opaque bytes; the format is Inertia.Net's.
## Choosing a store
| Store | Use when |
|---|---|
| Encrypted cookie | Default. Stateless, AOT safe, about 4 KB limit. |
| Session | Flash data can be large or must not reach the browser, and sessions already exist. |
| TempData | The app standardizes on MVC TempData, or you want to switch providers without changing code. |
| Custom | You need a shared store across instances or a different transport. |
In a multi-instance deployment, configure Data Protection with a shared key ring. Otherwise the cookie written by one instance cannot be read by another.
## History flags
`encryptHistory` is written only when set and is not carried across redirects. `clearHistory` and `preserveFragment` go through the state store and apply to the next page rendered after the redirects, then are consumed.
```csharp
app.MapPost("/logout", (HttpContext context) =>
{
context.Inertia().ClearHistory();
return Results.Redirect("/login");
});
```
See [Redirects and history](/docs/redirects-and-history).
---
# Redirects and history
URL: https://inertia-dotnet.temtem.dev/docs/redirects-and-history
> Redirects, Back, Location, history encryption, clearing and URL fragments.
Inertia requests follow redirects with XHR, which is not always what the browser would do. Inertia.Net adjusts redirect responses so the client behaves correctly.
## Redirect helpers
```csharp
return Results.Redirect("/users"); // standard redirect
return Back(); // Referer, else "~/" (app root, PathBase included)
return Back("/users"); // Referer, else "/users"
return Location("https://billing.example.com"); // leave the SPA
```
`Back()` follows a `Referer` only when it is an http(s) URL with the request's own host and port. When it is unavailable, the opt-in [previous URL](#previous-url) is tried before the fallback. This prevents an open redirect. The scheme is not compared, so it works behind a TLS-terminating proxy; on HTTPS an `http` Referer is upgraded to `https`.
## Previous URL
If a form returns `Back()` but the browser does not send its previous address (`Referer`), enable this option. For example, it helps when your site uses `Referrer-Policy: no-referrer`:
```csharp
builder.Services.AddInertia(o => o.State.RememberPreviousUrl = true);
```
After a successful page visit, a later `Back()` can use that saved address. `Back()` and the middleware's empty-response redirect consult the same-host `Referer` first, then the remembered URL, then the fallback. `context.Inertia().PreviousUrl` reads the remembered URL, or returns `null` when disabled, absent or invalid; it is user-controlled input validated as a relative path.
Only successful status-200 page GET renders are remembered, including initial HTML visits. Partial reloads, prefetches, Precognition, non-GET requests and error pages are excluded. The value is the relative `PathBase + Path + QueryString`, without scheme or host, in the plain `.Inertia.Previous` session cookie (`HttpOnly`, `SameSite=Lax`, `Path=/`, essential, with `Secure` following `o.State.CookieSecurePolicy`). The cookie is written only when the URL changes. Set `o.State.PreviousUrlCookieName` to change its name; it must differ from `o.State.CookieName`.
This cookie is used with both cookie and session state stores. It is **per browser, not per tab**: the most recent page render wins across tabs. The `Referer` still takes precedence when available.
## What the middleware changes
| Situation | Result |
|---|---|
| 302 for PUT, PATCH or DELETE | Changed to **303 See Other**, so the browser follows with a GET |
| Redirect to another origin, non-prefetch | `409` with `X-Inertia-Location`: the client makes a full visit instead of an XHR the browser would block |
| Redirect whose `Location` contains `#`, non-prefetch | `409` with `X-Inertia-Redirect`: the client performs a full visit that keeps the fragment |
| `Location(url)` | `409` + `X-Inertia-Location` for Inertia requests, a plain `302` otherwise |
| Empty `200` response | Redirect back (302, or 303 for PUT/PATCH/DELETE), using the same previous-URL and app-root fallback as `Back()` |
| `Vary` | `Vary: X-Inertia` is added to every response through the middleware |
Turn off external redirect conversion with `o.ConvertExternalRedirects = false` if the app intentionally redirects to another origin.
## Pages without a handler
```csharp
app.MapInertia("/about", "About", props);
```
Maps GET and HEAD to `Render("About", props)`, like Laravel's `Route::inertia`.
In MVC, `return this.InertiaView(props);` renders the action's own component, `{Controller}/{Action}` by default. `AddInertiaMvc(o => o.ComponentResolver = ctx => ...)` changes the rule.
## History encryption
```csharp
builder.Services.AddInertia(o => o.EncryptHistory = true); // every page
context.Inertia().EncryptHistory(); // this response
context.Inertia().EncryptHistory(false); // opt out for this response
```
`encryptHistory` is written as `true` only when set and is not carried across redirects. The Inertia client encrypts its history state with the page's key.
## Clearing history
```csharp
app.MapPost("/logout", (HttpContext context) =>
{
context.Inertia().ClearHistory();
return Results.Redirect("/login");
});
```
`clearHistory` goes through the state store, applies to the next page rendered after the redirect(s), then is consumed. It is typically used before a redirect after logout.
## Preserving a fragment
```csharp
context.Inertia().PreserveFragment();
```
By default a redirect loses the URL fragment. `PreserveFragment()` keeps it across the next redirect. The client receives `preserveFragment: true` on the next page.
## Page URL
The page object's `url` is PathBase, Path and QueryString. Behind a proxy that rewrites paths, set `o.UrlResolver` to return the URL the browser sees:
```csharp
o.UrlResolver = ctx => ctx.Request.GetEncodedUrl();
```
Flash data, errors, `clearHistory` and `preserveFragment` share the state store. By default that is an encrypted cookie; see [Flash data](/docs/flash-data#where-state-is-kept).
---
# Error pages
URL: https://inertia-dotnet.temtem.dev/docs/error-pages
> Render routing failures and unhandled exceptions as Inertia pages, with shared props and the original status.
When someone visits a missing page or the server encounters an error, you can show a page from your app instead of a blank response. Add this setup in `Program.cs` for production:
```csharp
builder.Services.AddInertia();
builder.Services.AddInertiaErrorPages(o =>
{
o.Component = "Error"; // default
o.Components[404] = "Errors/NotFound"; // optional per-status component
o.StatusCodes = [403, 404, 500, 503]; // default
o.Props = (context, status, exception) => new InertiaProps
{
["requestId"] = context.TraceIdentifier,
};
});
var app = builder.Build();
app.UseInertiaErrorPages(); // before UseInertia and the endpoints
app.UseInertia();
```
A second Inertia UI mounted under its own path registers its own components with `AddInertiaErrorPages(name, ...)`; see
[Multiple apps and libraries](/docs/mounts).
Create the named components in your client pages directory. Each receives `status`, the optional `Props` callback's values, and
shared props registered globally or through `context.Inertia().Share(...)`. The callback receives the exception for exception
responses and `null` for status-code pages. Avoid returning exception messages or stack traces to the client.
For example, `ClientApp/pages/Error.tsx`:
```tsx
export default function Error({ status }: { status: number }) {
const title = status === 404 ? 'Page not found'
: status === 403 ? 'Access denied'
: status === 503 ? 'Service unavailable'
: 'Something went wrong'
return
{status}: {title}
Return home
}
```
The handler answers requests with `X-Inertia` or an `Accept: text/html` navigation. It preserves the error status: XHR receives a
JSON page with `X-Inertia: true`; the first visit receives the normal root template. Root views and SSR work as for any other
render. An error component must also resolve in your SSR bundle when SSR is enabled.
Only configured error statuses with an empty response are replaced. API clients, existing response bodies, redirects, Inertia's
`409` responses and antiforgery's `419` responses are left to their existing handlers. Exceptions use the status selected by
ASP.NET Core's exception middleware (500 by default). The default statuses are 403, 404, 500 and 503; extend `StatusCodes` to handle
other error statuses. A page explicitly returned with `Render(...).WithStatusCode(404)` keeps its component and props.
In Development, error pages are disabled so the developer exception page and Inertia debug modal stay available. Set
`o.EnabledInDevelopment = true` to preview your error components. The Inertia client renders non-2xx Inertia responses by default;
application `httpException` event handlers or `onHttpException` callbacks can cancel that rendering.
## Already using `UseExceptionHandler()`
`AddInertiaErrorPages()` registers an `IExceptionHandler` in the existing chain. Register
API-specific handlers first if they should take priority. Use one exception middleware, then install just the status-code part:
```csharp
app.UseExceptionHandler();
app.UseInertiaErrorPages(useExceptionHandler: false);
app.UseInertia();
```
ASP.NET Core 10 suppresses exception diagnostics by default when an `IExceptionHandler` handles the exception. The Inertia
handler follows that policy and emits only a Debug message naming the rendered component. To keep the built-in exception logs
and diagnostics, configure the exception middleware explicitly:
```csharp
builder.Services.Configure(o =>
o.SuppressDiagnosticsCallback = _ => false);
```
`AddInertiaErrorPages()` also registers the built-in ProblemDetails service as the exception middleware’s fallback. The Inertia
exception handler declines requests that do not ask for an Inertia page or HTML, so API exceptions use the host’s ProblemDetails
fallback and content negotiation. Existing ProblemDetails configuration and earlier application handlers still apply; empty
status-code responses remain unchanged. Loaded flash and validation state is consumed when an error page renders, as on any other page response.
## Validation, SSR and rate limits
Validation redirects keep their existing behavior. A required SSR failure can be handled by the exception pipeline; configure the error page so it can render without relying on the same failed renderer. Rate-limit responses with an existing body are preserved; configure the rejection response explicitly, including the Precognition header for validation requests. See [Precognition](/docs/precognition#rate-limiting).
---
# Partial reloads
URL: https://inertia-dotnet.temtem.dev/docs/partial-reloads
> Reload only the props a page needs with only and except filters.
The Inertia client can reload the current page and ask for a subset of its props. This is how deferred props, polling and infinite scroll work, and you can use it directly for dashboard refresh buttons and filters. To refresh props when something changes on the server, without polling, use [live props](/docs/live-props).
## From the client
```tsx
import { router } from '@inertiajs/react'
router.reload({ only: ['users'] })
router.reload({ except: ['permissions'] })
```
`router.visit('/users', { only: ['users'] })` works for a new page too. The client sends:
| Header | Meaning |
|---|---|
| `X-Inertia-Partial-Component` | The component being rendered |
| `X-Inertia-Partial-Data` | The `only` list |
| `X-Inertia-Partial-Except` | The `except` list |
Filtering applies only when the partial component equals the component being rendered.
## Server behavior
```csharp
["users"] = Prop(ct => db.Users.ToListAsync(ct)), // runs on the partial reload above
["roles"] = Prop(ct => db.Roles.ToListAsync(ct)), // does not run
```
- Paths are dot paths and match on a `.` boundary. `only: ["user"]` includes `user.name`; `only: ["user.name"]` includes `user.name` and keeps `user` as a container.
- `except` beats `only`. `Always(...)` ignores both.
- The children of a value produced by a loader are not filtered. Nested literal `InertiaProps` are.
- Loaders of excluded props never run.
- Selecting an array descendant (`rows.1.extra`) reloads the complete array, including sibling fields and nested loaders. A descendant `except` excludes the entire array; `Always` still wins.
- Omitted or rescued direct array elements retain their positions as `null`.
- A missing or empty list header means “no filter”, which differs from an empty list.
`Always(...)` is sent even when a partial reload does not ask for it. Use it for props the page cannot render without; do not use it for expensive data.
## What the client keeps
A reloaded prop replaces the client's value by default. A prop resolved from a loader is sent in full. To combine values instead, use [merge props](/docs/merging-props).
## Deferred and once props
Deferred props are loaded through partial reloads. `X-Inertia-Partial-Data` asks for the deferred group, and the server records which deferred props it has sent.
Once props that the reload asks for always resolve. The client sends `X-Inertia-Except-Once-Props` with the keys it already holds, so the server can skip them while keeping the `onceProps` metadata.
## Testing partial reloads
`Inertia.Net.Testing` replays the request with the right headers:
```csharp
var page = await (await client.InertiaGetAsync("/dashboard")).GetInertiaPageAsync();
var only = await page.ReloadOnlyAsync(client, "users");
var except = await page.ReloadExceptAsync(client, "permissions");
var deferred = await page.LoadDeferredPropsAsync(client, "sidebar");
```
`ReloadOnlyAsync` asserts that the requested prop is present; `ReloadExceptAsync` asserts that it is missing.
---
# Deferred props
URL: https://inertia-dotnet.temtem.dev/docs/deferred-props
> Load slow page sections after the first render, in groups.
`Defer` leaves a prop out of the first response and lists it under `deferredProps`. The client requests each group with a partial reload as soon as the page is mounted. The page becomes interactive without waiting for the slow data. A deferred prop can also be [live](/docs/live-props): it is fetched again whenever its event is broadcast.
```csharp
return Render("Dashboard", new InertiaProps
{
["user"] = user,
["stats"] = Defer(ct => reports.StatsAsync(ct)),
["chart"] = Defer(ct => reports.ChartAsync(ct), group: "charts"),
["activity"] = Defer(ct => reports.ActivityAsync(ct), group: "charts"),
});
```
```json
{
"props": { "errors": {}, "user": { "name": "Ada" } },
"deferredProps": {
"default": ["stats"],
"charts": ["chart", "activity"]
}
}
```
Props in the same group load in one request. Different groups load in parallel requests. `deferredProps` is only produced on non-partial renders.
## The client
```tsx
import { Deferred } from '@inertiajs/react'
}>
```
Vue uses `` from `@inertiajs/vue3`; Svelte uses the matching component from `@inertiajs/svelte`.
## Combining with other props
A deferred prop can also be merged, once or rescued:
```csharp
["posts"] = Defer(ct => db.Posts.Page(1).ToListAsync(ct)).Merge(),
["plans"] = Defer(ct => plans.AllAsync(ct)).Once(),
["recommendations"] = Defer(ct => recs.GetAsync(ct)).Rescue(),
```
The metadata combines: `deferredProps` lists the group, `mergeProps` lists the path, and `onceProps` records the key.
## Deferred scroll props
A deferred `Scroll(...)` prop reports `"posts.data"` just like a resolved one. This is a deliberate fix of an inertia-laravel quirk:
```csharp
["posts"] = Scroll(page, wrapper: "data").Defer(),
```
## Testing
```csharp
var page = await (await client.InertiaGetAsync("/dashboard")).GetInertiaPageAsync();
new AssertableInertia(page).HasDeferred("sidebar", "stats").Missing("stats");
var loaded = await page.LoadDeferredPropsAsync(client, "sidebar");
new AssertableInertia(loaded).Has("stats");
```
See [Testing](/docs/testing) for the full assertion API.
Deferred props are for data the page can render without. If the page cannot be meaningful until the data arrives, a normal prop is simpler.
---
# Merging props
URL: https://inertia-dotnet.temtem.dev/docs/merging-props
> Append, prepend, deep merge and match items instead of replacing the client's value.
By default a reloaded prop replaces the client's value. Merge props tell the client to combine them. This is how pagination, chat messages and incremental lists work.
| C\# | Metadata | Client behavior |
|---|---|---|
| `Merge(v)` | `mergeProps: ["key"]` | Append to the existing array |
| `Prop(...).Prepend()` | `prependProps: ["key"]` | Prepend |
| `Merge(v).Append("path")` | `mergeProps: ["key.path"]` | Append at a nested path |
| `Prop(...).Prepend("path")` | `prependProps: ["key.path"]` | Prepend at a nested path |
| `DeepMerge(v)` | `deepMergeProps: ["key"]` | Deep merge objects and arrays |
| `.MatchOn("id")` | `matchPropsOn: ["key.id"]` | Replace items with the same `id` instead of duplicating them |
| `.Append("path", matchOn: "id")` | `matchPropsOn: ["key.path.id"]` | The match key is relative to the nested path |
```csharp
["posts"] = Merge(ct => db.Posts.Page(page).ToListAsync(ct)).MatchOn("id"),
```
```json
{
"mergeProps": ["posts"],
"matchPropsOn": ["posts.id"]
}
```
## Nested paths
```csharp
["chat"] = Merge(chat).Append("messages", matchOn: "id"),
["notifications"] = Prop(() => next).Prepend("items"),
["settings"] = DeepMerge(settings),
```
```json
{
"mergeProps": ["chat.messages", "notifications.items"],
"deepMergeProps": ["settings"],
"matchPropsOn": ["chat.messages.id"]
}
```
The path before the wrapper stays on the client; only the value below the path is merged.
## Resetting a merge
The client can ask for a fresh value instead of a merge by listing the path in `X-Inertia-Reset`:
```tsx
router.reload({ only: ['posts'], reset: ['posts'] })
```
Metadata is skipped for paths in the reset list, and on partial reloads for paths the reload did not ask for.
## Match on a stable key
`.MatchOn("id")` replaces an item with the same key instead of appending a duplicate. Use a stable, unique key from your data. Without it, incremental updates append every item again.
```csharp
["messages"] = Merge(page.Items).MatchOn("id"),
```
## Deep merge
`DeepMerge` recursively combines objects. Arrays are merged by index; use `MatchOn` when you need item identity.
```csharp
["filters"] = DeepMerge(new { search, tags }),
```
On a partial reload, merge metadata is only emitted for paths the reload asked for. `Always` still applies independently.
---
# Once props
URL: https://inertia-dotnet.temtem.dev/docs/once-props
> Remember expensive values on the client and skip them on later visits.
Once props are expensive values that rarely change. The client remembers them across visits and tells the server which keys it holds with `X-Inertia-Except-Once-Props`.
```csharp
["countries"] = Once(ct => db.CountriesAsync(ct)), // key = the prop path
["plans"] = Once(ct => db.PlansAsync(ct)).As("billing-plans"), // custom key
["rates"] = Once(ct => fx.RatesAsync(ct)).Until(TimeSpan.FromMinutes(10)), // client expires it
["tax"] = Once(ct => db.TaxAsync(ct)).Fresh(), // always re-send
["flags"] = Prop(ct => db.FlagsAsync(ct)).Once(key: "flags", until: TimeSpan.FromHours(1)),
```
```json
{
"props": { "errors": {}, "countries": [ { "code": "US" } ] },
"onceProps": {
"countries": { "prop": "countries", "expiresAt": null },
"billing-plans": { "prop": "plans", "expiresAt": 1760000000000 }
}
}
```
## Behavior
- The value is left out on later visits, but the `onceProps` entry stays so the client keeps its copy.
- `.Fresh()` sends the value even when the client has it.
- Partial reloads always resolve once props that the reload asks for.
- Expiry is computed from the registered `TimeProvider`, so tests can use `FakeTimeProvider`.
- `.As(key)` and `.Until(ttl)` imply once.
- Sharing a key between pages lets the client reuse one value across them.
## Keys
The default key is the prop path. Use `.As("billing-plans")` when the same value is exposed under different names, or when you want a stable key across page changes.
```csharp
["plans"] = Once(ct => plans.AllAsync(ct)).As("billing-plans"),
```
Two props that share a key are treated as the same client value. Only share a key when the values are actually the same. When in doubt, use the default prop path.
## Expiry
`.Until(TimeSpan)` sets `expiresAt` in milliseconds since the Unix epoch. The client stops treating the key as loaded after that time and requests the value again. The server leaves the value out only when the client lists the key in `X-Inertia-Except-Once-Props`.
## Shared once props
```csharp
o.Share("plans", Once(() => plans.All()));
```
This is the equivalent of Laravel's `shareOnce`: the client keeps the value across visits.
## Testing
`InertiaPage.OnceProps` exposes the metadata:
```csharp
var page = await (await client.InertiaGetAsync("/billing")).GetInertiaPageAsync();
Assert.True(page.OnceProps.ContainsKey("billing-plans"));
```
Revisit the page with the same client and assert that the prop is missing while `onceProps` remains.
---
# Infinite scroll
URL: https://inertia-dotnet.temtem.dev/docs/infinite-scroll
> Merge pages of items and report paging metadata to the InfiniteScroll client component.
Use infinite scroll when a reader should see more items as they reach the end of a list. `Scroll(...)` merges the items inside a value (the *wrapper*, `"data"` by default) and reports paging metadata to the `` client component.
```csharp
app.MapGet("/feed", (PageRequest request) => Render("Feed", new InertiaProps
{
["posts"] = Scroll(Pagination.Paginate(AppState.Posts, request)),
}));
```
```json
{
"props": { "errors": {}, "posts": { "data": [ { "id": 21 } ] } },
"mergeProps": ["posts.data"],
"scrollProps": {
"posts": { "pageName": "page", "previousPage": 1, "nextPage": 3, "currentPage": 2, "reset": false }
}
}
```
See [Pagination](/docs/pagination) for numbered and cursor pages, query binding and data-source delegates.
## The client
```tsx
import { InfiniteScroll } from '@inertiajs/react'
{posts.data.map((post) => )}
```
## Metadata
`ScrollMetadata(PageName, PreviousPage, NextPage, CurrentPage)` is a record. Page values can be `int`, `long`, `string` or `null` (cursor paging uses strings).
Metadata comes from the `metadata:` argument, or from the value if it implements `IProvidesScrollMetadata`. Without either, the render throws with a message naming the prop.
```csharp
["posts"] = Scroll(page, wrapper: "data", metadata: ScrollMetadata.FromPage(2, hasMore: true)),
```
The client sends `X-Inertia-Infinite-Scroll-Merge-Intent: prepend` when scrolling up. The wrapper is then reported under `prependProps` instead of `mergeProps`.
`scrollProps[path].reset` is true when the request lists the path in `X-Inertia-Reset`.
## Combining with other props
`Scroll(...)` accepts the five loader shapes, and `.Defer()`, `.Once()` and `.Rescue()` combine with it:
```csharp
["posts"] = Scroll(ct => feed.PageAsync(1, ct)).Defer(),
```
A deferred scroll prop reports `"posts.data"` just like a resolved one. This is a deliberate fix of an inertia-laravel quirk.
Infinite scroll is also a payload-size tool. Load 10 to 20 items per page instead of sending an unbounded list on the first request.
---
# Pagination
URL: https://inertia-dotnet.temtem.dev/docs/pagination
> Create numbered and cursor pages for pagers and infinite scroll.
A long list is easier to browse when the server sends a small group of items at a time. A numbered page lets readers choose a page; a cursor page uses a marker to fetch the next or previous group. Start with a list already in memory:
```csharp
builder.Services.AddInertia();
var app = builder.Build();
app.UseInertia();
// posts is an IReadOnlyList in a stable order.
app.MapGet("/posts", (PageRequest request) => Render("Posts/Index", new InertiaProps
{
["posts"] = Pagination.Paginate(posts, request),
}));
```
Visiting `/posts?page=2&perPage=5` returns the second group in `posts.data`. The default page size is 20 and the maximum is 100; configure both in [Pagination options](/docs/configuration#opagination-paginationoptions). For infinite scroll, wrap that value in `Scroll(...)` and render it with `InfiniteScroll` as shown in [Infinite scroll](/docs/infinite-scroll).
`Pagination` creates page values; `Scroll` adds infinite-scroll metadata and merging. Use a page directly for a classic pager.
| You write | You get |
|---|---|
| `Pagination.Paginate(posts, request)` | A numbered `Page` for a pager |
| `Scroll(Pagination.Paginate(posts, request))` | Numbered infinite scroll |
| `Scroll(await Pagination.PaginateAsync(request, key, fetch, ct: ct))` | Cursor-based infinite scroll using your data source |
| `Scroll(ct => Pagination.PaginateAsync(request, fetch, ct: ct)).Defer()` | A deferred page; other prop modifiers also work |
Minimal API handlers can take `PageRequest` or `CursorRequest` directly, as in [Infinite scroll](/docs/infinite-scroll). MVC and FastEndpoints can call `PageRequest.From(Request.Query)` and `CursorRequest.From(HttpContext)`. Page numbers below 1 become 1; invalid page sizes use the default and sizes above the maximum are clamped. Set defaults and parameter names with `o.Pagination` in `AddInertia`. `From` and `BindAsync` retain the query parameter name for the returned page. An explicit paginator name overrides it.
`Pagination.Paginate` slices an already materialized `IReadOnlyList`. For a database, search SDK or HTTP API, `Pagination.PaginateAsync` accepts a `fetch(skip, take, ct)` delegate and an optional count delegate. It requests one extra item to determine `hasMore`; without a count, `total` and `lastPage` are null. Delegate offsets above `int.MaxValue` return an empty page without calling `fetch`; the optional count still runs. Supply a stable, fully unique ordering to your data source. For a classic pager, use `currentPage`, `lastPage`, and `pageName` to build links; pages do not contain URL links.
```json
{ "data": [ { "id": 21 } ], "currentPage": 2, "perPage": 20, "total": 95, "lastPage": 5, "from": 21, "to": 40, "hasMore": true, "pageName": "page" }
```
`from` and `to` are one-based and null on an empty page. The examples abbreviate the data array.
For example, a data source can implement numbered pages without exposing `IQueryable`:
```csharp
var page = await Pagination.PaginateAsync(
request,
(skip, take, ct) => store.FetchPostsAsync(skip, take, ct),
ct => store.CountPostsAsync(ct),
ct: cancellationToken);
```
For a React pager, use Inertia links and preserve any other filters your app needs:
```tsx
import { Link } from '@inertiajs/react'
{page.lastPage != null && Array.from({ length: page.lastPage }, (_, i) => i + 1).map((n) => (
{n}
))}
```
## Cursors
A cursor is a marker for the position of an item, useful when readers move forward and backward rather than jump to page numbers. The cursor overload of `PaginateAsync` takes a key selector and a fetch delegate. The delegate receives a decoded cursor with `Direction` (`After` or `Before`) and `Value`, a take count, and a cancellation token. Return rows in the requested direction, including up to one extra; the helper trims and reverses `Before` results to display order. Choose a stable unique non-null key. `hasMore` indicates more rows in the requested direction.
```json
{ "data": [ { "id": 21 } ], "perPage": 20, "nextCursor": "eyJ...", "previousCursor": null, "hasMore": true, "cursorName": "cursor" }
```
Cursors are URL-safe base64 of a compact JSON array: direction (`"a"` or `"b"`) followed by the key values. Supported keys are `int`, `long`, `Guid`, `string`, `DateTime`, `DateTimeOffset`, `decimal`, `double`, and `bool`. Cursors are **not tamper-proof**; apps that need tamper protection should wrap them with `IDataProtector`.
A malformed or foreign nonempty cursor falls back to the first page. When `CursorRequest.Logger` is set, the paginator logs this at Debug without cursor values or exception details. Minimal API binding uses configured pagination options and supplies a logger in the `Inertia.Net.Pagination` category when a logger factory is available. In MVC or FastEndpoints, `CursorRequest.From(HttpContext)` does the same; optional arguments override the configured defaults and parameter names. For a query-only request or a request created outside HTTP, supply an app logger explicitly:
```csharp
var request = CursorRequest.From(query) with { Logger = logger };
```
`Logger` is ignored by JSON serialization and does not affect request equality.
## Defer or cache a page
A page can use the same prop modifiers as other values. For example, `Scroll(ct => Pagination.PaginateAsync(request, fetch, ct: ct)).Defer()` loads it after the first response; a lazy prop can also use `.Cache(...)`. See [Deferred props](/docs/deferred-props) and [Props](/docs/props) for loader and cache options.
## With EF Core
If your app uses EF Core, install the optional package alongside your database provider:
```sh
dotnet add package Inertia.Net.EntityFrameworkCore --version 1.7.0
```
The following uses an existing `AppDb` as `db`, with a `Posts` set. `request` is a `PageRequest`, `cursorRequest` is a `CursorRequest`, and `ct` is the request cancellation token:
```csharp
using Inertia.Net;
using Microsoft.EntityFrameworkCore;
// Numbered pages: order first, including a unique tie-breaker if needed.
var page = await db.Posts.OrderByDescending(p => p.Id)
.ToPageAsync(request, ct: ct);
// Cursor pages: pass an unordered query. The helper applies the ordering.
var cursorPage = await db.Posts
.ToCursorPageAsync(p => p.CreatedAt, p => p.Id, cursorRequest,
descending: true, ct: ct);
return Render("Feed", new InertiaProps { ["posts"] = Scroll(cursorPage) });
```
Both results contain the selected group in `data`. A cursor is a marker: send it back unchanged to request the next or previous group. `ToPageAsync` requires an ordered query and counts rows by default; pass `countTotal: false` to skip the count and return null `total` and `lastPage`. `ToCursorPageAsync` rejects an already ordered query and orders by the supplied key and optional tie-breaker itself. Both guards throw `InvalidOperationException`. The cursor helper fetches in both directions without counting.
Choose a stable, fully unique ordering: use a unique key, or add a unique tie-breaker when the first key can repeat. Nullable keys are unsupported. The cursor codec supports the types listed above, but EF translation and ordering for each type depend on the database provider; for example, SQLite needs a suitable value conversion to order `DateTimeOffset`. The name from a bound request or `From(...)` is used for the corresponding page parameter; an explicit `pageName` or `cursorName` argument takes precedence.
The EF Core package is not marked Native AOT compatible. EF Core has its own compiled-model requirements; for Native AOT applications, use the core delegate paginators with an AOT-compatible data source instead.
## Native AOT
Register each closed page type in your app's `JsonSerializerContext` along with the item types:
```csharp
[JsonSerializable(typeof(Page))]
[JsonSerializable(typeof(CursorPage))]
```
See [Native AOT](/docs/native-aot).
## Caching and scroll props
A cached page still writes its scroll metadata. Use cache keys that include the page or cursor and all user or tenant dependencies. `Scroll(...)` can be deferred or cached; do not reuse its mutable prop instance across requests. Root `Append()` and `Prepend()` cannot be combined on the same prop. See [Props](/docs/props#cached-props) and [Infinite scroll](/docs/infinite-scroll).
---
# Live props
URL: https://inertia-dotnet.temtem.dev/docs/live-props
> Props that refresh themselves when the server broadcasts an event.
A live prop refreshes itself when the server broadcasts an event. The page response says which props listen to which events; the client subscribes to those channels and, when an event arrives, reloads the affected props or applies the values the event carries. Components never subscribe or clean up.
Three pieces make it work:
1. **An event**, defined once as a `LiveEvent` and shared by both sides.
2. **A prop marked `.Live(event)`**, which puts its listeners in the page's `liveProps`.
3. **A publish** through `IInertiaLive`, delivered to subscribers by a transport.
Live props need `@inertiajs/react`, `@inertiajs/vue3` or `@inertiajs/svelte` 3.11.0 or later. Older clients ignore `liveProps`.
## Define each event once
A `LiveEvent` is an event name on a `LiveChannel`. The same value declares the prop and publishes the event, so the two sides cannot drift apart.
```csharp
public static class OrderEvents
{
public static LiveEvent Updated(int id) => new("OrderUpdated", LiveChannel.Private($"orders.{id}"));
public static LiveEvent StatsChanged { get; } = new("StatsChanged", LiveChannel.Public("stats"));
}
```
Channels are `LiveChannel.Public`, `Private`, `Presence` or `EncryptedPrivate`. Names carry no prefix; a transport adds `private-` or `presence-` itself when its protocol needs one.
## Mark props live
```csharp
app.MapGet("/orders/{id:int}", (int id, AppDb db) => Render("Orders/Show", new InertiaProps
{
["order"] = Prop(ct => db.Orders.SingleAsync(o => o.Id == id, ct))
.Live(OrderEvents.Updated(id))
.Throttle(TimeSpan.FromMilliseconds(500)),
["metrics"] = Defer(ct => metrics.ForOrderAsync(id, ct)).Live(OrderEvents.Updated(id)),
["stats"] = Optional(() => stats.Today()).Live(OrderEvents.StatsChanged),
}));
```
The page lists every listener, including those of props it left out:
```json
{
"props": { "errors": {}, "order": { "id": 1, "status": "paid" } },
"deferredProps": { "default": ["metrics"] },
"liveProps": {
"order": {
"listeners": [
{ "channel": { "name": "orders.1", "type": "private" }, "events": ["OrderUpdated"] }
],
"throttle": 500
},
"metrics": {
"listeners": [
{ "channel": { "name": "orders.1", "type": "private" }, "events": ["OrderUpdated"] }
]
},
"stats": {
"listeners": [
{ "channel": { "name": "stats", "type": "public" }, "events": ["StatsChanged"] }
]
}
}
}
```
| Call | Effect |
|---|---|
| `.Live(event)` | Adds a listener. Repeatable: call it once per event. |
| `.Live(channel, "Created", "Deleted")` | Listens to several events on one channel. |
| `Live(() => ..., event)` | The factory form of `Prop(...).Live(event)`. |
| `.Throttle(window)` | The shortest time between two reloads of the prop. The client's default is one second. |
### What combines with live
| Prop | Live? | Notes |
|---|---|---|
| `Prop`, `Always`, shared props | Yes | |
| `Defer`, `Optional` | Yes | Listeners are reported even while the value is left out. |
| `Once` | Yes | A once prop the client already holds stays subscribed. |
| `Rescue`, `Parallel` | Yes | A rescued prop stays subscribed; the next event fetches it again. |
| `Cache` | Yes | Publishing does not evict the cache. Remove the entry with `IInertiaCache` first. |
| `Merge`, `DeepMerge`, `Scroll` | No | A live prop is replaced whole, so these throw `InvalidOperationException`. |
A live prop inside an array reloads its whole array, as every [partial reload](/docs/partial-reloads) of an array descendant does. Prefer keyed objects for lists that change often.
## Shared live props
A shared live prop keeps something current on every page, such as a notification bell:
```csharp
builder.Services.AddInertia(o => o.Share("notifications", ctx =>
{
var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
return userId is null ? null : Prop(ct => notifications.SummaryAsync(userId, ct))
.Live(new LiveEvent("NotificationCreated", LiveChannel.Private($"users.{userId}")));
}));
```
## Publish events
`AddInertiaLive()` registers `IInertiaLive`. A transport registers the `IInertiaLiveBroadcaster` that delivers what it publishes.
```csharp
builder.Services.AddInertiaLive(); // then a transport: .AddSignalR() or your own IInertiaLiveBroadcaster
```
```csharp
// Everyone else on the channel reloads `order`; the caller already has the response.
app.MapPost("/orders/{id:int}/ship", async (int id, HttpContext context, IInertiaLive live) =>
{
await orders.ShipAsync(id);
await live.PublishAsync(OrderEvents.Updated(id), exceptSocketId: context.InertiaRequest().SocketId);
return Back();
});
```
```csharp
// Clients apply the value without a request; live props left out are reloaded.
await live.PublishAsync(OrderEvents.Updated(order.Id), new { order = OrderDto.From(order) });
```
```csharp
// Written with that context's own options, as JsonSerializer would.
await live.PublishAsync(OrderEvents.Updated(order.Id), new OrderProps(dto), AppJsonContext.Default.OrderProps);
```
- **Without values**, every client reloads the live props that listen to the event, each with its own request and its own data.
- **With values**, keyed by prop path (`"auth.user"` stays a flat key), those props are updated in place. Values use the page serializer options, so they match what a reload returns; under Native AOT the type needs to be in your registered JSON context, like any prop type.
- **`exceptSocketId`** leaves one connection out, usually the client that made the change. The client sends its id as `X-Socket-Id` and `HttpContext.InertiaRequest().SocketId` reads it. The header is client-supplied, so it is only ever used to exclude.
A broadcast reaches every client on the channel. Only publish values that everyone allowed on the channel may see. When a value depends on the user, publish without values and let each client reload its own.
What a broadcaster delivers:
```json
{
"__inertia": {
"event": "OrderUpdated",
"props": { "order": { "id": 1, "status": "shipped" } }
}
}
```
## On the client
Read a live prop like any other prop. `useProp` exposes its loading state, and the cancelable `live` router event can hold an update back, for example to show a "new posts" button instead:
```tsx
import { router, useProp } from '@inertiajs/react'
const order = useProp('order') // { value, loading, loaded }
router.on('live', (event) => {
if (event.detail.props.includes('posts')) {
showNewPostsButton()
return false
}
})
```
## SignalR
`Inertia.Net.Live.SignalR` runs the transport inside your app: a SignalR hub the browser subscribes to, channel authorization, and the broadcaster that `IInertiaLive` publishes through.
```sh
dotnet add package Inertia.Net.Live.SignalR
npm install @microsoft/signalr # or: bun add, pnpm add, yarn add @microsoft/signalr
```
```csharp
builder.Services.AddInertiaLive().AddSignalR(o =>
o.Authorize((context, channel, ct) =>
ValueTask.FromResult(channel.Name == $"users.{context.UserIdentifier}")));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseInertia();
app.MapInertiaLiveHub(); // /inertia-live
```
Apps created with `dotnet new inertia` ship the client transport as `ClientApp/lib/inertia-signalr.ts`. In other apps, copy [the file](https://github.com/adzamkomladev/Inertia.Net/blob/main/src/Inertia.Net.Templates/content/common/ClientApp/lib/inertia-signalr.ts) in, then turn it on:
```ts
import { signalr } from './lib/inertia-signalr'
createInertiaApp({ live: signalr() })
```
`@microsoft/signalr` loads on the first subscription, so pages without live props never download it. When a dropped connection comes back, the transport joins its channels again and Inertia reloads every live prop once, catching up on missed events.
### Who may join a channel
| Channel | Who joins |
|---|---|
| `Public` | Anyone who reaches the hub. Set `o.AuthorizePublicChannels = true` to ask the authorizer too. |
| `Private`, `Presence`, `EncryptedPrivate` | Only connections the authorizer allows. With no authorizer, nobody. |
For rules that need services, implement `ILiveChannelAuthorizer` and register it with `o.Authorize()`. It is created for each subscription, with constructor injection. SignalR has no presence member lists or end-to-end encryption, so presence and encrypted-private channels are authorized like private ones.
The hub answers requests from other origins with 403 (add trusted ones to `o.AllowedOrigins`). That check stands in for antiforgery tokens on the hub. Connections close when their authentication expires.
SignalR groups live on one server. Behind several instances, add a Redis backplane (`AddSignalR().AddStackExchangeRedis(...)`) or Azure SignalR Service; without one, a publish reaches only the clients connected to the instance that sent it.
The [package README](https://github.com/adzamkomladev/Inertia.Net/blob/main/src/Inertia.Net.Live.SignalR/README.md) lists the client options, how channels map to SignalR groups, and the security notes.
## Custom transports
The page metadata and the payload do not depend on SignalR, so any transport works:
- **Pusher-protocol servers** (Reverb, Soketi, Ably, Pusher) work with the client's built-in `pusher()` or `echo()` transport. On the server, implement `IInertiaLiveBroadcaster` with your server's publish API.
- **Anything else** (Action Cable, server-sent events, Azure Web PubSub): implement `IInertiaLiveBroadcaster` on the server and a `LiveTransport` on the client.
A broadcaster has one method. It receives the channel, the finished payload and the connection to skip:
```csharp
public sealed class MyBroadcaster : IInertiaLiveBroadcaster
{
public ValueTask PublishAsync(LiveMessage message, CancellationToken cancellationToken = default)
{
// message.Channel.Key: "private:orders.1"
// message.Payload: {"__inertia":{"event":"OrderUpdated",...}}, a JsonElement
// message.ExceptSocketId: the connection to leave out, or null
throw new NotImplementedException("Send message.Payload to the channel's subscribers.");
}
}
```
## Testing
`InertiaPage.LiveProps` holds the metadata, and `HasLive` asserts it, with the channel written as `"{type}:{name}"`:
```csharp
await response.AssertInertiaAsync(page => page
.HasLive("order")
.HasLive("order", "private:orders.1", "OrderUpdated"));
```
## Limits, proxies and authorization changes
SignalR defaults to 64 subscriptions per connection and a 200-character channel name. Configure `MaxSubscriptionsPerConnection` and `MaxChannelNameLength` through the SignalR options; both must be positive. Behind a TLS-terminating proxy configure trusted forwarded headers once and run `UseForwardedHeaders()` before the hub so origin and scheme checks see the external address.
Authorization is checked when a client subscribes, not continuously. If access changes, disconnect the client or use a transport/application strategy that enforces revocation. A `removed` event can tell the UI to update, but it is not a security revocation mechanism.
---
# Precognition
URL: https://inertia-dotnet.temtem.dev/docs/precognition
> Validate a form before submission without running its handler.
The [Minimal API React sample](https://github.com/adzamkomladev/Inertia.Net/tree/main/samples/MinimalApi.React) includes a login validation demo. It accepts dummy values and does not create an authentication session. Precognition sends the same POST as the form with `Precognition: true`. The server validates it and returns without running the handler:
```http
POST /login HTTP/1.1
Precognition: true
Precognition-Validate-Only: email
Content-Type: application/json
{"email":"invalid","password":"password123"}
HTTP/1.1 422 Unprocessable Entity
Precognition: true
Vary: Precognition
Content-Type: application/json
{"message":"The Email field is not a valid e-mail address.","errors":{"email":["The Email field is not a valid e-mail address."]}}
```
The `message` is the first returned error. A valid request returns `204` and `Precognition-Success: true`. For Minimal APIs register `AddValidation()` and use:
```csharp
app.MapPost("/login", (LoginForm input) => Back().WithFlash("success", "Demo form submitted"))
.WithInertiaPrecognition();
```
The sample's input model is:
```csharp
public sealed class LoginForm
{
[Required, EmailAddress]
public string? Email { get; set; }
[Required, MinLength(8)]
public string? Password { get; set; }
}
```
Import `System.ComponentModel.DataAnnotations`. This replaces `.WithInertiaValidation()`; do not use both. It also handles normal validation redirects. Filters added after it do not run on precognitive requests. FastEndpoints enables this through `c.ConfigureInertia()`; MVC does not support Precognition.
## Client setup
Inertia 3 includes Precognition in its form integration. Each example validates on blur and displays the error:
```tsx
import { useForm } from '@inertiajs/react'
import { useEffect } from 'react'
export default function LoginForm() {
const form = useForm({ email: '', password: '' }).withPrecognition('post', '/login')
useEffect(() => { form.setValidationTimeout(3000) }, [])
return (
)
}
```
```vue
Validating…
{/if}
```
`Precognition-Validate-Only` selects returned errors by field, including nested paths. Validation still runs the validator: keep database rules cheap. Validate on blur for login and payment forms to avoid an error while the user is still typing. `setValidationTimeout(3000)` raises the debounce interval for expensive rules; it is not the HTTP deadline.
## Antiforgery and authorization
`UseInertiaAntiforgery()` validates precognitive POSTs like other unsafe requests; the Inertia client sends the token automatically. Authorization middleware runs before endpoint filters, so protected forms validate only for authorized users.
## Rate limiting
Precognitive POSTs pass through rate limiting before the endpoint filter, so using a single strict login bucket can lock users out while typing. Use separate bounded partitions for validation and real submissions, and check endpoint `InertiaPrecognitionMetadata` as well as `InertiaPrecognition.IsPrecognitive(request)`: the header alone does not guarantee the handler is skipped. Do not use `GetNoLimiter` for validation; validators consume CPU and may query a database. With the metadata present the handler is skipped, so the validation bucket does not allow credential attempts.
On a rejected precognitive request set `Precognition: true` on the 429 response. This lets the client recognize the response; the validation request still rejects, so provide a retry or error message. Run `UseRouting`, `UseRateLimiter`, `UseInertia` and `UseInertiaAntiforgery` in that order. See the example below for a complete policy.
The following `AddServices` method is copied from the Minimal API sample's `LoginDemo` class. The sample's `_generation` field exists only to reset E2E test buckets; real apps can partition on a stable client identity appropriate to their deployment. Import `System.Globalization`, `System.Threading.RateLimiting` and `Microsoft.AspNetCore.RateLimiting`, and declare `private static int _generation;` and `private static int _submissions;` in that class. The [complete sample class](https://github.com/adzamkomladev/Inertia.Net/blob/main/samples/MinimalApi.React/LoginDemo.cs) includes `LoginForm`, `LoginPage`, and the reset helper:
```csharp
public static void AddServices(IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("login", http =>
{
var validation = http.GetEndpoint()?.Metadata.GetMetadata() is not null
&& InertiaPrecognition.IsPrecognitive(http.Request);
var client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// The generation lets the sample's E2E reset endpoint give each test fresh buckets.
var generation = Volatile.Read(ref _generation);
if (validation)
{
return RateLimitPartition.GetSlidingWindowLimiter($"validation:{client}:{generation}", _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 0,
});
}
return RateLimitPartition.GetFixedWindowLimiter($"submit:{client}:{generation}", _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
});
});
options.OnRejected = (context, _) =>
{
var http = context.HttpContext;
if (http.GetEndpoint()?.Metadata.GetMetadata() is not null
&& InertiaPrecognition.IsPrecognitive(http.Request))
{
http.Response.Headers[InertiaPrecognition.Header] = "true";
http.Response.Headers.Append("Vary", InertiaPrecognition.Header);
}
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
http.Response.Headers.RetryAfter = Math.Ceiling(retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture);
return ValueTask.CompletedTask;
};
});
}
```
Register it before building the app. The sample also opts 429 into error-page handling for real Inertia submissions; 429 is not a default error-page status:
```csharp
builder.Services.AddInertiaErrorPages(o => o.StatusCodes.Add(429));
```
Map an error page with a retry message. Precognitive requests accepting JSON retain their 429 response and `Precognition: true` header. Attach the policy to the endpoint:
```csharp
LoginDemo.AddServices(builder.Services);
// After builder.Build():
app.UseInertiaErrorPages(); // wraps rate-limit and page responses
app.UseRouting();
app.UseRateLimiter();
app.UseInertia();
app.UseInertiaAntiforgery(); // after UseAuthentication() in an app with sign-in
LoginDemo.MapRoutes(app);
```
The sample routes use this method:
```csharp
public static void MapRoutes(WebApplication app)
{
app.MapGet("/login", () => Render("Login", new LoginPage(Volatile.Read(ref _submissions))));
app.MapPost("/login", (LoginForm input) =>
{
Interlocked.Increment(ref _submissions);
return Back().WithFlash("success", "Demo form submitted");
}).WithInertiaPrecognition().RequireRateLimiting("login");
}
```
The sample partitions by remote IP for demonstration; configure trusted forwarded headers when behind a proxy and choose a partition key suited to your authentication and abuse model.
## Testing
With an `Inertia.Net.Testing` client that retains cookies, load the form page and attach its XSRF token. For an endpoint that validates `email`:
```csharp
client.DefaultRequestHeaders.Accept.ParseAdd("text/html");
using var page = await client.GetAsync("/login");
async Task ValidateAsync(string email)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/login")
{
Content = JsonContent.Create(new { email, password = "password123" }),
};
request.WithXsrfToken(page);
request.Headers.Add(InertiaPrecognition.Header, "true");
request.Headers.Add(InertiaPrecognition.ValidateOnlyHeader, "email");
return await client.SendAsync(request);
}
using var valid = await ValidateAsync("ann@example.com");
Assert.Equal(HttpStatusCode.NoContent, valid.StatusCode);
Assert.Equal("true", valid.Headers.GetValues(InertiaPrecognition.SuccessHeader).Single());
using var invalid = await ValidateAsync("invalid");
Assert.Equal(HttpStatusCode.UnprocessableEntity, invalid.StatusCode);
using var body = JsonDocument.Parse(await invalid.Content.ReadAsStringAsync());
Assert.True(body.RootElement.GetProperty("errors").TryGetProperty("email", out _));
```
Import `System.Net`, `System.Net.Http.Json`, `System.Text.Json`, `Inertia.Net` and `Inertia.Net.Testing`. [Testing](/docs/testing#testing-a-form-with-antiforgery) shows the client and token setup.
## Protocol reference
| Header | Direction | Meaning |
|---|---|---|
| `Precognition: true` | Request and response | Identifies a validation request and response |
| `Precognition-Validate-Only` | Request | Comma-separated client field paths whose errors are returned |
| `Precognition-Success: true` | Response | Validation succeeded (204) |
| `Vary: Precognition` | Response | Varies the response by request type |
A validation failure returns `422`; a limiter may return `429`. `InertiaPrecognition.IsPrecognitive(HttpRequest)` checks the header; `InertiaPrecognitionMetadata` identifies endpoints whose handler is skipped for these requests.
---
# Server-side rendering
URL: https://inertia-dotnet.temtem.dev/docs/server-side-rendering
> Render the first visit on Bun or Node, supervise the process from .NET, and fall back safely.
SSR renders the first visit on a JavaScript server using your client bundle, so crawlers and slow devices get HTML. Inertia XHR requests are never rendered on the server.
## 1. Build the SSR bundle
```tsx
// ClientApp/ssr.tsx
import { createInertiaApp } from '@inertiajs/react'
createInertiaApp()
```
```ts
// vite.config.ts
import inertia from '@inertiajs/vite'
export default defineConfig({
plugins: [
react(),
inertia({
ssr: { entry: 'ClientApp/ssr.tsx', host: '127.0.0.1', port: 13714 },
}),
],
})
```
Build both bundles:
```json
{ "scripts": { "build": "vite build && vite build --ssr" } }
```
```tsx
// resources/js/ssr.tsx
import { createInertiaApp, type ResolvedComponent } from '@inertiajs/react'
import createServer from '@inertiajs/react/server'
import ReactDOMServer from 'react-dom/server'
createServer((page) =>
createInertiaApp({
page,
render: ReactDOMServer.renderToString,
resolve: (name) => {
const pages = import.meta.glob<{ default: ResolvedComponent }>('./Pages/**/*.tsx', { eager: true })
return pages[`./Pages/${name}.tsx`]
},
setup: ({ App, props }) => ,
}),
{ host: '127.0.0.1', port: Number(process.env.INERTIA_SSR_PORT ?? 13714) },
)
```
The client entry hydrates when the server rendered:
```tsx
import { createRoot, hydrateRoot } from 'react-dom/client'
setup({ el, App, props }) {
if (!el) throw new Error('Inertia root element not found')
if (el.dataset.serverRendered === 'true') hydrateRoot(el, )
else createRoot(el).render()
}
```
Run the bundle with Bun or Node, or let the app start it:
```sh
bun ssr/ssr.js # listens on http://127.0.0.1:13714
# or
node ssr/ssr.js
```
Build and run from a directory with `node_modules` installed. The SSR build leaves `node_modules` imports external.
## 2. Enable it
```csharp
builder.Services.AddInertia(o =>
{
o.Ssr.Mode = SsrMode.Preferred; // Disabled (default), Preferred or Required
o.Ssr.Urls.Add("http://127.0.0.1:13714"); // default when empty; add more servers to spread renders
o.Ssr.ConnectTimeout = TimeSpan.FromSeconds(5); // connection establishment
o.Ssr.Timeout = TimeSpan.FromSeconds(5); // total deadline per render
o.Ssr.MaxPageBytes = 2 * 1024 * 1024; // larger pages render on the client without a call
o.Ssr.MaxResponseBytes = 16L * 1024 * 1024; // success and error responses
o.Ssr.BundlePath = "ssr/ssr.js"; // skip SSR while this file does not exist
o.Ssr.ExcludePaths.Add("/admin/*"); // exact, or a trailing * for a prefix
});
```
A response can choose its own mode:
```csharp
return Render("Checkout").WithoutSsr();
return Render("Landing").WithSsr(SsrMode.Required);
return Render("Admin/Home").WithSsr(SsrMode.Preferred);
```
The global mode decides first: `Disabled` is a kill switch no response can override. Otherwise the response's mode wins, then `ExcludePaths`, then the global mode.
SSR runs only for first visits (the HTML response). The page JSON is posted to `{url}/render`, and `{ "head": [...], "body": "..." }` is written into the root view. `@inertiaHead` outputs the head tags; `@inertia` outputs the body.
## 3. Let the app run Bun or Node
```csharp
o.Ssr.UseNodeProcess(); // also sets Mode = Preferred when it is Disabled
o.Ssr.UseNodeProcess(n =>
{
n.BundlePath = "ssr/ssr.js";
n.Executable = "node";
n.Arguments.Add("--enable-source-maps");
n.WorkingDirectory = null; // default: content root
n.StartupTimeout = TimeSpan.FromSeconds(10);
n.RestartOnExit = true;
n.Instances = 1; // instance i listens on BasePort + i
n.BasePort = 13714;
n.LivenessInterval = TimeSpan.FromSeconds(5);
n.LivenessFailureThreshold = 3;
n.AllowNetworkExposure = false;
});
```
A hosted service runs `{Executable} {bundle}` (`node` by default; Bun works too) once per instance, logs stdout as Information and stderr as Warning under `Inertia.Net.Ssr`, and waits for every instance's `GET /health`. Occupied ports are refused before anything is spawned. `Ssr.Urls` is not used in managed mode.
The executable is configurable. This documentation site builds a self-contained SSR bundle and runs it with Bun: `o.Ssr.UseNodeProcess(n => n.Executable = "bun")`. The default is `node`; the bundle accepts either runtime.
- An instance that exits is restarted after 1 second, doubling to 30 seconds.
- An instance that stops answering its liveness check is killed and restarted the same way.
- Shutdown stops the owned process trees without requesting `/shutdown`.
- Nothing is spawned while the Vite dev server runs.
- For external or remote servers, set `Mode` and `Urls` without `UseNodeProcess()`.
The SSR server has no authentication. Anyone who can reach it can `POST /render` (CPU) or `POST /shutdown`, and its `head` and `body` are written into your pages verbatim. Listen on `127.0.0.1`. Before an instance takes renders, Inertia.Net tries the machine's non-loopback addresses on its port. If the instance answers there, it is stopped and an error is logged. `AllowNetworkExposure = true` keeps it running with a warning.
A graceful shutdown stops the SSR process. If the .NET process is killed outright, the OS does not take the runtime with it. Use a process manager or container for hard-crash cleanup.
## 4. Development
With the Vite hot file present (Development only), or `o.Vite.DevServerUrl` set, Inertia.Net posts to `{viteDevUrl}/__inertia_ssr`. The endpoint is served by `@inertiajs/vite`, so you need no built bundle and no separate SSR server.
```ts
import inertia from '@inertiajs/vite'
inertia({ ssr: { entry: 'resources/js/ssr.tsx', host: '127.0.0.1', port: 13714 } })
```
`BundlePath` is ignored while the dev server runs.
## 5. Failures and fallbacks
In `Preferred` mode every failure logs a warning and the page renders on the client. In `Required` mode it throws `InertiaSsrException` before the first byte is written, so the app's exception handler can still answer.
| Type | Cause | Opens the circuit |
|---|---|---|
| `connection` | Connection refused or reset. A refused connection is retried on another server. | yes |
| `timeout` | `Ssr.Timeout` elapsed | yes |
| `http` | Non-2xx answer without a structured error | yes |
| `response` | Empty or malformed answer | yes |
| `render` | The component threw (structured error from the renderer) | no |
| `response-too-large` | Answer over `MaxResponseBytes` | no |
| `overloaded` | No render slot within `Concurrency.QueueTimeout`, or the queue is full | no |
| `circuit-open`, `not-ready`, `page-too-large`, `bundle-missing` | Skipped without a call | no |
**Circuit breaker.** After `CircuitBreaker.FailureThreshold` (default 5) consecutive failures that open the circuit, a server is skipped for `BreakDuration` (default 10 s). Then one render probes it: success closes the circuit, failure starts another break. A page that fails because of its own code never pauses the server.
**Render slots.** Each server takes at most `Concurrency.MaxActive` renders at once (default 8, `0` for no limit) from this app instance, with up to `MaxQueued` (default 32) waiting at most `QueueTimeout` (default 250 ms). Renders that find no slot fall back immediately.
## 6. Several servers
```csharp
o.Ssr.Urls.Add("http://ssr-1:13714");
o.Ssr.Urls.Add("http://ssr-2:13714");
```
Each render goes to the server with the fewest renders in flight among those whose circuit is closed (ties rotate). Every server has its own slots, queue and circuit. Only a refused connection is retried on another server within the same deadline.
Page props travel to the renderer, and its HTML goes into your pages unescaped, so it must be trusted. A plain `http://` URL that is not loopback logs a warning at startup. Use HTTPS or a private network.
## 7. Health check
```csharp
builder.Services.AddHealthChecks().AddInertiaSsr();
app.MapHealthChecks("/health");
```
The check reports what the renders have already shown, without calling the SSR servers: healthy while SSR is disabled, goes through the Vite dev server, or every server can take renders; degraded while some circuits are open or some managed instances are not ready; the registration's failure status (default unhealthy) when none can. Its data lists each server's state.
## 8. Observability
```csharp
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter(InertiaDiagnostics.MeterName))
.WithTracing(t => t.AddSource(InertiaDiagnostics.ActivitySourceName));
```
| Instrument | Type | Tags |
|---|---|---|
| `inertia.page.duration` (s) | histogram | `inertia.response`, `inertia.partial` |
| `inertia.page.size` (By) | histogram | `inertia.response` |
| `inertia.ssr.duration` (s) | histogram | `inertia.ssr.outcome` |
| `inertia.ssr.queue.duration` (s) | histogram | |
| `inertia.ssr.active` | up-down counter | |
| `inertia.ssr.outcomes` | counter | `inertia.ssr.outcome`, `inertia.ssr.reason` |
| `inertia.ssr.response.size` (By) | histogram | |
| `inertia.ssr.circuit.state` | gauge | `inertia.ssr.endpoint` |
| `inertia.ssr.circuit.transitions` | counter | `inertia.ssr.endpoint`, `to` |
| `inertia.ssr.process.restarts` | counter | `cause` |
| `inertia.props.rescued` | counter | |
Tag values are bounded: no URLs, component names or user data. The `inertia.render` activity carries `inertia.component`; the `inertia.ssr` activity carries `inertia.ssr.endpoint`, `inertia.ssr.outcome` and `inertia.ssr.reason`. Nothing is recorded unless a listener is attached.
## 9. Response cache
A render depends only on the page JSON, so a page that many visitors see byte for byte the same can reuse its HTML:
```csharp
builder.Services.AddInertia(o =>
{
o.Ssr.Cache.Duration = TimeSpan.FromMinutes(5);
o.Ssr.Cache.StaleFor = TimeSpan.FromMinutes(30);
o.Ssr.Cache.MaxBytes = 64 * 1024 * 1024;
});
return Render("Pricing", props).WithSsrCache(TimeSpan.FromHours(1));
return Render("Pricing", props).WithSsrCache(TimeSpan.FromHours(1), staleFor: TimeSpan.FromDays(1));
```
The key is the SHA-256 of the page JSON (component, props, url, version and the rest), so a new deploy or different props never reuse a stale render. Pages with per-user props make one entry per user; cache the pages that are shared. A hit is counted as `inertia.ssr.outcomes{inertia.ssr.outcome=rendered, inertia.ssr.reason=cache}` and bypasses the render slots and circuit breakers. Nothing is cached while the Vite dev server renders.
Concurrent misses for the same page share one fill per app instance. Waiting counts against `Ssr.Timeout`; cancelling a waiting request does not cancel the request filling the entry.
With `StaleFor`, a render past its `Duration` is still served and refreshed by the bounded background worker after response completion, like stale props. The connection can accept its next request while the renderer runs. Refreshes cancel when the host stops. If the 64-entry queue is full, the refresh is dropped and logged; monitor `inertia.cache.refresh.dropped` with `inertia.cache.kind=ssr`. `IInertiaCache.ClearSsrAsync()` removes every render.
`Ssr.ConnectTimeout` bounds connection establishment independently of `Ssr.Timeout`; both accept a positive duration or `Timeout.InfiniteTimeSpan`. In Required mode, `InertiaSsrException.Message` contains a generic failure category suitable for an error page. Renderer diagnostics remain in application logs and diagnostic exception properties; do not expose those properties to clients.
## Connection timeout and cached renders
`Ssr.ConnectTimeout` defaults to five seconds and limits connection establishment; `Ssr.Timeout` bounds the entire render. In `Required` mode, exceptions use a generic public message; diagnostic properties can contain private renderer details, so do not display them to users.
A stale SSR cache hit returns immediately and schedules refresh on a bounded background worker. Its queue holds 64 pending refreshes; a dropped refresh logs a warning and increments `inertia.cache.refresh.dropped` with `inertia.cache.kind=ssr`. Cached prop loaders used with stale refresh must use singleton dependencies or create their own scope; never capture request-scoped services or `HttpContext`. Global `Ssr.Cache.Duration` is suitable for anonymous pages. Prefer per-page `WithSsrCache` and keys that account for all user and tenant data.
---
# Vite
URL: https://inertia-dotnet.temtem.dev/docs/vite-integration
> Manifest tags, the dev-server hot file, asset base URLs, prefetching and CSP nonces.
`ViteAssets` (a singleton, also usable directly as `vite.RenderTags(httpContext, "resources/js/app.tsx")`) renders the tags for the `@vite(...)` token and the `` tag helper.
## Production
In production Inertia.Net reads `wwwroot/build/.vite/manifest.json` (falling back to `wwwroot/build/manifest.json` for older Vite). It parses the manifest once and pre-renders the tags to UTF-8. For each entry it emits, in order:
1. `` for the entry's CSS and the CSS of its static imports, recursively and deduplicated;
2. `");
await html.WriteAsync(context.GetBodyHtml()); // the page script and root element
await html.WriteAsync("