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, 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:

def deps do
  [
    {:combo_inertia, "<requirement>"}
  ]
end

Setting up necessary modules

This package includes a few modules:

First, add Combo.Inertia.Plug into the browser pipeline:

  # 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:

  # 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 <html> tag, which is supplied to client-side code to identify the current rendering mode.
  • replace the <title> tag with the <.inertia_title> component, which is used to keep the title in sync with client-side code.
  • add the <.inertia_head> component.
  # 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"}
-     </title>
+     <.inertia_title>
+       {if title = assigns[:page_title], do: "#{title} - MyApp", else: "MyApp"}
+     </.inertia_title>
+     <.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"]} />
    </head>
    <!-- ... -->

You may noticed that we add <.vite_react_refresh /> component before the <.vite_assets /> component. It's provided by 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.

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:

  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:

// 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(<App {...props} />)
  },
})

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:

{
  // ...
  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 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:

// assets/js/src/pages/Home.jsx

export default Home({ msg }) {
  return <p>This is the home page. {msg}</p>
}

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:

  // 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(<App {...props} />)
    },
  })

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.

// 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 }) => <App {...props} />,
  })
}

This is similar to the server entry-point documented here, 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.

  // 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.

  // 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(<App {...props} />)
+     if (ssr_mode()) {
+       hydrateRoot(el, <App {...props} />);
+     } else {
+       createRoot(el).render(<App {...props} />)
+     }
    },
  })

Updating npm script

Update the build script in package.json to build the new ssr.js file.

 "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.

  # .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:

# 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:

# 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:

// 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:

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:

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

Deferred props

Merging props

Once props

Scroll props

History encryption

Global encryption

To enable history encryption globally, use:

config :my_app, MyApp.Web.Endpoint,
  inertia: [
    encrypt_history: true
  ]

Per-request encryption

To encrypt the history of an individual request, use:

Clearing history

To clear the history state, use:

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:

import Config

config :my_app, MyApp.Web.Endpoint
  inertia: [
    camelize_props: true
  ]

To configure it on a per-request basis.

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:

def update(conn, params) do
  case MyApp.Settings.update(params) do
    {:ok, _settings} ->
      conn
      |> put_flash(:info, "Settings updated")
      |> redirect(to: "/settings")

    {:error, changeset} ->
      conn
      |> inertia_put_errors(changeset)
      |> redirect(to: "/settings")
  end
end

When redirecting to the /settings page, the Inertia component will receive the flash prop:

{
  "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:

def update(conn, params) do
  case MyApp.Settings.update(params) do
    {:ok, _settings} ->
      conn
      |> put_flash(:info, "Settings updated")
      |> redirect(to: "/settings")

    {:error, changeset} ->
      conn
      |> inertia_put_errors(changeset)
      |> redirect(to: "/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:

{
  "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:

conn
|> inertia_put_errors(%{
  name: "Name can't be blank",
  password: "Password must be at least 5 characters"
})

Testing

We recommend importing Combo.Inertia.Testing in your ConnCase helper:

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 https://inertiajs.com/.