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. Blazor SSR renders the HTML shell with the serialized page data
  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 /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 props

Subsequent 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 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 (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'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("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.):

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. Assembly version as fallback

Blazor SSR Shell

The InertiaShell Blazor component renders the HTML document with embedded page data:

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

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

csharp
builder.Services.AddSimpleModuleBlazor(options =>
{
    options.ShellComponent = typeof(MyCustomShell);
});

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 "Products/Browse":

  1. Extracts module name: "Products"
  2. Imports /_content/Products/Products.pages.js
  3. Looks up "Products/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/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:

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

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 Products/Browse page:

1. Endpoint (C#):

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

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

3. Page component (React):

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

  1. User navigates to /products/browse
  2. ASP.NET matches the route, calls the endpoint handler
  3. IProductContracts.GetAllProductsAsync() fetches products from the database
  4. Inertia.Render("Products/Browse", { products }) serializes the page data
  5. On initial load: Blazor SSR renders the full HTML shell with embedded JSON
  6. React hydrates, resolvePage("Products/Browse") imports Products.pages.js
  7. The Browse component renders with the server-provided products array
  8. On subsequent navigation: only JSON is returned, React swaps the component

Next Steps

Released under the MIT License.