Inertia.js Integration
SimpleModule uses Inertia.js to bridge the server-side .NET backend with a React frontend. Instead of building a separate API + SPA, endpoints return Inertia responses that render React components with server-provided props -- giving you the DX of a SPA with the architecture of a server-rendered app.
How It Works
The Inertia integration in SimpleModule has three layers:
- ASP.NET endpoints call
Inertia.Render()to specify a component name and props IInertiaPageRenderer— the built-inHtmlFileInertiaPageRendererreads a staticwwwroot/index.htmlshell and substitutes placeholders (page JSON, CSP nonce, deploy version, module CSS links). No Blazor or server-side component rendering is involved.- React ClientApp hydrates the page by dynamically importing the correct module's page bundle
Request Flow
Initial Page Load
On the first request (full page load), the flow is:
Browser GET /customers/browse
↓
ASP.NET route handler
→ Inertia.Render("Customers/Browse", { customers: [...] })
↓
InertiaResult.ExecuteAsync()
→ Serializes page data (component, props, url, version) as JSON
→ Delegates to IInertiaPageRenderer
↓
HtmlFileInertiaPageRenderer (default IInertiaPageRenderer)
→ Loads wwwroot/index.html once at startup and splits it around
the <!--INERTIA_PAGE_DATA--> placeholder
→ Writes the pre-split HTML, injecting page JSON into
<script data-page="app" type="application/json">
→ Also substitutes the CSP nonce, deploy version, and module CSS links
↓
Browser receives HTML
→ React's createInertiaApp hydrates the page
→ resolvePage() imports Customers.pages.js
→ "Customers/Browse" component renders with propsSubsequent Navigation
On subsequent navigation (Inertia XHR requests), the flow is shorter:
Browser clicks Inertia link
→ XHR GET /customers/browse (with X-Inertia header)
↓
ASP.NET route handler
→ Inertia.Render("Customers/Browse", { customers: [...] })
↓
InertiaResult.ExecuteAsync()
→ Detects X-Inertia header
→ Returns JSON response (not HTML)
↓
Inertia.js client
→ Swaps page component with new props
→ No full page reloadServer Side
Inertia.Render()
The static Inertia.Render() method creates an IResult that handles both full page loads and XHR requests:
using SimpleModule.Core.Inertia;
public class BrowseEndpoint : IViewEndpoint
{
public void Map(IEndpointRouteBuilder app)
{
app.MapGet(
"/browse",
async (ICustomerContracts customers) =>
Inertia.Render(
"Customers/Browse",
new { customers = await customers.GetAllCustomersAsync() }
)
);
}
}Parameters:
component-- the page name (e.g.,"Customers/Browse"). Must match an entry in the module'sPages/index.ts.props-- an anonymous object or any serializable type. Serialized as camelCase JSON.
Props Serialization
Props are serialized using System.Text.Json with JsonNamingPolicy.CamelCase:
// Server
Inertia.Render("Customers/Edit", new { customer });
// Client receives:
// { "component": "Customers/Edit", "props": { "customer": { "id": 1, "name": "..." } } }WARNING
Property names are automatically converted to camelCase. A C# property CustomerName becomes customerName in JavaScript.
Shared Data
Use InertiaSharedData to share props across all Inertia responses in a single HTTP request. This is useful for data that every page needs (current user, flash messages, etc.):
public sealed class InertiaSharedData
{
public void Set(string key, object? value);
public T? Get<T>(string key, T? defaultValue = default);
public bool Remove(string key);
public bool Contains(string key);
public IReadOnlyDictionary<string, object?> All { get; }
}InertiaSharedData is registered as a scoped service. Set values in middleware or endpoint filters, and they are automatically merged into every Inertia response for that request:
app.Use(async (context, next) =>
{
var sharedData = context.RequestServices.GetRequiredService<InertiaSharedData>();
sharedData.Set("appName", "My Application");
sharedData.Set("user", new { name = context.User.Identity?.Name });
await next();
});Shared data has lower priority than endpoint props. If an endpoint sets a prop with the same key as shared data, the endpoint's value wins.
Version Detection
The Inertia middleware handles asset versioning to prevent stale JavaScript from running after deployments:
app.UseInertia(); // Add to middleware pipelineThe middleware:
- Sets
X-Inertia-Versionon every response - On XHR requests (
X-Inertiaheader present), compares the client's version with the server's - If versions differ, returns
409 ConflictwithX-Inertia-Locationheader, triggering a full page reload - Converts
302redirects to303for PUT/PATCH/DELETE requests (Inertia protocol requirement)
The version is determined by:
DEPLOYMENT_VERSIONenvironment variable (for rolling deployments)- Build timestamp as fallback — the entry assembly's last-write time formatted as
yyyyMMddHHmmss, so every recompile/publish invalidates stale clients automatically
HTML File Shell
The default IInertiaPageRenderer is HtmlFileInertiaPageRenderer (in SimpleModule.Hosting.Inertia). It reads a single static wwwroot/index.html file at startup and substitutes placeholders at request time — there is no Blazor, no HtmlRenderer, and no server-side component tree.
The host's wwwroot/index.html contains these placeholders:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--MODULE_CSS_LINKS-->
<script type="importmap" nonce="<!--CSP_NONCE-->">
{
"imports": {
"react": "/js/vendor/react.js?v=<!--DEPLOY_VERSION-->",
"react-dom": "/js/vendor/react-dom.js?v=<!--DEPLOY_VERSION-->",
"@inertiajs/react": "/js/vendor/inertiajs-react.js?v=<!--DEPLOY_VERSION-->"
}
}
</script>
</head>
<body>
<!--INERTIA_PAGE_DATA-->
<script src="/js/app.js?v=<!--DEPLOY_VERSION-->" nonce="<!--CSP_NONCE-->"></script>
</body>
</html>At startup the renderer:
- Reads
index.htmlonce. - Replaces
<!--DEPLOY_VERSION-->withInertiaMiddleware.Versionfor cache-busting. - Injects
<link rel="stylesheet">tags for each module RCL that ships a{assembly}.cssasset (replacing<!--MODULE_CSS_LINKS-->). - Splits the template around
<!--INERTIA_PAGE_DATA-->intobefore/afterbuffers — so every request only concatenates three strings.
At request time, RenderPageAsync writes:
before + <script data-page="app" type="application/json" nonce="…">{pageJson}</script> + afterand swaps <!--CSP_NONCE--> with the per-request nonce from ICspNonce. In development it also strips the import map and app.js script tag and injects Vite's /@vite/client and /app.tsx entries when the Vite dev server is active (via DevToolsConstants.ViteDevServerKey).
To swap the renderer, replace the IInertiaPageRenderer registration with your own implementation — there is no InertiaOptions.ShellComponent or AddSimpleModuleBlazor hook.
Client Side
App Bootstrap
The React app is bootstrapped in ClientApp/app.tsx:
import { createInertiaApp } from '@inertiajs/react';
import { resolvePage } from '@simplemodule/client/resolve-page';
import { createRoot } from 'react-dom/client';
createInertiaApp({
resolve: resolvePage,
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />);
},
});Page Resolution
The resolvePage function dynamically imports module page bundles based on the component name:
export async function resolvePage(name: string) {
const moduleName = name.split('/')[0];
const cacheBuster = (
document.querySelector('meta[name="cache-buster"]') as HTMLMetaElement
)?.content;
const suffix = cacheBuster ? `?v=${cacheBuster}` : '';
const mod = await import(
`/_content/${moduleName}/${moduleName}.pages.js${suffix}`
);
const page = mod.pages[name];
// Supports lazy entries: () => import('./SomePage')
if (typeof page === 'function') {
const resolved = await page();
return resolved.default ? resolved : { default: resolved };
}
return page.default ? page : { default: page };
}For a component name like "Customers/Browse":
- Extracts module name:
"Customers" - Imports
/_content/Customers/Customers.pages.js - Looks up
"Customers/Browse"in thepagesexport - Supports lazy loading via function entries
Module Pages Registry
Each module exports a pages record in Pages/index.ts:
// modules/Customers/src/SimpleModule.Customers/Pages/index.ts
export const pages: Record<string, unknown> = {
'Customers/Browse': () => import('./Browse'),
'Customers/Manage': () => import('./Manage'),
'Customers/Create': () => import('./Create'),
'Customers/Edit': () => import('./Edit'),
};Critical
Every IViewEndpoint that calls Inertia.Render("Module/Page", ...) must have a matching entry in the module's Pages/index.ts. Missing entries silently fail with no error in the console. Run npm run validate-pages to catch mismatches.
Writing a Page Component
Page components receive props from the server as React props:
import { PageHeader } from '@simplemodule/ui/components';
interface BrowseProps {
customers: Customer[];
}
export default function Browse({ customers }: BrowseProps) {
return (
<div>
<PageHeader title="Customers" />
<ul>
{customers.map((c) => (
<li key={c.id}>{c.name} - {c.email}</li>
))}
</ul>
</div>
);
}Error Handling
The ClientApp handles non-Inertia error responses (404, 500, etc.) by intercepting the invalid event on the Inertia router:
router.on('invalid', (event) => {
event.preventDefault();
const response = event.detail.response;
const body = response.data as { detail?: string; title?: string } | undefined;
const message = body?.detail ?? body?.title ?? `Server error (${response.status})`;
showErrorToast(message);
});Instead of showing the default "must receive a valid Inertia response" error, a toast notification displays the server error message.
Full Example
Here is the complete flow for a Customers/Browse page:
1. Endpoint (C#):
public class BrowseEndpoint : IViewEndpoint
{
public void Map(IEndpointRouteBuilder app)
{
app.MapGet(
"/browse",
async (ICustomerContracts customers) =>
Inertia.Render(
"Customers/Browse",
new { customers = await customers.GetAllCustomersAsync() }
)
).AllowAnonymous();
}
}2. Page registry (TypeScript):
// Pages/index.ts
export const pages: Record<string, unknown> = {
'Customers/Browse': () => import('./Browse'),
};3. Page component (React):
// Pages/Browse.tsx
export default function Browse({ customers }: { customers: Customer[] }) {
return (
<div>
<h1>Customers</h1>
{customers.map((c) => (
<div key={c.id}>{c.name}</div>
))}
</div>
);
}4. What happens at runtime:
- User navigates to
/customers/browse - ASP.NET matches the route, calls the endpoint handler
ICustomerContracts.GetAllCustomersAsync()fetches customers from the databaseInertia.Render("Customers/Browse", { customers })serializes the page data- On initial load:
HtmlFileInertiaPageRendererwrites the pre-splitindex.htmlshell with the JSON injected into<script data-page="app"> - React hydrates,
resolvePage("Customers/Browse")importsCustomers.pages.js - The Browse component renders with the server-provided customers array
- On subsequent navigation: only JSON is returned, React swaps the component
Next Steps
- Frontend Overview -- the complete React + Inertia.js architecture
- Pages Registry -- how page components are resolved at runtime
- Vite Build System -- module-scoped library mode builds