{"id":50309420,"url":"https://github.com/climactic/veer","last_synced_at":"2026-05-29T20:00:35.443Z","repository":{"id":359823457,"uuid":"1247647035","full_name":"Climactic/Veer","owner":"Climactic","description":"The Inertia.js server-side protocol superset, for Rust.","archived":false,"fork":false,"pushed_at":"2026-05-26T06:47:41.000Z","size":468,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-28T19:38:35.341Z","etag":null,"topics":["axum","inertiajs","react","rust","ssr","vite","xhr"],"latest_commit_sha":null,"homepage":"","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Climactic.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE-APACHE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"Climactic","ko_fi":"ClimacticCo"}},"created_at":"2026-05-23T15:41:04.000Z","updated_at":"2026-05-26T06:46:39.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/Climactic/Veer","commit_stats":null,"previous_names":["climactic/veer"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/Climactic/Veer","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Climactic%2FVeer","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Climactic%2FVeer/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Climactic%2FVeer/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Climactic%2FVeer/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Climactic","download_url":"https://codeload.github.com/Climactic/Veer/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Climactic%2FVeer/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33668186,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["axum","inertiajs","react","rust","ssr","vite","xhr"],"created_at":"2026-05-28T19:30:35.308Z","updated_at":"2026-05-29T20:00:35.428Z","avatar_url":"https://github.com/Climactic.png","language":"Rust","funding_links":["https://github.com/sponsors/Climactic","https://ko-fi.com/ClimacticCo","https://github.com/sponsors/climactic"],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n\u003cimg src=\".github/veer.webp\" alt=\"Veer\" width=\"100%\" /\u003e\n\n# Veer\n\n**The Inertia.js server-side protocol superset, for Rust.**\n\nBuild modern single-page apps in React, Vue, or Svelte — without writing a JSON API, a client-side router, or a single `fetch`. Your Rust handlers return typed props, and the frontend hydrates as if the page was server-rendered. Because it was.\n\n[![Discord](https://img.shields.io/badge/Discord-Join%20Us-5865F2?style=for-the-badge\u0026logo=discord\u0026logoColor=white)](http://go.climactic.co/discord)\n[![Latest Version on crates.io](https://img.shields.io/crates/v/veer.svg?style=for-the-badge)](https://crates.io/crates/veer)\n[![GitHub CI Status](https://img.shields.io/github/actions/workflow/status/climactic/veer/ci.yml?branch=main\u0026label=ci\u0026style=for-the-badge)](https://github.com/climactic/veer/actions?query=workflow%3Aci+branch%3Amain)\n[![docs.rs](https://img.shields.io/docsrs/veer?style=for-the-badge)](https://docs.rs/veer)\n[![MSRV 1.85](https://img.shields.io/badge/MSRV-1.85-blue?style=for-the-badge)](https://www.rust-lang.org)\n[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?style=for-the-badge\u0026logo=github)](https://github.com/sponsors/climactic)\n[![Support on Ko-fi](https://img.shields.io/badge/Support-Ko--fi-FF5E5B?style=for-the-badge\u0026logo=ko-fi\u0026logoColor=white)](https://ko-fi.com/ClimacticCo)\n\n\u003c/div\u003e\n\n## 📖 Table of Contents\n\n- ✨ [What is Inertia, and why a Rust adapter](#-what-is-inertia-and-why-a-rust-adapter)\n- 📦 [Installation](#-installation)\n- 🚀 [Quick Start](#-quick-start)\n- 🍳 [Cookbook](#-cookbook)\n- 🎛️ [Feature Flags](#️-feature-flags)\n- 🏗️ [Architecture](#️-architecture)\n- ⚠️ [Caveats](#️-caveats)\n- 🧪 [Example App](#-example-app)\n- 🗺️ [Status \u0026 Roadmap](#️-status--roadmap)\n- 🙌 [Acknowledgements](#-acknowledgements)\n- 🧪 [Testing](#-testing)\n- 📋 [Changelog](#-changelog)\n- 🤝 [Contributing](#-contributing)\n- 🔒 [Security Vulnerabilities](#-security-vulnerabilities)\n- 💖 [Support This Project](#-support-this-project)\n- ⭐ [Star History](#-star-history)\n- 📄 [License](#-license)\n\n## ✨ What is Inertia, and why a Rust adapter\n\n[Inertia.js](https://inertiajs.com) is a glue layer that lets a classic server-rendered backend drive a modern SPA frontend. The server returns a page object (component name + props); the official Inertia client adapter for React/Vue/Svelte takes care of mounting the component, hydrating props, intercepting links, and making subsequent navigations into JSON XHRs.\n\n`veer` is a clean-room Rust implementation of the server side of the [Inertia v3 protocol](https://inertiajs.com/the-protocol). It targets [axum](https://github.com/tokio-rs/axum) out of the box; the protocol core is framework-agnostic, so adapters for other Rust web frameworks slot in beside it.\n\n```text\n   ┌─────────────────────────┐                       ┌─────────────────────────┐\n   │  Rust handler           │  ── page object ──▶   │  Inertia client (JS)    │\n   │  inertia.render(...)    │       (JSON)          │  React / Vue / Svelte   │\n   └─────────────────────────┘                       └─────────────────────────┘\n              ▲                                                  │\n              └─────────────  navigation XHR  ───────────────────┘\n```\n\n**Highlights**\n\n- 🦀 Pure Rust server-side implementation of Inertia v3\n- ⚡ First-class [axum](https://github.com/tokio-rs/axum) adapter (extractor + tower layer)\n- 🧩 Framework-agnostic protocol core — drop new adapters in beside the axum one\n- 📦 Partial reloads, deferred props, merge props, encrypted/clear history\n- 🖥️ SSR via `@inertiajs/server` (Node or Bun) with graceful fallback\n- ⚙️ Vite dev + production manifest integration that mirrors Laravel's `@vite`\n- 📤 File uploads via typed `InertiaForm` + a streaming `MultipartStream` extractor\n- ✅ Validation flash for `validator` and `garde`, plus a cookie-based session store\n- 🪢 End-to-end TypeScript bindings: typed page props + Ziggy-style route helpers, generated from Rust\n\n## 📦 Installation\n\nAdd `veer` to your `Cargo.toml`:\n\n```toml\n[dependencies]\nveer = \"0.1\"\n```\n\nOr with `cargo add`:\n\n```bash\ncargo add veer\n```\n\nThe default feature set includes the axum adapter. See [Feature flags](#️-feature-flags) for everything else.\n\n## 🚀 Quick Start\n\n```rust,no_run\nuse axum::{routing::get, Router};\nuse veer::{Inertia, InertiaConfig, InertiaLayer, MinimalRootView};\n\n#[tokio::main]\nasync fn main() {\n    let cfg = InertiaConfig::new()\n        .version(|| \"1\".into())\n        .root_view(\n            MinimalRootView::new()\n                .title(\"Acme\")\n                .vite_entry(\"/src/main.tsx\"),\n        );\n\n    let app = Router::new()\n        .route(\"/\", get(|inertia: Inertia| async move {\n            inertia.render(\"Home\", serde_json::json!({ \"msg\": \"hello\" }))\n        }))\n        .layer(InertiaLayer::new(cfg));\n\n    let listener = tokio::net::TcpListener::bind(\"0.0.0.0:3000\").await.unwrap();\n    axum::serve(listener, app).await.unwrap();\n}\n```\n\nThe `Inertia` extractor reads the request. `render(component, props)` returns a builder. The layer handles the rest of the protocol — initial HTML on first load, JSON on XHR navigations, 409 for asset-version mismatches, 303 for POST-redirects, version checks, partial reloads.\n\n## 🍳 Cookbook\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eValidation + flash, Laravel-style\u003c/b\u003e\u003c/summary\u003e\n\n```rust,ignore\nasync fn users_create(\n    inertia: Inertia,\n    InertiaForm(body): InertiaForm\u003cNewUser\u003e,\n) -\u003e impl IntoResponse {\n    if let Err(errors) = body.validate() {\n        return inertia.with_errors(errors).redirect(\"/users/new\");\n    }\n    create_user(body).await;\n    inertia\n        .redirect(\"/users\")\n        .with_flash(\"success\", serde_json::json!(\"User created\"))\n}\n```\n\n`InertiaForm` accepts `application/json`, `application/x-www-form-urlencoded`, and (with the `multipart` feature) `multipart/form-data`. The same handler works regardless of how the Inertia client serialized the request.\n\nOn the frontend, `usePage().props.errors` and `usePage().props.flash` are auto-populated whenever a `SessionStore` is configured — no extra wiring.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eFile uploads\u003c/b\u003e\u003c/summary\u003e\n\nEnable the `multipart` feature and add `UploadedFile` fields to your typed struct:\n\n```rust,ignore\nuse veer::{InertiaForm, UploadedFile};\n\n#[derive(serde::Deserialize)]\nstruct CreateAvatar {\n    user_id: String,\n    avatar: UploadedFile,\n}\n\nasync fn upload_avatar(\n    inertia: Inertia,\n    InertiaForm(form): InertiaForm\u003cCreateAvatar\u003e,\n) -\u003e impl IntoResponse {\n    save_avatar(\u0026form.user_id, \u0026form.avatar.bytes, form.avatar.filename.as_deref()).await;\n    inertia.redirect(\"/profile\").with_flash(\"success\", json!(\"Avatar updated\"))\n}\n```\n\nInertia's `useForm` automatically switches to `multipart/form-data` when any field is a `File` or `Blob`. The same handler accepts JSON when no file is attached and multipart when one is — no separate route required.\n\nFor large or unbounded uploads where in-memory buffering isn't acceptable, use `MultipartStream` instead:\n\n```rust,ignore\nuse veer::MultipartStream;\n\nasync fn huge_upload(MultipartStream(mut m): MultipartStream) -\u003e impl IntoResponse {\n    while let Some(field) = m.next_field().await.unwrap() {\n        // pipe field.chunk().await into S3, disk, wherever — no buffering\n    }\n    // ...\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eExternal redirect (OAuth, billing, etc.)\u003c/b\u003e\u003c/summary\u003e\n\n```rust,ignore\nasync fn oauth_start(inertia: Inertia) -\u003e impl IntoResponse {\n    inertia.location(\"https://accounts.google.com/o/oauth2/v2/auth?...\")\n}\n```\n\nReturns 409 + `X-Inertia-Location`. The Inertia client honors this with a hard navigation.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003ePartial reloads, lazy \u0026 deferred props\u003c/b\u003e\u003c/summary\u003e\n\n```rust,ignore\ninertia\n    .render(\"Users/Index\", serde_json::json!({ \"users\": users }))\n    .lazy(\"stats\", || async { json!({ \"hits\": load_stats().await }) })\n    .deferred(\"expensive\", \"dashboard\", || async { json!(load_expensive().await) })\n    .merge(\"notifications\")\n```\n\n| Method | Behavior |\n|---|---|\n| `lazy(key, closure)` (alias `optional`) | Closure only runs when the client requests this key via a partial reload |\n| `deferred(key, group, closure)` | First response advertises the key under `deferredProps[group]`; client then issues a follow-up reload that resolves it |\n| `merge(key)` | Marks the key so the client merges the value into existing state instead of replacing |\n| `encrypt_history()` / `clear_history()` | Set the Inertia v2+ history-state primitives |\n| `no_ssr()` | Skip SSR for this response only |\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eShared props (auth, app name, feature flags)\u003c/b\u003e\u003c/summary\u003e\n\n```rust,ignore\nuse veer::shared::shared_props_fn;\n\nlet cfg = InertiaConfig::new()\n    .shared(shared_props_fn(|_req| async move {\n        serde_json::json!({\n            \"auth\": { \"user\": current_user().await },\n            \"app\": { \"name\": \"Acme\" },\n        })\n    }));\n```\n\nShared props merge under per-response props (handler props win on key collision).\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eSSR via @inertiajs/server (Node or Bun)\u003c/b\u003e\u003c/summary\u003e\n\nEnable the `ssr` feature and point at the SSR service:\n\n```rust,ignore\nuse veer::ssr::http::HttpSsrClient;\n\nlet cfg = InertiaConfig::new()\n    .ssr(HttpSsrClient::new(\"http://127.0.0.1:13714/render\"));\n```\n\nSSR failures fall back to client-side rendering by default. Set `ssr_required(true)` for hard-fail behavior.\n\nFor end-to-end SSR you'll usually combine this with `ViteRootView` (next entry) — `ViteRootView` inlines the SSR body verbatim and emits the `\u003cscript data-page\u003e` mount the Inertia v3 client expects.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eVite integration (dev + production)\u003c/b\u003e\u003c/summary\u003e\n\n`ViteRootView` is a drop-in `RootView` that mirrors what Laravel's `@vite` + `@viteReactRefresh` Blade directives do. Two modes:\n\n**Dev** — cross-origin script tags pointing at the Vite dev server, plus (opt-in) the React refresh preamble required when the HTML shell is served off-origin:\n\n```rust,ignore\nuse veer::ViteRootView;\n\nlet cfg = InertiaConfig::new()\n    .root_view(\n        ViteRootView::dev()\n            .title(\"Acme\")\n            .entry(\"frontend/app.tsx\")\n            .dev_server(\"http://localhost:5173\")\n            .react_refresh(true), // needed for @vitejs/plugin-react cross-origin\n    );\n```\n\n**Production** — `vite build` writes `dist/.vite/manifest.json`. `ViteRootView::production` walks it from the entry, emitting the entry script, its CSS, and `\u003clink rel=\"modulepreload\"\u003e` hints for transitively imported chunks:\n\n```rust,ignore\nuse veer::{ViteManifest, ViteRootView};\n\nlet manifest = ViteManifest::load(\"dist/.vite/manifest.json\")?;\nlet version  = manifest.hash(); // changes whenever any chunk hash changes\n\nlet cfg = InertiaConfig::new()\n    .version(move || version.clone().into()) // bumps client asset cache on rebuild\n    .root_view(\n        ViteRootView::production()\n            .title(\"Acme\")\n            .entry(\"frontend/app.tsx\")\n            .manifest(manifest)\n            .asset_base(\"/build\"),\n    );\n\n// Serve the built bundle next to the routes:\n// .nest_service(\"/build\", tower_http::services::ServeDir::new(\"dist\"))\n```\n\nWiring `version` to `manifest.hash()` means any rebuild auto-invalidates clients via the Inertia 409 + force-reload protocol — no manual version bumps.\n\nFor SSR in production, build the sidecar with `vite build --ssr frontend/ssr.tsx` and run the resulting bundle (`bun dist/ssr/ssr.js` or `node …`). Same `:13714/render` interface as dev.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eCSRF protection (Inertia/axios)\u003c/b\u003e\u003c/summary\u003e\n\nThe Inertia client uses axios, which reads an `XSRF-TOKEN` cookie and echoes it\nback in an `X-XSRF-TOKEN` header on every mutating request — no frontend code\nneeded. `CsrfLayer` is the server side of that convention: it issues the cookie\nand verifies the header using a stateless, HMAC-signed double-submit token (no\nserver-side session required).\n\nEnable the `csrf` feature and stack the layer next to `InertiaLayer`:\n\n```toml\nveer = { version = \"0.1\", features = [\"csrf\"] }\n```\n\n```rust,ignore\nuse veer::{CsrfLayer, InertiaLayer};\n\nlet app = router()\n    .with_state(state)\n    .layer(InertiaLayer::new(cfg))\n    .layer(CsrfLayer::new(secret));   // 32-byte secret; outermost layer\n```\n\nOn a token mismatch the layer short-circuits with `419` (the Laravel/Inertia\nconvention for an expired token) before the handler runs. Safe methods\n(GET/HEAD/OPTIONS/TRACE) are never checked. For endpoints that can't carry the\nheader (third-party webhooks), exclude them:\n\n```rust,ignore\nCsrfLayer::new(secret).exclude(\"/webhooks\")\n```\n\nThe cookie is JS-readable by design (so axios can echo it); it is `Secure` +\n`SameSite=Lax` by default — call `.secure(false)` for local HTTP dev.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eEmbedded assets (single-binary deploy)\u003c/b\u003e\u003c/summary\u003e\n\nFor a single self-contained binary, embed the built frontend instead of serving\nit from disk. The manifest embeds via `include_str!`; `EmbeddedAssets` serves the\nbytes. Enable the `embed` feature:\n\n```toml\nveer = { version = \"0.1\", features = [\"embed\"] }\nrust-embed = \"8\"\n```\n\n```rust,ignore\nuse rust_embed::RustEmbed;\nuse veer::{EmbeddedAssets, ViteManifest, ViteRootView};\n\n#[derive(RustEmbed)]\n#[folder = \"dist/\"]\nstruct Assets;\n\nlet manifest: ViteManifest = include_str!(\"../dist/.vite/manifest.json\").parse()?;\nlet version  = manifest.hash();\n\nlet cfg = InertiaConfig::new()\n    .version(move || version.clone().into())\n    .root_view(ViteRootView::production().entry(\"frontend/app.tsx\").manifest(manifest));\n\nlet app = router()\n    .with_state(state)\n    .layer(InertiaLayer::new(cfg))\n    // Replaces `.nest_service(\"/build\", ServeDir::new(\"dist\"))`:\n    .nest_service(\"/build\", EmbeddedAssets::new(|p| Assets::get(p).map(|f| f.data)));\n```\n\n`EmbeddedAssets` takes any `Fn(\u0026str) -\u003e Option\u003cCow\u003c'static, [u8]\u003e\u003e`, so it works\nwith `rust-embed`, `include_dir`, or a plain map — Veer depends on none of them.\nIt sets `Content-Type` from the file extension and serves content-hashed assets\nwith `Cache-Control: public, max-age=31536000, immutable`.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eEnd-to-end TypeScript bindings (Wayfinder-style)\u003c/b\u003e\u003c/summary\u003e\n\nEnable the `ts` feature and the frontend gets an auto-generated `gen/` directory containing every page's props type, a discriminated `Pages` union, and one TypeScript module per controller with method-aware URL builders.\n\n```toml\n[dependencies]\nveer = { version = \"0.1\", features = [\"ts\"] }\nts-rs = \"10\"\n```\n\nAnnotate each prop struct and register it as a page:\n\n```rust,ignore\nuse serde::Serialize;\nuse ts_rs::TS;\n\n#[derive(Serialize, TS)]\n#[ts(export)]\n#[serde(rename_all = \"camelCase\")]\npub struct UsersIndexProps {\n    pub users: Vec\u003cUser\u003e,\n}\nveer::register_page!(UsersIndexProps, \"Users/Index\");\n```\n\nThen build your router using `veer::Router` — same fluent API as `axum::Router`, but every route gets a name and method so the codegen knows about it:\n\n```rust,ignore\nuse veer::Method::*;\n\npub fn router() -\u003e veer::Router\u003cAppState\u003e {\n    veer::Router::new()\n        .named_route(GET,    \"users.index\",   \"/users\",      users_index)\n        .named_route(GET,    \"users.show\",    \"/users/:id\",  users_show)\n        .named_route(GET,    \"users.create\",  \"/users/new\",  users_create)\n        .named_route(POST,   \"users.store\",   \"/users\",      users_store)\n        .named_route(PATCH,  \"users.update\",  \"/users/:id\",  users_update)\n        .named_route(DELETE, \"users.destroy\", \"/users/:id\",  users_destroy)\n}\n\n// In main():\nlet app = router().build().with_state(state).layer(InertiaLayer::new(cfg));\n```\n\n`build()` returns a regular `axum::Router`, so `.with_state` / `.layer` / `.merge` / anything else just works. Same-path multi-method calls (GET + POST on `/users`) are merged into a single `MethodRouter` automatically — no panic on duplicate paths.\n\n`ts-rs` mirrors `#[serde(rename_all)]` into the generated TypeScript, so the same struct drives both wire format and type. Route names follow the Laravel resource convention (`index`/`show`/`create`/`store`/`update`/`destroy`) so they don't collide with JavaScript reserved words.\n\nAdd a tiny binary that builds the router (so the runtime route registry populates) and then emits the bundle. Drop this in `src/bin/gen-bindings.rs` inside your app crate — `cargo` auto-discovers it:\n\n```rust,ignore\n// src/bin/gen-bindings.rs\nfn main() {\n    let _ = my_app::router().build();   // populate registry\n    veer::bindings::generate_split(\"./frontend/gen\").unwrap();\n}\n```\n\nGenerate with:\n\n```bash\ncargo run --bin gen-bindings\n```\n\nThe codegen has to run inside *your* app binary (not a standalone CLI), because the route registry is populated at runtime by `Router::build()` and the page registrations are link-time `inventory` submissions — both only exist in your compiled crate.\n\nOn the frontend, import types and the per-controller action module:\n\n```tsx,ignore\nimport { usePage, router, Link } from \"@inertiajs/react\";\nimport { users, type UsersIndexProps } from \"./gen\";\n\nexport default function Index() {\n  const { props } = usePage\u003cUsersIndexProps\u003e();\n  return (\n    \u003c\u003e\n      \u003cLink href={users.create.url()}\u003eNew user\u003c/Link\u003e\n      {props.users.map((u) =\u003e (\n        \u003cLink key={u.id} href={users.show.url({ id: u.id })}\u003e{u.name}\u003c/Link\u003e\n      ))}\n    \u003c/\u003e\n  );\n}\n```\n\nEach action is a callable with `.url` and `.form` helpers:\n\n| Call | Returns |\n|---|---|\n| `users.show({id: 1})` | `{ url: \"/users/1\", method: \"get\" } as const` (visit definition) |\n| `users.show.url({id: 1})` | `\"/users/1\"` (just the path string) |\n| `users.show.form({id: 1})` | `{ action: \"/users/1\", method: \"get\" } as const` (props for `\u003cform\u003e`) |\n\nGenerated layout:\n\n```text\nfrontend/gen/\n  index.ts              protocol types, Pages, prop types, action namespace re-exports\n  actions/\n    users.ts            export const index, show, create, store, update, destroy\n    posts.ts            …\n    _root.ts            routes without a dotted prefix\n```\n\n`Always\u003cT\u003e` and `Merge\u003cT\u003e` collapse to `T` on the TypeScript side — the protocol's `mergeProps` / `deferredProps` arrays in the envelope carry the marker information instead.\n\n**Customize the output layout** with the `Split` builder — rename the `actions/` subdirectory, add a filename prefix/suffix, or flatten everything into the root:\n\n```rust,ignore\nveer::bindings::Split::new(\"./frontend/gen\")\n    .actions_dir(\"controllers\")    // -\u003e frontend/gen/controllers/\n    .file_suffix(\"-controller\")    // -\u003e users-controller.ts, posts-controller.ts\n    .generate()?;\n```\n\n`actions_dir(\"\")` writes the controller files directly alongside `index.ts`; `file_prefix` is similarly available.\n\n**Need a single bundled file** instead of the split tree? Use [`bindings::generate(\"./frontend/inertia.gen.ts\")`](https://docs.rs/veer/latest/veer/bindings/fn.generate.html) — same content, one file, with the route tree exposed as a nested `routes.users.show(...)` namespace.\n\n**Auto-regenerate on commit** with [lefthook](https://github.com/evilmartians/lefthook). Add this to `lefthook.yml` at the repo root:\n\n```yaml\npre-commit:\n  commands:\n    veer-bindings:\n      glob: \"*.rs\"\n      run: cargo run -q --bin gen-bindings\n      stage_fixed: true\n```\n\n`stage_fixed: true` re-adds the regenerated TS files to the commit so they never drift from the Rust source. Run `lefthook install` once per dev machine. Pair with a CI job that runs `cargo run --bin gen-bindings \u0026\u0026 git diff --exit-code` on PRs to fail-fast if someone bypasses the hook.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eCookie-based flash session\u003c/b\u003e\u003c/summary\u003e\n\nFor apps without an existing session crate:\n\n```rust,ignore\nuse veer::session::cookie::CookieSessionStore;\n\nlet cfg = InertiaConfig::new()\n    .session(CookieSessionStore::new(env_secret()).secure(true));\n```\n\nHMAC-SHA256-signed, constant-time verification, one-shot semantics. For anything more elaborate, implement the `SessionStore` trait over your existing session crate (`axum-login`, redis, …) in ~30 lines.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003etower-sessions integration\u003c/b\u003e\u003c/summary\u003e\n\nIf the app already runs `tower-sessions`, enable the `tower-sessions` feature and plug `TowerSessionStore` into the config. Flash data round-trips through whatever backend (Redis, Postgres, in-memory, …) tower-sessions is configured with.\n\n```rust,ignore\nuse time::Duration;\nuse tower_sessions::{Expiry, MemoryStore, SessionManagerLayer};\nuse veer::{session::tower::TowerSessionStore, InertiaConfig, InertiaLayer};\n\nlet session_layer = SessionManagerLayer::new(MemoryStore::default())\n    .with_expiry(Expiry::OnInactivity(Duration::minutes(30)));\n\nlet cfg = InertiaConfig::new().session(TowerSessionStore::new());\n\nlet app = axum::Router::new()\n    // … routes …\n    .layer(InertiaLayer::new(cfg))\n    .layer(session_layer); // tower-sessions must wrap *outside* InertiaLayer\n```\n\nFlash is stored under a single key (`_veer_flash` by default; override with `TowerSessionStore::new().key(\"...\")`).\n\n\u003c/details\u003e\n\n## 🎛️ Feature Flags\n\n| Flag | Default | Effect |\n|------|---------|--------|\n| `axum` | **on** | Axum extractor + tower layer + `InertiaForm` body extractor |\n| `multipart` | off | File upload support (`UploadedFile`, `MultipartStream`) |\n| `ssr` | off | HTTP SSR client (`reqwest`) |\n| `cookie-session` | off | Signed-cookie one-shot flash store |\n| `tower-sessions` | off | Flash store backed by [`tower-sessions`](https://crates.io/crates/tower-sessions) |\n| `validator` | off | `IntoErrorBag` impl for `validator::ValidationErrors` |\n| `garde` | off | `IntoErrorBag` impl for `garde::Report` |\n| `csrf` | off | Inertia/axios-compatible CSRF protection (`CsrfLayer`) |\n| `embed` | off | Embedded-asset serving for single-binary deploys (`EmbeddedAssets`) |\n| `ts` | off | End-to-end TypeScript bindings codegen (`ts-rs` + `inventory`) |\n\nDisabling a feature drops its transitive deps entirely.\n\n## 🏗️ Architecture\n\n```text\n┌────────────────────────────────────────────────────────┐\n│  axum Router                                           │\n│  ┌──────────────────────────────────────────────────┐  │\n│  │ InertiaLayer                                     │  │\n│  │   ├─ reads flash from SessionStore               │  │\n│  │   ├─ injects config into request extensions      │  │\n│  │   └─ on response: finalizes InertiaResponse,     │  │\n│  │      writes flash, rewrites 302→303 on non-GET   │  │\n│  └──────────────────────────────────────────────────┘  │\n│                       │                                │\n│                       ▼                                │\n│        ┌───────────────────────────┐                   │\n│        │  Inertia (extractor)      │                   │\n│        │  inertia.render(...) →    │                   │\n│        │  InertiaResponse builder  │                   │\n│        └───────────────────────────┘                   │\n└────────────────────────────────────────────────────────┘\n              │\n              ▼\n   ┌──────────────────────────────────────┐\n   │  Protocol core (framework-agnostic)  │\n   │  • RequestInfo / PageObject          │\n   │  • decide() state machine            │\n   │  • prop resolver (partial reloads,   │\n   │    deferred groups, merge keys)      │\n   │  • Pluggable: RootView, SessionStore,│\n   │    SsrClient, SharedProps            │\n   └──────────────────────────────────────┘\n```\n\nThe protocol core has zero I/O and zero framework deps — pure data structures and decision functions, exhaustively unit-tested. Adapters for other Rust frameworks (actix, rocket, salvo) can sit beside the axum one without touching it.\n\n## ⚠️ Caveats\n\n\u003e `Always\u003cT\u003e` and `Merge\u003cT\u003e` are detected at any depth and through any serialization path — typed `#[derive(Serialize)]` structs, `serde_json::json!`, hand-built `Value`s, mixed maps. Only top-level matches affect the Inertia wire format though, because the protocol has no notion of a \"nested merge prop\". Wrappers placed deeper are still stripped from the JSON sent to the client; they just don't appear in `mergeProps`.\n\n## 🧪 Example App\n\nA complete end-to-end demo lives at [`examples/axum-react-todo/`](examples/axum-react-todo) — axum backend + React/Vite frontend, with validation, flash messages, and an in-memory todo store.\n\n```bash\n# Terminal 1 — Rust backend\ncargo run -p axum-react-todo\n\n# Terminal 2 — Vite dev server\ncd examples/axum-react-todo\nbun install\nbun dev\n```\n\n## 🗺️ Status \u0026 Roadmap\n\n`veer` is pre-1.0. The v0.1 protocol surface is complete (all Inertia v3 features: partial reloads, deferred props, merge props, encrypted/clear history, SSR, asset versioning, validation flash). Planned for follow-ups:\n\n- Adapters for `actix-web` and `rocket`\n- Typed route-param inference (today: `string | number`; goal: read each handler's `Path` extractor and emit the matching TS type)\n\nContributions, bug reports, and protocol-conformance fixtures welcome.\n\n## 🙌 Acknowledgements\n\nThe protocol is [Inertia.js](https://inertiajs.com) by Jonathan Reinink and contributors. `veer` is an independent server-side implementation for Rust, modeled on the Laravel adapter's behavior.\n\n## 🧪 Testing\n\n```bash\ncargo test --all-features\n```\n\n## 📋 Changelog\n\nPlease see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.\n\n## 🤝 Contributing\n\nPlease see [CONTRIBUTING](CONTRIBUTING.md) for details.\nYou can also join our Discord server to discuss ideas and get help: [Discord Invite](http://go.climactic.co/discord).\n\n## 🔒 Security Vulnerabilities\n\nPlease report security vulnerabilities to [security@climactic.co](mailto:security@climactic.co).\n\n## 💖 Support This Project\n\nVeer is free and open source, built and maintained with care. If this crate has saved you development time or helped power your application, please consider supporting its continued development.\n\n\u003ca href=\"https://github.com/sponsors/climactic\"\u003e\n    \u003cimg src=\"https://img.shields.io/badge/Sponsor%20on-GitHub-ea4aaa?style=for-the-badge\u0026logo=github\" alt=\"Sponsor on GitHub\" /\u003e\n\u003c/a\u003e\n\u0026nbsp;\n\u003ca href=\"https://ko-fi.com/ClimacticCo\"\u003e\n    \u003cimg src=\"https://img.shields.io/badge/Support%20on-Ko--fi-FF5E5B?style=for-the-badge\u0026logo=ko-fi\u0026logoColor=white\" alt=\"Support on Ko-fi\" /\u003e\n\u003c/a\u003e\n\n### 🌟 Sponsors\n\n\u003c!-- sponsors --\u003e\n*Your logo here* — Become a sponsor and get your logo featured in this README and on our website.\n\u003c!-- sponsors --\u003e\n\n**Interested in title sponsorship?** Contact us at [sponsors@climactic.co](mailto:sponsors@climactic.co) for premium placement and recognition.\n\n## ⭐ Star History\n\n\u003ca href=\"https://star-history.com/#climactic/veer\u0026Date\"\u003e\n \u003cpicture\u003e\n   \u003csource media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=climactic/veer\u0026type=Date\u0026theme=dark\" /\u003e\n   \u003csource media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=climactic/veer\u0026type=Date\" /\u003e\n   \u003cimg alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=climactic/veer\u0026type=Date\" /\u003e\n \u003c/picture\u003e\n\u003c/a\u003e\n\n## 📄 License\n\nDual-licensed under **MIT** or **Apache 2.0** at your option. Please see [`MIT`](LICENSE-MIT) and [`APACHE`](LICENSE-APACHE) for more information.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fclimactic%2Fveer","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fclimactic%2Fveer","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fclimactic%2Fveer/lists"}