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 - Blazor SSR renders the HTML shell with the serialized page data
- 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 /products/browse
↓
ASP.NET route handler
→ Inertia.Render("Products/Browse", { products: [...] })
↓
InertiaResult.ExecuteAsync()
→ Serializes page data (component, props, url, version) as JSON
→ Delegates to IInertiaPageRenderer
↓
InertiaPageRenderer (Blazor SSR)
→ Renders InertiaShell component with page JSON
→ Returns full HTML document
↓
Browser receives HTML
→ React's createInertiaApp hydrates the page
→ resolvePage() imports Products.pages.js
→ "Products/Browse" component renders with propsSubsequent Navigation
On subsequent navigation (Inertia XHR requests), the flow is shorter:
Browser clicks Inertia link
→ XHR GET /products/browse (with X-Inertia header)
↓
ASP.NET route handler
→ Inertia.Render("Products/Browse", { products: [...] })
↓
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 (IProductContracts products) =>
Inertia.Render(
"Products/Browse",
new { products = await products.GetAllProductsAsync() }
)
);
}
}Parameters:
component-- the page name (e.g.,"Products/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("Products/Edit", new { product });
// Client receives:
// { "component": "Products/Edit", "props": { "product": { "id": 1, "name": "..." } } }WARNING
Property names are automatically converted to camelCase. A C# property ProductName becomes productName 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)- Assembly version as fallback
Blazor SSR Shell
The InertiaShell Blazor component renders the HTML document with embedded page data:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="cache-buster" content="@CacheBuster" />
<script type="importmap">
{
"imports": {
"react": "/js/vendor/react.js",
"react-dom": "/js/vendor/react-dom.js",
"react/jsx-runtime": "/js/vendor/react-jsx-runtime.js",
"react-dom/client": "/js/vendor/react-dom-client.js",
"@inertiajs/react": "/js/vendor/inertiajs-react.js"
}
}
</script>
</head>
<body>
<!-- Blazor layout wraps the Inertia page data -->
<InertiaPage PageJson="@PageJson" />
</body>
</html>The InertiaPageRenderer uses Blazor's HtmlRenderer to produce static HTML server-side:
public sealed class InertiaPageRenderer(
IServiceProvider services,
ILoggerFactory loggerFactory,
IOptions<InertiaOptions> options
) : IInertiaPageRenderer
{
public async Task RenderPageAsync(HttpContext httpContext, string pageJson)
{
await using var renderer = new HtmlRenderer(services, loggerFactory);
var html = await renderer.Dispatcher.InvokeAsync(async () =>
{
var output = await renderer.RenderComponentAsync(
options.Value.ShellComponent,
ParameterView.FromDictionary(new Dictionary<string, object?>
{
["PageJson"] = pageJson,
["HttpContext"] = httpContext,
})
);
return output.ToHtmlString();
});
httpContext.Response.ContentType = "text/html; charset=utf-8";
await httpContext.Response.WriteAsync(html);
}
}You can customize the shell component via InertiaOptions:
builder.Services.AddSimpleModuleBlazor(options =>
{
options.ShellComponent = typeof(MyCustomShell);
});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 "Products/Browse":
- Extracts module name:
"Products" - Imports
/_content/Products/Products.pages.js - Looks up
"Products/Browse"in thepagesexport - Supports lazy loading via function entries
Module Pages Registry
Each module exports a pages record in Pages/index.ts:
// modules/Products/src/Products/Pages/index.ts
export const pages: Record<string, any> = {
'Products/Browse': () => import('../Views/Browse'),
'Products/Manage': () => import('../Views/Manage'),
'Products/Create': () => import('../Views/Create'),
'Products/Edit': () => import('../Views/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 {
products: Product[];
}
export default function Browse({ products }: BrowseProps) {
return (
<div>
<PageHeader title="Products" />
<ul>
{products.map((p) => (
<li key={p.id}>{p.name} - ${p.price}</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 Products/Browse page:
1. Endpoint (C#):
public class BrowseEndpoint : IViewEndpoint
{
public void Map(IEndpointRouteBuilder app)
{
app.MapGet(
"/browse",
async (IProductContracts products) =>
Inertia.Render(
"Products/Browse",
new { products = await products.GetAllProductsAsync() }
)
).AllowAnonymous();
}
}2. Page registry (TypeScript):
// Pages/index.ts
export const pages: Record<string, any> = {
'Products/Browse': () => import('../Views/Browse'),
};3. Page component (React):
// Views/Browse.tsx
export default function Browse({ products }: { products: Product[] }) {
return (
<div>
<h1>Products</h1>
{products.map((p) => (
<div key={p.id}>{p.name}</div>
))}
</div>
);
}4. What happens at runtime:
- User navigates to
/products/browse - ASP.NET matches the route, calls the endpoint handler
IProductContracts.GetAllProductsAsync()fetches products from the databaseInertia.Render("Products/Browse", { products })serializes the page data- On initial load: Blazor SSR renders the full HTML shell with embedded JSON
- React hydrates,
resolvePage("Products/Browse")importsProducts.pages.js - The Browse component renders with the server-provided products 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