Skip to content

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:

  1. ASP.NET endpoints call Inertia.Render() to specify a component name and props
  2. IInertiaPageRenderer — the built-in HtmlFileInertiaPageRenderer reads a static wwwroot/index.html shell and substitutes placeholders (page JSON, CSP nonce, deploy version, module CSS links). No Blazor or server-side component rendering is involved.
  3. 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 props

Subsequent 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 reload

Server Side

Inertia.Render()

The static Inertia.Render() method creates an IResult that handles both full page loads and XHR requests:

csharp
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's Pages/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:

csharp
// 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.):

csharp
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:

csharp
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:

csharp
app.UseInertia(); // Add to middleware pipeline

The middleware:

  1. Sets X-Inertia-Version on every response
  2. On XHR requests (X-Inertia header present), compares the client's version with the server's
  3. If versions differ, returns 409 Conflict with X-Inertia-Location header, triggering a full page reload
  4. Converts 302 redirects to 303 for PUT/PATCH/DELETE requests (Inertia protocol requirement)

The version is determined by:

  1. DEPLOYMENT_VERSION environment variable (for rolling deployments)
  2. 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:

html
<!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:

  1. Reads index.html once.
  2. Replaces <!--DEPLOY_VERSION--> with InertiaMiddleware.Version for cache-busting.
  3. Injects <link rel="stylesheet"> tags for each module RCL that ships a {assembly}.css asset (replacing <!--MODULE_CSS_LINKS-->).
  4. Splits the template around <!--INERTIA_PAGE_DATA--> into before / after buffers — so every request only concatenates three strings.

At request time, RenderPageAsync writes:

text
before + <script data-page="app" type="application/json" nonce="…">{pageJson}</script> + after

and 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:

typescript
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:

typescript
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":

  1. Extracts module name: "Customers"
  2. Imports /_content/Customers/Customers.pages.js
  3. Looks up "Customers/Browse" in the pages export
  4. Supports lazy loading via function entries

Module Pages Registry

Each module exports a pages record in Pages/index.ts:

typescript
// 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:

tsx
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:

typescript
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#):

csharp
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):

typescript
// Pages/index.ts
export const pages: Record<string, unknown> = {
  'Customers/Browse': () => import('./Browse'),
};

3. Page component (React):

tsx
// 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:

  1. User navigates to /customers/browse
  2. ASP.NET matches the route, calls the endpoint handler
  3. ICustomerContracts.GetAllCustomersAsync() fetches customers from the database
  4. Inertia.Render("Customers/Browse", { customers }) serializes the page data
  5. On initial load: HtmlFileInertiaPageRenderer writes the pre-split index.html shell with the JSON injected into <script data-page="app">
  6. React hydrates, resolvePage("Customers/Browse") imports Customers.pages.js
  7. The Browse component renders with the server-provided customers array
  8. On subsequent navigation: only JSON is returned, React swaps the component

Next Steps

Released under the MIT License.