# User Guide This documentation introduces how to manually install and configure Inertia for a combo project. Before we begin, we need to choose the frontend framework to use. Here we'll use React, but the process is similar for other Inertia-compatible frameworks, like Vue or Svelte. > Combo's project generator - [combo_new](https://github.com/combo-lab/combo_new), already includes all of this scaffolding and are the fastest way to get started with Combo and Inertia. ## Compatibility `Inertia.js >= 2.0.0` ## Installation ### Generating a project using Vite When using Inertia, it's best to use it in conjunction with modern assets build tools like Vite. To get started quickly, let's create a project from `vite` template provided by `combo_new`: ``` $ mix combo_new vite my_app ``` ### Server-side setup #### Installing dependencies Add `:combo_inertia` to the list of dependencies in `mix.exs`: ```elixir def deps do [ {:combo_inertia, ""} ] end ``` #### Setting up necessary modules This package includes a few modules: - `Combo.Inertia.Plug` - the plug for detecting Inertia requests and preparing the connection accordingly. - `Combo.Inertia.Conn` - the `%Plug.Conn{}` helpers for rendering Inertia responses. - `Combo.Inertia.HTML` - the HTML components and helpers for building Inertia views. First, add `Combo.Inertia.Plug` into the browser pipeline: ```diff # lib/my_app/web/router.ex defmodule MyApp.Web.Router do use MyApp.Web, :router pipeline :browser do # ... + plug Combo.Inertia.Plug end end ``` Then: - import `Combo.Inertia.Conn` into the controller helper. - import `Combo.Inertia.HTML` into the html helper. ```diff # lib/my_app/web.ex defmodule MyApp.Web do # ... def controller do quote do # ... + import Combo.Inertia.Conn end end # ... defp html_helpers do quote do # ... + import Combo.Inertia.HTML # ... end end # ... end ``` #### Modifying the root layout - add `data-ssr` attribute to the `` tag, which is supplied to client-side code to identify the current rendering mode. - replace the `` tag with the `<.inertia_title>` component, which is used to keep the title in sync with client-side code. - add the `<.inertia_head>` component. ```diff # lib/my_app/web/layouts/root.html.ceex <!DOCTYPE html> - <html lang="en"> + <html lang="en" data-ssr={@inertia_ssr}> <head> <!-- ... --> - <title> - {if title = assigns[:page_title], do: "#{title} ยท MyApp", else: "MyApp"} - + <.inertia_title> + {if title = assigns[:page_title], do: "#{title} - MyApp", else: "MyApp"} + + <.inertia_head content={@inertia_head} /> - <.vite_assets names={["src/css/app.css", "src/js/app.js"]} /> + <.vite_react_refresh /> + <.vite_assets names={["src/js/app.jsx"]} /> ``` > You may noticed that we add `<.vite_react_refresh />` component before the `<.vite_assets />` component. > It's provided by [`combo_vite`](https://github.com/combo-lab/combo_vite) for enabling fast refresh in development, and only for React. #### Adding configuration If you'd like to add configuration, these configuration options are available. ```elixir config :my_app, MyApp.Web.Endpoint, inertia: [ # Configures the asset versioning strategy. # # Available values: # * `:auto` - Automatically determines version in following order: # 1. check manifest file generated by Vite, and hash it if present # 2. check manifest file generated by `combo.static.digest`, and hash it if present # 3. Falls back to `"not-detected"` if no manifest found # # * a string - Uses a fixed version string # # * a {module, fun, args} tuple - Calls the specified function to generate version string # # Defaults to `:auto` assets_version: :auto, # Instruct the client side whether to encrypt the page object in the window # history state. # Defaults to `false`. encrypt_history: false, # Enable automatic conversion of prop keys from snake case to camel case. # Defaults to `false`. camelize_props: false, # Enable server-side rendering for page responses (requires some additional setup, # see instructions below). # Defaults to `false`. ssr: false, # Whether to raise an exception when server-side rendering fails. # Defaults to `true`. raise_on_ssr_failure: true ] ``` ### Client-side setup #### Configuring Vite for React Add `@vitejs/plugin-react`: ``` $ cd assets $ npm install -D --install-links @vitejs/plugin-react ``` Edit `assets/vite.config.js`: ```diff import { defineConfig } from "vite" import combo from "vite-plugin-combo" + import react from "@vitejs/plugin-react" export default defineConfig({ plugins: [ combo({ - input: ["src/css/app.css", "src/js/app.js"], + input: ["src/js/app.jsx"], staticDir: "../priv/static", }), + react(), ], }) ``` #### Installing React and Inertia adapter ``` $ cd assets $ npm install -S --install-links @inertiajs/react react react-dom ``` #### Creating the Inertia app Next, rename `app.js` to `app.jsx` and update it to create your Inertia app: ```javascript // assets/src/js/app.jsx import "vite/modulepreload-polyfill" import "@fontsource-variable/instrument-sans" import "../css/app.css" import { createInertiaApp } from "@inertiajs/react" import { createRoot } from "react-dom/client" createInertiaApp({ resolve: (name) => { const page = `./pages/${name}.jsx` const pages = import.meta.glob("./pages/**/*.jsx", { eager: true }) return pages[page] }, setup({ el, App, props }) { createRoot(el).render() }, }) ``` The `resolve` callback tells Inertia how to load a page component. It receives a page name as string, and returns a page component module. By default we recommend eager loading your components, which will result in a single JavaScript bundle. However, if you'd like to lazy-load your components, you can modify the `resolve` callback like this: ```javascript { // ... resolve: (name) => { const page = `./pages/${name}.jsx` const pages = import.meta.glob("./pages/**/*.jsx") // remove the {eager: true} option return pages[page]() // add parentheses at the end } // ... } ``` See [the code splitting documentation of Inertia](https://inertiajs.com/code-splitting) for more information. The `setup` callback receives everything necessary to initialize the client-side framework, including the root Inertia `App` component. The above code assumes your pages live in the `assets/src/js/pages` directory and have a default export with page component, like this: ```javascript // assets/js/src/pages/Home.jsx export default Home({ msg }) { return

This is the home page. {msg}

} ``` #### Setting up CSRF protection `Combo.Inertia` sets the CSRF token to `CSRF-TOKEN` cookie, and `Combo` expects to receive the CSRF token via the `X-CSRF-TOKEN` header But, Axios, the HTTP library that Inertia uses under the hood, uses the following CSRF related config by default: ``` axios.defaults.xsrfCookieName = "XSRF-TOKEN" axios.defaults.xsrfHeaderName = "X-XSRF-TOKEN" ``` To make them work together, we should setup Axios: ```diff // assets/src/js/app.jsx import "vite/modulepreload-polyfill" import "@fontsource-variable/instrument-sans" import "../css/app.css" + import axios from "axios" import { createInertiaApp } from "@inertiajs/react" import { createRoot } from "react-dom/client" + axios.defaults.xsrfCookieName = "CSRF-TOKEN" + axios.defaults.xsrfHeaderName = "X-CSRF-TOKEN" createInertiaApp({ resolve: (name) => { const page = `./pages/${name}.jsx` const pages = import.meta.glob("./pages/**/*.jsx", { eager: true }) return pages[page] }, setup({ el, App, props }) { createRoot(el).render() }, }) ``` ## Setting up SSR (optional) Inertia comes with with server-side rendering (SSR) support. > The steps for enabling SSR similar to other backend frameworks, but instead of running a separate Node.js server process to render HTML, `Combo.Inertia` spins up a pool of Node.js process workers to handle SSR calls and manages the state of those node processes from your Elixir process tree. This is mostly just an implementation detail that you don't need to be concerned about, but we'll highlight how our `ssr.js` script differs from the Inertia docs. > To run Combo and a Node.js process pool with 1 process, you need at least 512MiB of memory. Otherwise, the machine may experience out-of-memory (OOM) errors or severe slowness. ### Client-side setup #### Adding the SSR entrypoint Create a Node.js module that exports a `render` function to perform the actual server-side rendering of pages. Let's name it `ssr.jsx`. ```javascript // assets/src/js/ssr.jsx import { createInertiaApp } from "@inertiajs/react" import ReactDOMServer from "react-dom/server" export function render(page) { return createInertiaApp({ page, render: ReactDOMServer.renderToString, resolve: (name) => { const page = `./pages/${name}.jsx` const pages = import.meta.glob("./pages/**/*.jsx", { eager: true }) return pages[page] }, setup: ({ App, props }) => , }) } ``` > This is similar to the server entry-point [documented here](https://inertiajs.com/server-side-rendering#add-server-entry-point), except we are simply exporting a function called `render`, instead of starting a Node.js server process. #### Configuring Vite for the SSR entrypoint Configure vite to build `assets/src/js/ssr.jsx`, and put the bundled `ssr.js` into `priv/ssr`. ```diff // assets/vite.config.js import { defineConfig } from "vite" import combo from "vite-plugin-combo" import react from "@vitejs/plugin-react" export default defineConfig({ plugins: [ combo({ input: ["src/js/app.jsx"], staticDir: "../priv/static", + ssrInput: ["src/js/ssr.jsx"], + ssrOutDir: "../priv/ssr", }), react(), ], }) ``` #### Modifying the CSR entrypoint When SSR is enabled, `hydrateRoot` should be used. ```diff // assets/src/js/app.jsx import "vite/modulepreload-polyfill" import "@fontsource-variable/instrument-sans" import "../css/app.css" import axios from "axios"; import { createInertiaApp } from "@inertiajs/react"; - import { createRoot } from "react-dom/client"; + import { createRoot, hydrateRoot } from "react-dom/client"; axios.defaults.xsrfCookieName = "CSRF-TOKEN" axios.defaults.xsrfHeaderName = "X-CSRF-TOKEN" + function ssr_mode() { + return document.documentElement.hasAttribute("data-ssr"); + } createInertiaApp({ resolve: (name) => { const page = `./pages/${name}.jsx` const pages = import.meta.glob("./pages/**/*.jsx", { eager: true }) return pages[page] }, setup({ el, App, props }) { - createRoot(el).render() + if (ssr_mode()) { + hydrateRoot(el, ); + } else { + createRoot(el).render() + } }, }) ``` #### Updating npm script Update the `build` script in `package.json` to build the new `ssr.js` file. ```diff "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && vite build --ssr", // ... }, ``` Now you can build both your client-side and server-side bundles. ``` $ npm run build ``` #### Updating .gitignore Since `priv/ssr/` is for generated file, add it to your `.gitignore` file. ```diff # .gitignore + # Ignore files that are produced for SSR by assets build tools. + /priv/ssr/ ``` ### Server-side setup #### Setting up `Combo.Inertia.SSR` First, add the `Combo.Inertia.SSR` module to the of supervision tree: ```elixir # lib/my_app/web/supervisor.ex defmodule MyApp.Web.Supervisor do use Supervisor @spec start_link(term()) :: Supervisor.on_start() def start_link(arg) do Supervisor.start_link(__MODULE__, arg, name: __MODULE__) end @impl Supervisor def init(_arg) do children = Enum.concat( inertia_children(), [ MyApp.Web.Endpoint ] ) Supervisor.init(children, strategy: :one_for_one) end defp inertia_children do config = Application.get_env(:my_app, MyApp.Web.Endpoint) ssr? = get_in(config, [:inertia, :ssr]) if ssr? do path = Path.join([Application.app_dir(:my_app), "priv/ssr"]) [{Combo.Inertia.SSR, endpoint: MyApp.Web.Endpoint, path: path}] else [] end end end ``` Then, update your config to enable SSR for production environment: ```elixir # config/prod.exs config :my_app, MyApp.Web.Endpoint, inertia: [ ssr: true ] ``` ## Setting up Inertia helper (optional) Create a little helper can be used in the `resolve` function. First, `assets/src/js/inertia-helper.js`: ```javascript // An Inertia helper for resolving page component. // // # Usage // // Use it in the `resolve` function. // // ## Resolve page component // // createInertiaApp({ // // ... // resolve: (name) => // resolvePageComponent( // `./pages/${name}.jsx`, // import.meta.glob("./pages/**/*.jsx", { eager: true }), // ), // }) // // ## Resolve page component with a fallback name // // createInertiaApp({ // // ... // resolve: (name) => // resolvePageComponent( // `./pages/${name}.jsx`, // import.meta.glob("./pages/**/*.jsx", { eager: true }), // { fallbackName: "./pages/404.jsx" }, // ), // }) // export async function resolvePageComponent(name, pages, options) { if (typeof pages[name] === "undefined" && options?.fallbackName) { name = options.fallbackName } const page = pages[name] if (typeof page !== "undefined") { // When code spiltting is enabled, page is a function. // Or, page is an object. return typeof page === "function" ? page() : page } else { throw new Error(`Page not found: ${name}`) } } ``` Then, use it in your `assets/src/js/app.jsx` and `assets/src/js/ssr.jsx`. ## Rendering responses Rendering an Inertia response looks like this: ```elixir defmodule MyApp.Web.PageController do use MyApp.Web, :controller def index(conn, _params) do conn |> inertia_put_prop(:msg, "Hello world") |> inertia_render("Home") end end ``` ## Shared data To share data on every request, you can use the `inertia_put_prop/3` function inside of a plug in your response pipeline. For example, suppose you have a `UserAuth` plug responsible for fetching the current user and you want to be sure all your Inertia components receive that user data. Your plug can be something like this: ```elixir defmodule MyApp.Web.UserAuth do import Plug.Conn import Combo.Conn import Combo.Inertia.Conn def authenticate_user(conn, _opts) do user = get_user_from_session(conn) conn |> assign(:user, user) # put a serialized represention of the user to Inertia props. |> inertia_put_prop(:user, serialize_user(user)) end # ... end ``` Anywhere this plug is used, the serialized `user` prop will be passed to the Inertia components. ## Lazy data evaluation - `Combo.Inertia.Conn.inertia_optional/1` - `Combo.Inertia.Conn.inertia_always/1` ## Deferred props - `Combo.Inertia.Conn.inertia_defer/1` - `Combo.Inertia.Conn.inertia_defer/2` ## Merging props - `Combo.Inertia.Conn.inertia_merge/1` - `Combo.Inertia.Conn.inertia_deep_merge/1` ## History encryption ### Global encryption To enable history encryption globally, use: ```elixir config :my_app, MyApp.Web.Endpoint, inertia: [ encrypt_history: true ] ``` ### Per-request encryption To encrypt the history of an individual request, use: - `Combo.Inertia.Conn.inertia_encrypt_history/1` - `Combo.Inertia.Conn.inertia_encrypt_history/2` ### Clearing history To clear the history state, use: - `Combo.Inertia.Conn.inertia_clear_history/1` - `Combo.Inertia.Conn.inertia_clear_history/2` ## Distinctive features ### Camelizing props Combo.Inertia allows to automatically convert your prop keys from snake case (conventional in Elixir) to camel case (conventional in JavaScript), like `first_name` to `firstName`. To configure it globally: ```elixir import Config config :my_app, MyApp.Web.Endpoint inertia: [ camelize_props: true ] ``` To configure it on a per-request basis. ```elixir defmodule MyApp.Web.PageController do use MyApp.Web, :controller def index(conn, _params) do conn |> inertia_put_prop(:first_name, "Bob") |> inertia_camelize_props() |> inertia_render("Welcome") end end ``` ### Flash messages `Combo.Inertia` automatically includes Combo flash data in Inertia props, under the `flash` key. For example, given the following controller action: ```elixir def update(conn, params) do case MyApp.Settings.update(params) do {:ok, _settings} -> conn |> put_flash(:info, "Settings updated") |> redirect(to: ~p"/settings") {:error, changeset} -> conn |> inertia_put_errors(changeset) |> redirect(to: ~p"/settings") end end ``` When redirecting to the `/settings` page, the Inertia component will receive the `flash` prop: ```javascript { "component": "...", "props": { "flash": { "info": "Settings updated" }, // ... } } ``` ### Validations Validation errors follow some specific conventions to make wiring up with Inertia's form helpers seamless. The `errors` prop is managed by `Combo.Inertia` and is always included in the props object for Inertia components. (When there are no errors, the `errors` prop will be an empty object). The `inertia_put_errors` function is how you tell Inertia what errors should be represented on the front-end. By default, you can either pass an `Ecto.Changeset` struct or a bare map to it. For other error data types, you may implement the `Combo.Inertia.Errors` protocol: ```elixir def update(conn, params) do case MyApp.Settings.update(params) do {:ok, _settings} -> conn |> put_flash(:info, "Settings updated") |> redirect(to: ~p"/settings") {:error, changeset} -> conn |> inertia_put_errors(changeset) |> redirect(to: ~p"/settings") end end ``` The `inertia_put_errors` function will convert the changeset errors into a shape compatible with the client-side adapter. Since Inertia expects a flat map of key-value pairs, the error serializer will flatten nested errors down to compound keys: ```javascript { "name" => "can't be blank", // Nested errors keys are flattened with a dot separator (`.`) "team.name" => "must be at least 3 characters long", // Nested arrays are zero-based and indexed using bracket notation (`[0]`) "items[1].price" => "must be greater than 0" } ``` Errors are automatically preserved across redirects, so you can safely respond with a redirect back to page where the form lives to display form errors. If you need to construct your own map of errors (rather than pass in a changeset), be sure it's a flat mapping of atom (or string) keys to string values like this: ```elixir conn |> inertia_put_errors(%{ name: "Name can't be blank", password: "Password must be at least 5 characters" }) ``` ## Testing - `Combo.Inertia.Testing` We recommend importing `Combo.Inertia.Testing` in your `ConnCase` helper: ```elixir defmodule MyApp.Web.ConnCase do use ExUnit.CaseTemplate using do quote do import Combo.Inertia.Testing # ... end end end ``` ## Deployment There's only one thing to note - make Node.js running in production mode, which is configured by setting following environment variable: ``` NODE_ENV="production" ``` Why? In short: - To get best SSR performance. Node.js running in production mode will cache the SSR module in memory. - To avoid memory leaks. > Performance comparison for rendering a simple page when testing on an M1 MacBook Pro: > > - Node.js running in production mode - `4ms` > - Node.js running in non-production mode - `15ms` ## More Visit .