Inertia.js

Ferro provides first-class Inertia.js integration, enabling you to build modern single-page applications using React while keeping your routing and controllers on the server. This gives you the best of both worlds: the snappy feel of an SPA with the simplicity of server-side rendering.

How Inertia Works

Inertia.js is a protocol that connects your server-side framework to a client-side framework (React, Vue, or Svelte). Instead of returning HTML or building a separate API:

  1. Your controller returns an Inertia response with a component name and props
  2. On the first request, a full HTML page is rendered with the initial data
  3. On subsequent requests, only JSON is returned
  4. The client-side adapter swaps components without full page reloads

First-Load HTML Shell

The first-load document

A non-X-Inertia GET request to an Inertia handler returns a complete HTML document containing a mount node with the serialized page object as an attribute:

<div id="app" data-page="{&quot;component&quot;:&quot;Home&quot;,&quot;props&quot;:{...},&quot;url&quot;:&quot;/&quot;,&quot;version&quot;:&quot;1.0&quot;}"></div>

The same handler returns the JSON contract when the request carries X-Inertia: true. No hand-rolled root HTML document or per-app manifest parsing is needed — the application supplies only page props.

In development mode the HTML includes Vite HMR tags; in production it includes hashed asset paths resolved from the Vite manifest.json. Both modes are handled automatically by Inertia::render.

The recommended first-load pattern is for the Ferro backend to serve both the HTML shell and the API from the same origin (e.g. http://localhost:8080). The browser issues a GET / to that origin, the Ferro Inertia handler responds with the full HTML document containing the data-page mount node, and subsequent Inertia XHR requests go to the same origin.

With same-origin serving, session cookies work with any SameSite value (Strict, Lax, or None) because browser and backend share one origin. No proxy configuration is required.

Vite server.proxy recipe (split-port dev)

For development setups where Vite HMR runs on a separate port from the Ferro backend, configure Vite's dev server proxy to forward requests to the backend. From the browser's perspective every request goes to the Vite origin; Vite forwards them to the backend, and the Set-Cookie response from Ferro is accepted.

// vite.config.ts — split-port dev: Vite on :5173, Ferro backend on :8080
import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    proxy: {
      // Forward API requests to Ferro.
      // changeOrigin: false preserves the Origin header so CSRF and session
      // validation on the backend receives the browser's actual origin.
      '/api': { target: 'http://localhost:8080', changeOrigin: false },
      // If the backend also serves the HTML shell, proxy the root as well:
      '/': { target: 'http://localhost:8080', changeOrigin: false },
    },
  },
})

Cookie flow: SameSite=Lax (or None) cookies flow correctly across the proxy because the browser sends all requests to the Vite origin (:5173) and Vite forwards them to the Ferro backend (:8080). SameSite=Strict or strict HttpOnly setups require the same-origin story above — the proxy approach does not satisfy SameSite=Strict since the cookie was set by the backend origin, not the Vite origin.

changeOrigin guidance: changeOrigin: false (the default) keeps the Origin header intact, which is what Ferro's CSRF validation expects. Set changeOrigin: true only if the backend explicitly requires the Origin to match its own host — doing so rewrites the Origin and can break CSRF checks.

head_extras and the custom template escape hatch

head_extras adds raw HTML into <head> (e.g. meta tags, favicon, font preloads) and is developer-controlled config — it must not be populated from request data to avoid XSS.

When html_template is set in InertiaConfig, the custom template owns the entire HTML document and head_extras is ignored. Use html_template only when the structured fields (title, head_extras, mount_id) are insufficient for your needs.

Configuration

Environment Variables

Configure Inertia in your .env file:

# Vite development server URL
VITE_DEV_SERVER=http://localhost:5173

# Frontend entry point
VITE_ENTRY_POINT=src/main.tsx

# Asset version for cache busting
INERTIA_VERSION=1.0

# Development mode (enables HMR)
APP_ENV=development

Bootstrap Setup

In src/bootstrap.rs, configure Inertia:

#![allow(unused)]
fn main() {
use ferro::{App, InertiaConfig};

pub async fn register() {
    // Configure from environment
    let config = InertiaConfig::from_env();
    App::set_inertia_config(config);
}
}

Manual Configuration

Use the builder chain to set specific fields on top of the environment defaults:

#![allow(unused)]
fn main() {
use ferro::InertiaConfig;

let config = InertiaConfig::from_env()
    .title("My App")
    .head_extras(r#"<link rel="icon" href="/favicon.ico">"#)
    .mount_id("app");
}

head_extras accepts raw HTML injected into <head> before </head>. It is developer-controlled config and must not be populated from request data. When html_template is set, head_extras is ignored — the custom template owns the entire <head>.

For full control, override individual builder methods:

#![allow(unused)]
fn main() {
let config = InertiaConfig::from_env()
    .vite_dev_server("http://localhost:5173")
    .entry_point("src/main.tsx")
    .version("1.0")
    .development()
    .mount_id("app");
}

Basic Usage

Rendering Responses

Use Inertia::render() to return an Inertia response:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Inertia};
use serde::Serialize;

#[derive(Serialize)]
pub struct HomeProps {
    pub title: String,
    pub message: String,
}

#[handler]
pub async fn index(req: Request) -> Response {
    Inertia::render(&req, "Home", HomeProps {
        title: "Welcome".to_string(),
        message: "Hello from Ferro!".to_string(),
    })
}
}

The component name ("Home") maps to frontend/src/pages/Home.tsx.

The InertiaProps Derive Macro

For automatic camelCase conversion (standard in JavaScript), use the InertiaProps derive macro:

#![allow(unused)]
fn main() {
use ferro::InertiaProps;

#[derive(InertiaProps)]
pub struct DashboardProps {
    pub user_name: String,      // Serializes as "userName"
    pub total_posts: i32,       // Serializes as "totalPosts"
    pub is_admin: bool,         // Serializes as "isAdmin"
}

#[handler]
pub async fn dashboard(req: Request) -> Response {
    Inertia::render(&req, "Dashboard", DashboardProps {
        user_name: "John".to_string(),
        total_posts: 42,
        is_admin: true,
    })
}
}

In your React component:

interface DashboardProps {
    userName: string;
    totalPosts: number;
    isAdmin: boolean;
}

export default function Dashboard({ userName, totalPosts, isAdmin }: DashboardProps) {
    return <h1>Welcome, {userName}!</h1>;
}

Compile-Time Component Validation

The inertia_response! macro validates that your component exists at compile time:

#![allow(unused)]
fn main() {
use ferro::inertia_response;

#[handler]
pub async fn show(req: Request) -> Response {
    // Validates that frontend/src/pages/Users/Show.tsx exists
    inertia_response!(&req, "Users/Show", UserProps { ... })
}
}

If the component doesn't exist, you get a compile error with fuzzy matching suggestions:

error: Component "Users/Shwo" not found. Did you mean "Users/Show"?

Shared Props

Shared props are data that should be available to every page component, like authentication state, flash messages, and CSRF tokens.

Creating the Middleware

#![allow(unused)]
fn main() {
use ferro::{Middleware, Request, Response, Next, InertiaShared};
use async_trait::async_trait;

pub struct ShareInertiaData;

#[async_trait]
impl Middleware for ShareInertiaData {
    async fn handle(&self, mut request: Request, next: Next) -> Response {
        let mut shared = InertiaShared::new();

        // Add CSRF token
        if let Some(token) = request.csrf_token() {
            shared = shared.csrf(token);
        }

        // Add authenticated user
        if let Some(user) = request.user() {
            shared = shared.auth(AuthUser {
                id: user.id,
                name: user.name.clone(),
                email: user.email.clone(),
            });
        }

        // Add flash messages
        if let Some(flash) = request.session().get::<FlashMessages>("flash") {
            shared = shared.flash(flash);
        }

        // Add custom shared data
        shared = shared.with(serde_json::json!({
            "app_name": "My Application",
            "app_version": "1.0.0",
        }));

        // Store in request extensions
        request.insert(shared);
        next(request).await
    }
}
}

Registering the Middleware

In src/bootstrap.rs:

#![allow(unused)]
fn main() {
use ferro::global_middleware;
use crate::middleware::ShareInertiaData;

pub async fn register() {
    global_middleware!(ShareInertiaData);
}
}

Using Shared Props in Controllers

When InertiaShared is in the request extensions, it's automatically merged:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request) -> Response {
    // Shared props (auth, flash, csrf) are automatically included
    Inertia::render(&req, "Home", HomeProps {
        title: "Welcome".to_string(),
    })
}
}

Accessing Shared Props in React

import { usePage } from '@inertiajs/react';

interface SharedProps {
    auth?: {
        id: number;
        name: string;
        email: string;
    };
    flash?: {
        success?: string;
        error?: string;
    };
    csrf?: string;
}

export default function Layout({ children }) {
    const { auth, flash } = usePage<{ props: SharedProps }>().props;

    return (
        <div>
            {auth && <nav>Welcome, {auth.name}</nav>}
            {flash?.success && <div className="alert-success">{flash.success}</div>}
            {children}
        </div>
    );
}

SavedInertiaContext

When you need to consume the request body (e.g., for validation) before rendering, use SavedInertiaContext:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Inertia, SavedInertiaContext, Validator};

#[handler]
pub async fn store(req: Request) -> Response {
    // Save context BEFORE consuming the request
    let ctx = SavedInertiaContext::from(&req);

    // Now consume the request body
    let data: serde_json::Value = req.json().await?;

    // Validate
    let errors = Validator::new()
        .rule("title", rules![required(), string(), min(1)])
        .rule("content", rules![required(), string()])
        .validate(&data);

    if errors.fails() {
        // Use saved context to render with validation errors
        return Inertia::render_ctx(&ctx, "Posts/Create", CreatePostProps {
            errors: errors.to_json(),
            old: data,
        });
    }

    // Create the post...
    let post = Post::create(&data).await?;

    redirect!(format!("/posts/{}", post.id))
}
}

Common Patterns

Form Handling with Validation

The most common pattern requiring SavedInertiaContext is form validation. Here's the complete flow:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Inertia, SavedInertiaContext, Validator};

#[handler]
pub async fn store(req: Request) -> Response {
    // STEP 1: Save context BEFORE consuming the request body
    // This is required because req.json()/req.input() consumes the body
    let ctx = SavedInertiaContext::from(&req);

    // STEP 2: Now safely consume the request body
    let data: CreateItemRequest = req.json().await?;

    // STEP 3: Validate
    let errors = Validator::new()
        .rule("name", rules![required(), string(), min(1)])
        .rule("email", rules![required(), email()])
        .validate(&data);

    // STEP 4: On validation failure, render with saved context
    if errors.fails() {
        return Inertia::render_ctx(&ctx, "Items/Create", FormProps {
            errors: Some(errors.to_json()),
            old: Some(data),
        });
    }

    // STEP 5: On success, redirect
    let item = Item::create(&data).await?;
    Inertia::redirect_ctx(&ctx, &format!("/items/{}", item.id))
}
}

Why SavedInertiaContext? The request body in Rust can only be read once. Once you call req.json() or req.input(), the body is consumed. But Inertia::render() needs request metadata (headers, URL). SavedInertiaContext captures this metadata before body consumption.

Frontend Setup

Project Structure

your-app/
├── src/                    # Rust backend
│   ├── controllers/
│   ├── middleware/
│   └── main.rs
├── frontend/               # React frontend
│   ├── src/
│   │   ├── pages/          # Inertia page components
│   │   │   ├── Home.tsx
│   │   │   ├── Dashboard.tsx
│   │   │   └── Users/
│   │   │       ├── Index.tsx
│   │   │       └── Show.tsx
│   │   ├── components/     # Shared components
│   │   ├── layouts/        # Layout components
│   │   └── main.tsx        # Entry point
│   ├── package.json
│   └── vite.config.ts
└── Cargo.toml

Entry Point (main.tsx)

import { createInertiaApp } from '@inertiajs/react';
import { createRoot } from 'react-dom/client';

createInertiaApp({
    resolve: (name) => {
        const pages = import.meta.glob(['./pages/**/*.tsx', '!**/*.test.tsx'], { eager: true });
        return pages[`./pages/${name}.tsx`];
    },
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />);
    },
});

Vite Configuration

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [react()],
    server: {
        port: 5173,
        strictPort: true,
    },
    build: {
        manifest: true,
        outDir: '../public/build',
        rollupOptions: {
            input: 'src/main.tsx',
        },
    },
});

Package Dependencies

{
    "dependencies": {
        "@inertiajs/react": "^1.0.0",
        "react": "^18.2.0",
        "react-dom": "^18.2.0"
    },
    "devDependencies": {
        "@types/react": "^18.2.0",
        "@types/react-dom": "^18.2.0",
        "@vitejs/plugin-react": "^4.0.0",
        "typescript": "^5.0.0",
        "vite": "^5.0.0"
    }
}

Use the Inertia Link component for client-side navigation:

import { Link } from '@inertiajs/react';

export default function Navigation() {
    return (
        <nav>
            <Link href="/">Home</Link>
            <Link href="/about">About</Link>
            <Link href="/users" method="get" as="button">Users</Link>
        </nav>
    );
}

Programmatic Navigation

import { router } from '@inertiajs/react';

function handleClick() {
    router.visit('/dashboard');
}

function handleSubmit(data) {
    router.post('/posts', data, {
        onSuccess: () => {
            // Handle success
        },
    });
}

Partial Reloads

Inertia supports partial reloads to refresh only specific props without a full page reload.

Requesting Partial Data

import { router } from '@inertiajs/react';

// Only reload the 'users' prop
router.reload({ only: ['users'] });

// Reload specific props
router.visit('/dashboard', {
    only: ['notifications', 'messages'],
});

Server-Side Handling

Ferro automatically handles partial reload requests. The X-Inertia-Partial-Data header specifies which props to return:

#![allow(unused)]
fn main() {
#[handler]
pub async fn dashboard(req: Request) -> Response {
    // All props are computed, but only requested ones are sent
    Inertia::render(&req, "Dashboard", DashboardProps {
        user: get_user().await?,          // Always sent on full load
        notifications: get_notifications().await?,  // Only if requested
        stats: get_stats().await?,        // Only if requested
    })
}
}

Version Conflict Handling

When your assets change (new deployment), Inertia uses versioning to force a full page reload.

Checking Version

#![allow(unused)]
fn main() {
use ferro::Inertia;

#[handler]
pub async fn index(req: Request) -> Response {
    // Check if client version matches
    if let Some(response) = Inertia::check_version(&req, "1.0", "/") {
        return response;  // Returns 409 Conflict
    }

    Inertia::render(&req, "Home", HomeProps { ... })
}
}

Middleware Approach

#![allow(unused)]
fn main() {
pub struct InertiaVersionCheck;

#[async_trait]
impl Middleware for InertiaVersionCheck {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let current_version = std::env::var("INERTIA_VERSION")
            .unwrap_or_else(|_| "1.0".to_string());

        if let Some(response) = Inertia::check_version(&request, &current_version, "/") {
            return response;
        }

        next(request).await
    }
}
}

Forms

Basic Form Handling

import { useForm } from '@inertiajs/react';

export default function CreatePost() {
    const { data, setData, post, processing, errors } = useForm({
        title: '',
        content: '',
    });

    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        post('/posts');
    }

    return (
        <form onSubmit={handleSubmit}>
            <input
                value={data.title}
                onChange={e => setData('title', e.target.value)}
            />
            {errors.title && <span>{errors.title}</span>}

            <textarea
                value={data.content}
                onChange={e => setData('content', e.target.value)}
            />
            {errors.content && <span>{errors.content}</span>}

            <button type="submit" disabled={processing}>
                Create Post
            </button>
        </form>
    );
}

Server-Side Validation Response

#![allow(unused)]
fn main() {
use ferro::{Inertia, SavedInertiaContext};

#[handler]
pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from(&req);
    let data: CreatePostRequest = req.json().await?;

    let errors = validate_post(&data);
    if errors.fails() {
        // Return to form with errors
        return Inertia::render_ctx(&ctx, "Posts/Create", CreatePostProps {
            errors: errors.to_json(),
        });
    }

    let post = Post::create(&data).await?;
    redirect!(format!("/posts/{}", post.id))
}
}

TypeScript Generation

Ferro can generate TypeScript types from your Rust props:

ferro generate-types

This creates type definitions for your InertiaProps structs:

// Generated: frontend/src/types/props.d.ts
export interface HomeProps {
    title: string;
    message: string;
}

export interface DashboardProps {
    userName: string;
    totalPosts: number;
    isAdmin: boolean;
}

Automatic Type Generation

When running ferro serve, TypeScript types are automatically regenerated whenever you modify a file containing InertiaProps structs. Changes are debounced (500ms) to avoid excessive regeneration.

You'll see [types] Regenerated N type(s) in the console when types are updated.

To disable automatic regeneration:

ferro serve --skip-types

Note: Type watching is disabled in --backend-only mode since there's no frontend to update.

Custom Types

The type generator automatically discovers structs with #[derive(InertiaProps)]. For nested types that don't have this derive, you have two options.

Create manual TypeScript type files for complex domain types:

// frontend/src/types/theme-config.ts
export interface ThemeConfig {
  primaryColor?: string;
  fontFamily?: string;
  borderRadius?: number;
}

export interface BottomNavConfig {
  enabled: boolean;
  items: NavItem[];
}

Then import in your components:

import { ThemeConfig } from '@/types/theme-config';
import { DashboardProps } from '@/types/props';  // Auto-generated

interface Props extends DashboardProps {
  themeConfig: ThemeConfig;
}

Option 2: Add InertiaProps Derive

For shared types used in multiple props, add the derive:

#![allow(unused)]
fn main() {
#[derive(Serialize, InertiaProps)]
pub struct ThemeConfig {
    pub primary_color: Option<String>,
    pub font_family: Option<String>,
}
}

This will include ThemeConfig in the generated types.

Note: The generator only scans src/ directory for InertiaProps. Types in libraries or other locations need manual definitions.

Generated Type Utilities

The generated props.d.ts includes utility types:

// Arbitrary JSON values
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

// Validation errors
export type ValidationErrors = Record<string, string[]>;

Use these in your components:

import { JsonValue, ValidationErrors } from '@/types/props';

interface FormProps {
  errors: ValidationErrors | null;
  metadata: JsonValue;
}

Development vs Production

Development Mode

In development, Ferro serves the Vite dev server with HMR:

#![allow(unused)]
fn main() {
let config = InertiaConfig::from_env()
    .development()
    .vite_dev_server("http://localhost:5173");
}

The rendered HTML includes:

<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main.tsx"></script>

Production Mode

In production, Ferro uses the built manifest:

#![allow(unused)]
fn main() {
let config = InertiaConfig::from_env()
    .production();
}

The rendered HTML includes hashed assets:

<script type="module" src="/build/assets/main-abc123.js"></script>
<link rel="stylesheet" href="/build/assets/main-def456.css">

JSON API Fallback

For testing or API clients that need raw JSON data from Inertia routes, enable JSON fallback:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, Inertia};

#[handler]
pub async fn show(req: Request, post: Post) -> Response {
    Inertia::render_with_json_fallback(&req, "Posts/Show", ShowProps { post })
}
}

When enabled:

  • Requests with X-Inertia: true header → Normal Inertia JSON response
  • Requests with Accept: application/json (no X-Inertia) → Raw props as JSON
  • Browser requests → Full HTML page

This is useful for:

  • API testing with curl or Postman
  • Hybrid apps that sometimes need raw JSON
  • Debug tooling

Example with curl:

# Get raw JSON props
curl -H "Accept: application/json" http://localhost:3000/posts/1

# Get normal Inertia response
curl -H "X-Inertia: true" http://localhost:3000/posts/1

# Get HTML page
curl http://localhost:3000/posts/1

Note: This is opt-in per route. Consider security implications before enabling on routes that return sensitive data.

Example: Complete CRUD

Routes

#![allow(unused)]
fn main() {
use ferro::{get, post, put, delete};

pub fn routes() -> Vec<Route> {
    vec![
        get!("/posts", controllers::posts::index),
        get!("/posts/create", controllers::posts::create),
        post!("/posts", controllers::posts::store),
        get!("/posts/{post}", controllers::posts::show),
        get!("/posts/{post}/edit", controllers::posts::edit),
        put!("/posts/{post}", controllers::posts::update),
        delete!("/posts/{post}", controllers::posts::destroy),
    ]
}
}

Controller

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, redirect, Inertia, SavedInertiaContext, InertiaProps};

#[derive(InertiaProps)]
pub struct IndexProps {
    pub posts: Vec<Post>,
}

#[derive(InertiaProps)]
pub struct ShowProps {
    pub post: Post,
}

#[derive(InertiaProps)]
pub struct FormProps {
    pub post: Option<Post>,
    pub errors: Option<serde_json::Value>,
}

#[handler]
pub async fn index(req: Request) -> Response {
    let posts = Post::all().await?;
    Inertia::render(&req, "Posts/Index", IndexProps { posts })
}

#[handler]
pub async fn create(req: Request) -> Response {
    Inertia::render(&req, "Posts/Create", FormProps {
        post: None,
        errors: None,
    })
}

#[handler]
pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from(&req);
    let data: CreatePostInput = req.json().await?;

    match Post::create(&data).await {
        Ok(post) => redirect!(format!("/posts/{}", post.id)),
        Err(errors) => Inertia::render_ctx(&ctx, "Posts/Create", FormProps {
            post: None,
            errors: Some(errors.to_json()),
        }),
    }
}

#[handler]
pub async fn show(post: Post, req: Request) -> Response {
    Inertia::render(&req, "Posts/Show", ShowProps { post })
}

#[handler]
pub async fn edit(post: Post, req: Request) -> Response {
    Inertia::render(&req, "Posts/Edit", FormProps {
        post: Some(post),
        errors: None,
    })
}

#[handler]
pub async fn update(post: Post, req: Request) -> Response {
    let ctx = SavedInertiaContext::from(&req);
    let data: UpdatePostInput = req.json().await?;

    match post.update(&data).await {
        Ok(post) => redirect!(format!("/posts/{}", post.id)),
        Err(errors) => Inertia::render_ctx(&ctx, "Posts/Edit", FormProps {
            post: Some(post),
            errors: Some(errors.to_json()),
        }),
    }
}

#[handler]
pub async fn destroy(post: Post, _req: Request) -> Response {
    post.delete().await?;
    redirect!("/posts")
}
}

Redirects

For form submissions (POST, PUT, PATCH, DELETE) that should redirect after success, use Inertia::redirect():

#![allow(unused)]
fn main() {
use ferro::{handler, Inertia, Request, Response, Auth};

#[handler]
pub async fn login(req: Request) -> Response {
    // ... validation and auth logic ...

    Auth::login(user.id);
    Inertia::redirect(&req, "/dashboard")
}

#[handler]
pub async fn logout(req: Request) -> Response {
    Auth::logout();
    Inertia::redirect(&req, "/")
}
}

Why Not redirect!()?

The redirect!() macro doesn't have access to the request context, so it can't detect Inertia XHR requests. For non-Inertia routes (API endpoints, traditional forms), redirect!() works fine.

For Inertia pages, always use Inertia::redirect() which:

  • Detects Inertia XHR requests via the X-Inertia header
  • Uses 303 status for POST/PUT/PATCH/DELETE (forces GET on redirect)
  • Includes proper X-Inertia: true response header

With Saved Context

If you've consumed the request with req.input(), use the saved context:

#![allow(unused)]
fn main() {
use ferro::{handler, Inertia, Request, Response, SavedInertiaContext};

#[handler]
pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from(&req);
    let form: CreateForm = req.input().await?;

    // ... create record ...

    Inertia::redirect_ctx(&ctx, "/items")
}
}

Best Practices

  1. Use InertiaProps derive - Automatic camelCase conversion matches JavaScript conventions
  2. Save context before consuming request - Use SavedInertiaContext for validation flows
  3. Share common data via middleware - Auth, flash, CSRF in ShareInertiaData
  4. Organize pages in folders - Posts/Index.tsx, Posts/Show.tsx for clarity
  5. Use compile-time validation - inertia_response! macro catches typos early
  6. Handle version conflicts - Ensure smooth deployments with version checking
  7. Keep props minimal - Only send what the page needs
  8. Use partial reloads - Optimize updates by requesting only changed data
  9. Use Inertia::redirect() for form success - Ensures proper 303 status for Inertia XHR requests

Troubleshooting

Request Body Already Consumed

Symptom: Error when calling Inertia::render() after req.json() or req.input().

Cause: The request body was consumed before rendering. In Rust, request bodies can only be read once.

Solution: Use SavedInertiaContext to capture request metadata before consuming the body:

#![allow(unused)]
fn main() {
let ctx = SavedInertiaContext::from(&req);  // Save first
let data = req.json().await?;                        // Then consume
Inertia::render_ctx(&ctx, "Component", props)        // Use saved context
}

Validation Errors Not Displaying

Symptom: Form validation errors are lost after redirect.

Cause: Using redirect!() after validation failure instead of re-rendering with errors.

Solution: On validation failure, render the form again with errors. On success, redirect:

#![allow(unused)]
fn main() {
if errors.fails() {
    // Re-render form with errors (don't redirect)
    return Inertia::render_ctx(&ctx, "Form", FormProps {
        errors: Some(errors.to_json()),
        old: Some(data),
    });
}

// Only redirect on success
Inertia::redirect_ctx(&ctx, "/success")
}

Props Not Updating After Navigation

Symptom: Page shows stale data after Inertia navigation.

Cause: Browser caching or partial reload configuration issue.

Solution: Check that your handler returns fresh data and consider using router.reload() on the frontend to force a refresh:

import { router } from '@inertiajs/react';

// Force reload current page data
router.reload();

// Reload only specific props
router.reload({ only: ['items'] });

MCP Tools

Use these tools to inspect Inertia props structs and manage TypeScript type generation without running the CLI.

list_props

Returns all structs with #[derive(InertiaProps)] found in src/, including field names, Rust types, their TypeScript equivalents, and which Inertia components use each props struct. Use this to audit the props surface before adding new fields or debugging type mismatches.

inspect_props

Returns a detailed breakdown of a single props struct: source code, TypeScript interface preview, which handlers pass it to Inertia::render(), and a validation report comparing the Rust definition to any existing TypeScript interface in frontend/src/types/. Use this to catch mismatches between Rust and TypeScript before they cause runtime errors.

generate_types

Generates or regenerates frontend/src/types/inertia-props.ts from all InertiaProps structs found in the project. Supports a dry_run parameter to preview changes. Equivalent to ferro generate-types but runs as an MCP tool without requiring the CLI or a running server.