# 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.yebi.africa/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.UseInertia()` pre-processor | | Precognition | `.WithInertiaPrecognition()` | not available | `c.UseInertia()` | | 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 - Node.js and npm to build the frontend (at runtime only when SSR is enabled) - 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 - [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. --- # Installation URL: https://inertia-dotnet.yebi.africa/docs/installation > Install Inertia.Net, the optional MVC or FastEndpoints 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.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.5.3 dotnet add package Inertia.Net.Mvc --version 1.5.3 # MVC only dotnet add package Inertia.Net.FastEndpoints --version 1.5.3 # FastEndpoints only dotnet add package Inertia.Net.Testing --version 1.5.3 # test projects ``` Use the same version for every Inertia.Net package. The package version and the Inertia.js protocol version are separate numbers: Inertia.Net 1.5.3 implements Inertia.js v3. ## Requirements - .NET 10 SDK and an `Microsoft.NET.Sdk.Web` project. - Node.js and npm for the frontend. Node is only needed at runtime when SSR is enabled. - An Inertia.js v3 client: `@inertiajs/react`, `@inertiajs/vue3` or `@inertiajs/svelte`. ## Start from the template ```sh dotnet new install Inertia.Net.Templates::1.5.3 dotnet new inertia -n MyApp --framework react cd MyApp npm install ``` 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. ## 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. --- # Quick start URL: https://inertia-dotnet.yebi.africa/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"); }); 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.UseInertia()); // 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. ## 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 @viteReactRefresh @vite("ClientApp/app.tsx") @inertiaHead @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 Start Vite and ASP.NET Core in two terminals, from the project directory: ```sh npm run dev # terminal 1: Vite, writes wwwroot/hot dotnet watch # terminal 2: ASP.NET Core, Development environment ``` Open the URL printed by `dotnet watch` (not the Vite URL). The first visit to `/` should display “Hello from .NET”. For production, build the client and publish the app: ```sh npm run build 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. --- # Client setup URL: https://inertia-dotnet.yebi.africa/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 the npm commands from the ASP.NET Core project directory (the one containing your `.csproj`). ```sh npm install react react-dom @vitejs/plugin-react @inertiajs/react@^3 npm install -D vite typescript ``` ```sh npm install vue @vitejs/plugin-vue @inertiajs/vue3@^3 npm install -D vite typescript ``` ```sh npm install svelte @sveltejs/vite-plugin-svelte @inertiajs/svelte@^3 npm install -D vite typescript ``` 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. ## 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 } from '@inertiajs/react' import { createRoot } from 'react-dom/client' createInertiaApp({ resolve: (name) => { const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true }) return pages[`./Pages/${name}.tsx`] }, setup({ el, App, props }) { createRoot(el).render() }, }) ``` ```ts // resources/js/app.ts import { createApp, h } from 'vue' import { createInertiaApp } from '@inertiajs/vue3' createInertiaApp({ resolve: (name) => { const pages = import.meta.glob('./Pages/**/*.vue', { eager: true }) return pages[`./Pages/${name}.vue`] }, setup({ el, App, props, plugin }) { createApp({ render: () => h(App, props) }).use(plugin).mount(el) }, }) ``` ```ts // resources/js/app.ts import { mount } from 'svelte' import { createInertiaApp } from '@inertiajs/svelte' createInertiaApp({ resolve: (name) => { const pages = import.meta.glob('./Pages/**/*.svelte', { eager: true }) return pages[`./Pages/${name}.svelte`] }, setup({ el, App, props }) { 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 ( ) } ``` ```vue ``` ```svelte ``` 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 # terminal 1: Vite on :5173, writes wwwroot/hot dotnet watch # terminal 2: the app, ASPNETCORE_ENVIRONMENT=Development ``` Open the ASP.NET Core URL, not the Vite URL. In Development the app reads `wwwroot/hot` and renders the Vite tags; in production it reads the manifest. For production: ```sh npm 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. --- # AI agents URL: https://inertia-dotnet.yebi.africa/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.yebi.africa/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.UseInertia()` (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 `npm 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 `npm 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.yebi.africa/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.yebi.africa/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?)`. ## 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. - The refresh runs in the response's completion callbacks, before the request's services are disposed, so it can keep using the request's scoped services. It must not touch `HttpContext`. - 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 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.yebi.africa/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.yebi.africa/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 are the JSON names of the model members, one naming-policy conversion per segment: `Address.Street` becomes `address.street`. - **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' }) ``` ## Minimal API `WithInertiaValidation()` for a route or a route group. It requires `services.AddValidation()` for attribute validation. ```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. ## 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.UseInertia()` 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.UseInertia(ep => ep.AllowAnonymous())); ``` `c.UseInertia()` sets `c.Endpoints.Configurator`. Pass your own configuration as its argument instead of setting the configurator separately. The pre-processor runs after an endpoint's own pre-processors, so one that already answered (a 403, say) wins. ## Precognition (live validation) Precognition validates a form as the user types, without running the handler. A request with `Precognition: true` is validated and answered with: - `204` and `Precognition-Success: true` when valid; - `422` and `{ "message": "...", "errors": { "field": ["..."] } }` when not, limited to the fields in `Precognition-Validate-Only`; - `Precognition: true` and `Vary: Precognition` on every response. ```csharp // Minimal API: use it instead of WithInertiaValidation(), not in addition. app.MapPost("/users", (CreateUserInput input) => Results.Redirect("/users")) .WithInertiaPrecognition(); // FastEndpoints: c.UseInertia() already answers Precognition requests. ``` `WithInertiaPrecognition()` is a superset of `WithInertiaValidation()`: normal Inertia validation redirects keep working. Any endpoint filter you add after it does not run for a precognitive request. In MVC, Precognition is not available. Client (Inertia v3 has it built in): ```tsx const form = useForm({ name: '', email: '' }).withPrecognition('post', '/users') // form.validate('email')} /> ``` ## 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.yebi.africa/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). ## 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.yebi.africa/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()` only follows a `Referer` that is an http(s) URL with the request's own host and port. 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`. ## 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), falling back to the app root | | `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). --- # Partial reloads URL: https://inertia-dotnet.yebi.africa/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. ## 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.yebi.africa/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. ```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.yebi.africa/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.yebi.africa/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.yebi.africa/docs/infinite-scroll > Merge pages of items and report paging metadata to the InfiniteScroll client component. `Scroll(...)` merges the items inside a value (the *wrapper*, `"data"` by default) and reports paging metadata to the `` client component. ```csharp public sealed record FeedPage(IReadOnlyList Data, int Page, bool HasMore) : IProvidesScrollMetadata { public ScrollMetadata GetScrollMetadata() => ScrollMetadata.FromPage(Page, HasMore); // pageName defaults to "page" } app.MapGet("/feed", async (int? page, AppDb db, CancellationToken ct) => { var p = page ?? 1; var items = await db.Posts.OrderByDescending(x => x.Id).Skip((p - 1) * 20).Take(21).ToListAsync(ct); return Render("Feed", new InertiaProps { ["posts"] = Scroll(new FeedPage(items.Take(20).ToList(), p, items.Count > 20)), }); }); ``` ```json { "props": { "errors": {}, "posts": { "data": [ { "id": 21 } ] } }, "mergeProps": ["posts.data"], "scrollProps": { "posts": { "pageName": "page", "previousPage": 1, "nextPage": 3, "currentPage": 2, "reset": false } } } ``` ## 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. --- # Server-side rendering URL: https://inertia-dotnet.yebi.africa/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 } 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('./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.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.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 rendered again after the response, like stale props. `IInertiaCache.ClearSsrAsync()` removes every render. --- # Vite URL: https://inertia-dotnet.yebi.africa/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. `