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}; use ferro::inertia::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}; use ferro::inertia::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}; use ferro::inertia::{Inertia, SavedInertiaContext}; use ferro::validation::{Validator, rules}; #[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)) } }
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', { 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::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::{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;
}
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">
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}; use ferro::inertia::{Inertia, SavedInertiaContext}; use ferro::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::{Inertia, Request, Response, Auth}; pub async fn login(req: Request) -> Response { // ... validation and auth logic ... Auth::login(user.id); Inertia::redirect(&req, "/dashboard") } 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::{Inertia, Request, Response, SavedInertiaContext}; 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