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:
- Your controller returns an Inertia response with a component name and props
- On the first request, a full HTML page is rendered with the initial data
- On subsequent requests, only JSON is returned
- The client-side adapter swaps components without full page reloads
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
#![allow(unused)] fn main() { use ferro::InertiaConfig; let config = InertiaConfig { vite_dev_server: "http://localhost:5173".to_string(), entry_point: "src/main.tsx".to_string(), version: "1.0".to_string(), development: true, html_template: None, }; }
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_request(&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_request(&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()orreq.input(), the body is consumed. ButInertia::render()needs request metadata (headers, URL).SavedInertiaContextcaptures 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"
}
}
Links and Navigation
Inertia Link Component
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, ¤t_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_request(&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-onlymode 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.
Option 1: Manual Type Files (Recommended)
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 { development: true, vite_dev_server: "http://localhost:5173".to_string(), // ... }; }
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 { development: false, // ... }; }
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: trueheader → 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_request(&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_request(&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-Inertiaheader - Uses 303 status for POST/PUT/PATCH/DELETE (forces GET on redirect)
- Includes proper
X-Inertia: trueresponse 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
- Use InertiaProps derive - Automatic camelCase conversion matches JavaScript conventions
- Save context before consuming request - Use
SavedInertiaContextfor validation flows - Share common data via middleware - Auth, flash, CSRF in
ShareInertiaData - Organize pages in folders -
Posts/Index.tsx,Posts/Show.tsxfor clarity - Use compile-time validation -
inertia_response!macro catches typos early - Handle version conflicts - Ensure smooth deployments with version checking
- Keep props minimal - Only send what the page needs
- Use partial reloads - Optimize updates by requesting only changed data
- 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_request(&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.