# 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 } ``` 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:
form.setData('title', e.target.value)} /> {form.errors.title &&
{form.errors.title}
}
``` 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 ``` ```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: '' })
{ event.preventDefault() form.post('/users', { errorBag: 'createUser' }) }}> form.setData('email', event.target.value)} />

{form.errors.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 (
{ e.preventDefault(); form.post('/login') }}> {form.errors.password &&

{form.errors.password}

} {form.validating && Validating…} {form.errors.email &&

{form.errors.email}

}
) } ```
```vue ``` ```svelte
{ event.preventDefault(); form.post('/login') }}> {#if form.errors.password}

{form.errors.password}

{/if}
{#if form.validating}Validating…{/if} {#if form.errors.email}

{form.errors.email}

{/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(""); } } ``` ## Error pages per mount ```csharp builder.Services.AddInertiaErrorPages(); // the host's Error component builder.Services.AddInertiaErrorPages("dashboard", o => o.Component = "Dashboard/Error"); app.UseInertiaErrorPages(); // once, before the Map branches ``` A mount renders only the error components registered for it, because another mount's components are not in its bundle. A mount without `AddInertiaErrorPages(name, ...)` leaves its errors to the rest of the pipeline. ## SSR, health checks and TypeScript Each mount has its own SSR gateway, and its own managed Node processes when `o.Ssr.UseNodeProcess()` is set; give each mount its own `BasePort`. Check a mount's SSR servers with: ```csharp builder.Services.AddHealthChecks().AddInertiaSsrForMount("dashboard"); // named "inertia-ssr-dashboard" ``` `o.TypeScript.OutputPath` is per mount too, so each client gets its own generated declarations. ## For library authors Expose one registration call and one mapping call, and keep the mount name private to the library: ```csharp public static IServiceCollection AddDashboard(this IServiceCollection services) => services.AddInertia("dashboard", o => o.Version = DashboardAssets.Version) .AddKeyedSingleton("dashboard"); public static IApplicationBuilder MapDashboard(this IApplicationBuilder app, string path) => app.Map(path, branch => { branch.UseInertia("dashboard"); branch.UseRouting(); branch.UseEndpoints(e => { /* the dashboard's endpoints */ }); }); ``` Mounts are adapter configuration: the Inertia protocol on the wire does not change. --- # Configuration URL: https://inertia-dotnet.temtem.dev/docs/configuration > InertiaOptions, Vite, SSR, props, state and the middleware and result API. Core configuration is passed to `AddInertia`; error page options use `AddInertiaErrorPages`. Options marked “Development only” do nothing in other environments. ## `AddInertia(o => ...)`: `InertiaOptions` | Option | Default | Description | |---|---|---| | `RootView` | `"app.html"` | Root template relative to the content root (a Razor view name with `UseRazorRootView`). Paths outside the content root are refused. | | `RootElementId` | `"app"` | Id of the root element and the `data-page` script. | | `Version` | `null` | Asset version. `null` means the Vite manifest hash, or `""` without one. | | `VersionResolver` | `null` | `Func` per-request version; wins over `Version` when non-null. | | `EncryptHistory` | `false` | Encrypt browser history state of every page. | | `WithAllErrors` | `false` | Send every validation message per field instead of the first. | | `PreserveBigIntegers` | `false` | Emit integers beyond ±(2^53−1) as `{"$bigint":"..."}`. | | `ExposeSharedPropKeys` | `true` | List top-level shared keys in `sharedProps`. | | `CspNonce` | `null` | `Func`: the request's CSP nonce. | | `ConvertExternalRedirects` | `true` | Turn an Inertia redirect to another origin into `409` + `X-Inertia-Location`. | | `UrlResolver` | `null` | `Func`: the page `url`. | | `Pages.Directory` / `Pages.Extensions` | `null` / `.tsx .jsx .ts .js .vue .svelte` | Development check that a rendered component has a page file. | | `TypeScript.OutputPath`, `.Page(component)`, `.SharedProps()`, `.FlashData()` | `null` | TypeScript declarations, written in Development. | | `Share(key, value)` / `Share(key, Func)` | none | Shared props. | ## `o.Pagination`: `PaginationOptions` | Option | Default | Description | |---|---|---| | `DefaultPerPage` | `20` | Default page size. | | `MaxPerPage` | `100` | Maximum page size (below `int.MaxValue` to allow over-fetching); the default must be between 1 and this value. | | `PageName` | `"page"` | Numbered-page query parameter. | | `PerPageName` | `"perPage"` | Page-size query parameter. | | `CursorName` | `"cursor"` | Cursor query parameter. | ## `o.Vite`: `ViteOptions` | Option | Default | Description | |---|---|---| | `PublicDirectory` | `"wwwroot"` | Web root containing the build directory and hot file. | | `BuildDirectory` | `"build"` | Vite output directory under `PublicDirectory`; also the URL prefix. | | `ManifestPath` | `null` | Manifest path relative to the content root. `null` = `{PublicDirectory}/{BuildDirectory}/.vite/manifest.json`, then `manifest.json`. | | `HotFilePath` | `null` | Hot file relative to the content root. `null` = `{PublicDirectory}/hot`. Read in Development only. | | `DevServerUrl` | `null` | Dev server origin, e.g. `http://localhost:5173`. When set, dev tags always render. | | `AssetBaseUrl` | `null` | Replaces `{PathBase}/{BuildDirectory}` as the URL prefix of built assets. | | `Prefetch` | `None` | `Waterfall` or `Aggressive`: prefetch code-split chunks after load. | | `PrefetchConcurrency` | `3` | Chunks `Waterfall` downloads at a time. | ## `o.Ssr`: `SsrOptions` `Ssr.ConnectTimeout` bounds connection establishment separately from the rendering deadline and defaults to five seconds; use a positive supported duration or `Timeout.InfiniteTimeSpan`. | Option | Default | Description | |---|---|---| | `Mode` | `Disabled` | `Disabled`, `Preferred` or `Required`. `Disabled` cannot be overridden per response. | | `Urls` | empty (`http://127.0.0.1:13714`) | SSR server base URLs. Not used with `UseNodeProcess()`. | | `Timeout` | `5 s` | Total deadline per render. `Timeout.InfiniteTimeSpan` disables it. | | `MaxResponseBytes` | `16 MiB` | Limit on actual response bytes, including errors and chunked responses. | | `MaxPageBytes` | `2 MiB` | Pages whose JSON is larger render on the client without a call. | | `BundlePath` | `null` | Skip SSR while this file is missing, unless the Vite dev server runs. | | `ExcludePaths` | empty | Request paths never rendered on the server (exact, or a trailing `*` for a prefix). | | `Concurrency.MaxActive` | `8` | Renders in flight per server from this app instance; `0` for no limit. | | `Concurrency.MaxQueued` | `32` | Renders waiting for a slot per server. | | `Concurrency.QueueTimeout` | `250 ms` | How long a render waits for a slot. | | `CircuitBreaker.Enabled` | `true` | Pause servers that keep failing. | | `CircuitBreaker.FailureThreshold` | `5` | Consecutive failures that open a server's circuit. | | `CircuitBreaker.BreakDuration` | `10 s` | How long an open circuit skips the server before one probe. | | `Cache.Duration` | `Zero` (off) | Reuse a render for this long when the page JSON is the same. | | `Cache.StaleFor` | `Zero` | Serve stale while re-rendering. | | `Cache.MaxBytes` | `64 MiB` | Size of the default store's render cache. | | `UseNodeProcess(configure?)` | off | Start and supervise Node SSR processes (sets `Mode = Preferred` when `Disabled`). | Per response: `Render(...).WithSsr(SsrMode)` and `.WithoutSsr()`. The full SSR options are in [Server-side rendering](/docs/server-side-rendering). ## `o.Props`: `InertiaPropsOptions` | Option | Default | Description | |---|---|---| | `MaxConcurrency` | `4` | Loaders marked `.Parallel()` that run at once per render. Must be positive. | ## `o.State`: `InertiaStateOptions` | Option | Default | Description | |---|---|---| | `CookieName` | `".Inertia.State"` | Cookie name (cookie store) or session key (session store). | | `RememberPreviousUrl` | `false` | Remember successful page GET URLs for `Back()` when Referer is unavailable. | | `PreviousUrlCookieName` | `".Inertia.Previous"` | Plain previous-URL cookie, with either state store; non-empty and different from `CookieName`. | | `CookieSecurePolicy` | `SameAsRequest` | When state and previous-URL cookies are `Secure`. | | `UseSession()` | off | Use `ISession` instead of the encrypted cookie. | | `AddInertiaMvc(o => o.UseTempDataStateStore())` | off | Use MVC TempData. Inertia.Net.Mvc package. | ## `AddInertiaErrorPages(o => ...)`: `InertiaErrorPagesOptions` | Option | Default | Description | |---|---|---| | `Component` | `"Error"` | Default error component. | | `Components` | empty | Per-status overrides, with keys in `StatusCodes`. | | `StatusCodes` | `[403, 404, 500, 503]` | Non-empty set of statuses from 400–599. Protocol `409` and antiforgery `419` responses are preserved. | | `EnabledInDevelopment` | `false` | Preview error components in Development. | | `Props` | `null` | `Func` for additional props. | See [Error pages](/docs/error-pages) for setup, shared props and existing exception middleware. ## Middleware and endpoint API | API | Description | |---|---| | `services.AddInertia(configure?)` | Registers services and options. | | `services.AddInertiaErrorPages(configure?)` / `app.UseInertiaErrorPages(useExceptionHandler: true)` | Error page services and middleware, before `UseInertia()`. Use `false` with your own `UseExceptionHandler()`. | | `app.UseInertia()` | The protocol middleware. Before the endpoints; after `UseSession()` when used. | | `app.UseInertiaAntiforgery(configure?)` | Issues the `XSRF-TOKEN` cookie and validates unsafe requests (`419` on failure). | | `app.MapInertia(pattern, component, props?)` | GET and HEAD endpoint that renders the component. | | `services.AddInertiaDevTools(configure?)` / `app.MapInertiaDevTools()` | DevTools recorder and read API. A warning is logged when recording is enabled outside Development without an `Authorize` callback; the read API then returns 404. | | `app.UseInertiaAntiforgeryCookie(cookieName = "XSRF-TOKEN")` | Only writes the token cookie; validation is up to you. | | `endpoint.WithInertiaValidation()` | Minimal API: validation failures redirect back with errors. | | `endpoint.WithInertiaPrecognition()` | Minimal API: `WithInertiaValidation()` plus Precognition. | | `healthChecks.AddInertiaSsr(...)` | SSR health check from the gateway's state. | | `mvc.AddInertiaMvc(o => o.UseRazorRootView("App"))` | MVC filter, optional Razor root view and component resolver. | | `config.ConfigureInertia(configure?)` | FastEndpoints: validation redirect and Precognition. | | `Render(component, props?)` | `InertiaResult`: `.WithStatusCode`, `.WithRootView`, `.WithViewData`, `.WithSsr`, `.WithoutSsr`, `.WithSsrCache`. | | `Location(url)` | `InertiaLocationResult`. | | `Back(fallback = "~/")` | `InertiaBackResult`: `.WithErrors`, `.WithFlash`. | | `httpContext.Inertia()` | `InertiaFeature`: `Share`, `Flash`, `WithErrors`, `ClearHistory`, `EncryptHistory`, `PreserveFragment`. | | `Send.InertiaAsync(...)`, `Send.InertiaLocationAsync(...)`, `Send.InertiaBackAsync(...)` | FastEndpoints `Send` extensions. | ## Extension points `IInertiaRootView`, `IInertiaStateStore`, `ViteAssets`, `InertiaRequest` (parsed headers), `InertiaHeaders` (header name constants), `InertiaSsrException`, `InertiaSsr.HttpClientName` (the SSR HTTP client) and `InertiaDiagnostics` (meter and activity source names). ## Name collisions The package imports `Inertia.Net` and static members of `Inertia.Net.Inertia` when implicit usings are enabled. If a name such as `Location` collides with a type, use `Inertia.Net.Inertia.Location(url)`. To manage imports yourself, set `false` in the project and add `using Inertia.Net;` and `using static Inertia.Net.Inertia;` where needed. `InertiaPropJsonConverter`, `InertiaSsrHealthCheck`, `InertiaCacheEntryKind`, `InertiaCacheEntryOptions`, `InertiaPagesOptions`, `InertiaPrecognition.IsPrecognitive(...)` and `InertiaPrecognitionMetadata` are public APIs for serialization, health checks, cache stores, page checks and precognitive request handling. --- # TypeScript types URL: https://inertia-dotnet.temtem.dev/docs/typescript > Generate declarations for page props, shared props and flash data from your .NET contracts. Inertia.Net can write TypeScript declarations for typed page props, shared props and flash data. They follow the app's JSON contracts (the HTTP JSON options and their source-generated contexts), so names, nullability and enum values match the page JSON. ## Configure generation ```csharp builder.Services.AddInertia(o => { o.TypeScript.OutputPath = "ClientApp/types/inertia.d.ts"; // written in Development, only when it changed o.TypeScript.Page("Users/Index").Page("Home"); o.TypeScript.FlashData(); // optional; SharedProps() too }); public sealed record UsersPage( List Users, [property: InertiaPropType(typeof(UserStats))] InertiaProp Stats); ``` The file is written when the app starts in Development, and only when its content changed. Commit it so `tsc` passes in CI without running the app. ```ts // ClientApp/types/inertia.d.ts (excerpt) export interface InertiaPages { 'Users/Index': UsersPage Home: HomePage } export interface UsersPage { users: User[] stats?: UserStats } declare module '@inertiajs/core' { export interface InertiaConfig { sharedPageProps: SharedProps flashDataType: FlashData errorValueType: string } } ``` ```tsx import type { InertiaPages } from '../../types/inertia' export default function Index({ users, stats }: InertiaPages['Users/Index']) { // ... } ``` ## Shared props Shared props are inferred from the values passed to `o.Share(...)`; dot keys nest. Factories and per-request shares are typed as `unknown`. `o.TypeScript.SharedProps()` replaces the inference with an explicit type. The `InertiaConfig` augmentation types `usePage().props` and `flash`. ## Prop members An `InertiaProp` member is optional (it can be deferred, optional or rescued) and `unknown`, unless `[InertiaPropType(typeof(T))]` names its value type. ## Mapping | .NET | TypeScript | |---|---| | `int`, `long`, `double`, `decimal`, `float` | `number` (`number \| bigint` for 64-bit and larger with `PreserveBigIntegers`) | | `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `Guid`, `byte[]` | `string` | | enum | union of JSON values (string names with a string enum converter) | | dictionary | `Record` | | polymorphic type | union tagged with its discriminator | | nullable member | `T \| null` | Types missing from the JSON metadata (under Native AOT, register them in your context) are `unknown`, with a warning in the log. Number handling follows member settings, then type settings, then the global JSON options. Custom converter output is typed as `unknown` with a warning when its shape cannot be inferred. Props and MVC validation error keys use the HTTP `JsonOptions`. FastEndpoints error keys use its binding serializer policy. Keep client field names aligned with the applicable policy; custom resolvers without member metadata may require client JSON names for aliases. --- # Testing URL: https://inertia-dotnet.temtem.dev/docs/testing > Fluent assertions over Inertia responses and helpers for partial reloads and deferred props. `Inertia.Net.Testing` has Laravel-style fluent assertions over Inertia responses and `HttpClient` helpers. It parses both JSON responses (`X-Inertia: true`) and the first-visit HTML `