Inertia.js Phoenix Adapter Hex PackageHex Docs

The official Elixir/Phoenix adapter for Inertia.js.

Table of Contents

Installation

Using Igniter

The easiest way to get started is to use the Igniter installer.

mix archive.install hex igniter_new
mix igniter.install inertia

The following options can be used to customize the installation:

--client-framework [react|vue|svelte] # Configures the client-side framework in `assets/package.json`
--camelize-props                      # Sets `camelize_props: true` in `config.exs` (See below)
--history-encrypt                     # Sets `history: [encrypt: true]` in `config.exs` (See below)
--typescript                          # Creates a TypeScript config file and installs dev dependencies

Manually

The package can be installed by adding inertia to your list of dependencies in mix.exs:

def deps do
  [
    {:inertia, "~> 3.0.0-rc"}
  ]
end

Add your desired configuration in your config.exs file:

# config/config.exs

config :inertia,
  # The Phoenix Endpoint module for your application. This is used for building
  # asset URLs to compute a unique version hash to track when something has
  # changed (and a reload is required on the frontend).
  endpoint: MyAppWeb.Endpoint,

  # An optional list of static file paths to track for changes. You'll generally
  # want to include any JavaScript assets that may require a page refresh when
  # modified.
  static_paths: ["/assets/app.js"],

  # The default version string to use (if you decide not to track any static
  # assets using the `static_paths` config). Defaults to "1".
  default_version: "1",

  # Enable automatic conversion of prop keys from snake case (e.g. `inserted_at`),
  # which is conventional in Elixir, to camel case (e.g. `insertedAt`), which is
  # conventional in JavaScript. Defaults to `false`.
  camelize_props: false,

  # Instruct the client side whether to encrypt the page object in the window history
  # state. This can also be set/overridden on a per-request basis, using the `encrypt_history`
  # controller helper. Defaults to `false`.
  history: [encrypt: 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 (only applies
  # when SSR is enabled). Defaults to `true`.
  #
  # Recommended: enable in non-production environments and disable in production,
  # so that SSR failures will not cause 500 errors (but instead will fallback to
  # CSR).
  raise_on_ssr_failure: config_env() != :prod

This library includes a few modules to help render Inertia responses:

To get started, import Inertia.Controller in your controller helper and Inertia.HTML in your html helper:

  # lib/my_app_web.ex
  defmodule MyAppWeb do
    def controller do
      quote do
        use Phoenix.Controller, namespace: MyAppWeb

+       import Inertia.Controller
      end
    end

    def html do
      quote do
        use Phoenix.Component

+       import Inertia.HTML
      end
    end
  end

Then, install the plug in your browser pipeline:

  # lib/my_app_web/router.ex
  defmodule MyAppWeb.Router do
    use MyAppWeb, :router

    pipeline :browser do
      plug :accepts, ["html"]

+     plug Inertia.Plug
    end
  end

Next, replace the title tag in your layout with the <.inertia_title> component, so that the client-side library will keep the title in sync, and add the <.inertia_head> component:

  # lib/my_app_web/components/layouts/root.html.heex
  <!DOCTYPE html>
  <html lang="en" class="[scrollbar-gutter:stable]">
    <head>
-     <.live_title>{@page_title}</.live_title>
+     <.inertia_title>{@page_title}</.inertia_title>
+     <.inertia_head content={@inertia_head} />
    </head>

You're now ready to start rendering inertia responses!

Rendering responses

Rendering an Inertia.js response looks like this:

defmodule MyAppWeb.ProfileController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    conn
    |> assign_prop(:text, "Hello world")
    |> render_inertia("ProfilePage")
  end
end

The assign_prop function allows you to define props that should be passed in to the component. The render_inertia function accepts the conn, the name of the component to render, and an optional map containing more initial props to pass to the page component.

This action will render an HTML page containing a <div> element with the name of the component and the initial props, following Inertia.js conventions. On subsequent requests dispatched by the Inertia.js client library, this action will return a JSON response with the data necessary for rendering the page.

If you want to automatically convert your prop keys from snake case (conventional in Elixir) to camel case to keep with JavaScript conventions (e.g. first_name to firstName), you can configure that globally or enable/disable it on a per-request basis.

import Config

config :inertia,
  endpoint: MyAppWeb.Endpoint,
  camelize_props: true
defmodule MyAppWeb.ProfileController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    conn
    |> assign_prop(:first_name, "Bob")
    |> camelize_props()
    |> render_inertia("ProfilePage")
  end
end

Setting up the client-side

The Inertia.js docs provide a good general walk-through on how to setup your JavaScript assets to boot your Inertia app. If you're new to Inertia, we recommend checking that out to familiarize yourself with how it all works. Here we'll provide some guidance on getting your Phoenix app with esbuild configured for basic client-side rendering (and further down, we'll delve into server-side rendering).

To get started, install the Inertia.js library for the frontend framework of your choice. In these instructions we'll use React, but the process is similar for other Inertia-compatible frameworks, like Vue or Svelte.

cd assets
npm install @inertiajs/react react react-dom

Replace the contents of your app.js file with the Inertia boot function and rename it to app.jsx (since we are using JSX).

// assets/js/app.jsx

import React from "react";

import { createInertiaApp } from "@inertiajs/react";
import { createRoot } from "react-dom/client";

createInertiaApp({
  resolve: async (name) => {
    return await import(`./pages/${name}.jsx`);
  },
  setup({ App, el, props }) {
    createRoot(el).render(<App {...props} />);
  },
  http: {
    xsrfHeaderName: "x-csrf-token",
  },
});

The example above assumes your pages live in the assets/js/pages directory and have a default export with page component, like this:

// assets/js/pages/Dashboard.jsx

import React from "react";

const Dashboard = () => {
  return <div>{/* ... page contents ...*/}</div>;
};

export default Dashboard;

Next, make some adjustments to your esbuild config:

# config/config.exs

config :esbuild,
  version: "0.21.5",
  my_app: [
    args: ~w(js/app.jsx --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]

If you updated your esbuild version, you'll need to run mix esbuild.install to fetch the new version.

Esbuild also supports code-splitting, which can be useful for larger applications. To enable it, you'll need to:

  # config/config.exs

  config :esbuild,
    version: "0.21.5",
    my_app: [
-     args: ~w(js/app.jsx --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
+     args: ~w(js/app.jsx --bundle --chunk-names=chunks/[name]-[hash] --splitting --format=esm --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
      cd: Path.expand("../assets", __DIR__),
      env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
    ]

After that, we need to update our root layout to load the JavaScript bundle as an ESM module (by changing the type attribute from text/javascript to module):

  # lib/my_app_web/components/layouts/root.html.eex

-  <script type='text/javascript' defer phx-track-static src={~p"/assets/app.js"}></script>
+  <script type='module' defer phx-track-static src={~p"/assets/app.js"}></script>

[!NOTE] ESM code splitting requires modern browser support. While most current browsers support ESM modules, you should verify compatibility requirements with your target audience. You can read more about how code-splitting works with esbuild in the official documentation.

Lazy data evaluation

If you have expensive data for your props that may not always be required (that is, if you plan to use partial reloads), you can wrap your expensive computation in a function and pass the function reference when setting your Inertia props. You may use either an anonymous function (or named function reference) and optionally wrap it with the Inertia.Controller.inertia_optional/1 function.

[!NOTE] inertia_optional props will only be included the when explicitly requested in a partial reload. If you want to include the prop on first visit, you'll want to use a bare anonymous function or named function reference instead. See below for examples of how prop assignment behaves.

Here are some specific examples of how the methods of lazy data evaluation differ:

conn
# ALWAYS included on first visit...
# OPTIONALLY included on partial reloads...
# ALWAYS evaluated...
|> assign_prop(:cheap_thing, cheap_thing())

# ALWAYS included on first visit...
# OPTIONALLY included on partial reloads...
# ONLY evaluated when needed...
|> assign_prop(:expensive_thing, fn -> calculate_thing() end)
|> assign_prop(:another_expensive_thing, &calculate_another_thing/0)

# NEVER included on first visit...
# OPTIONALLY included on partial reloads...
# ONLY evaluated when needed...
|> assign_prop(:super_expensive_thing, inertia_optional(fn -> calculate_thing() end))

Deferred props

Requires Inertia v2.x or later on the client-side.

If you have expensive data that you'd like to automatically fetch (from the client-side via an async background request) after the page is initially rendered, you can mark the prop as deferred:

conn
|> assign_prop(:expensive_thing, inertia_defer(fn -> calculate_thing() end))

The inertia_defer/1 helper accepts a function argument in the first position. You may optionally use the inertia_defer/2 helper, which accepts a "group" name in the second position:

conn
|> assign_prop(:expensive_thing, inertia_defer(fn -> calculate_thing() end, "dashboard"))

If no group names are specified, then the client-side will issue a single async request to fetch all the deferred props. If there are multiple group names, then the client-side will issue one async request per group instead. This is useful if you have some very expensive data that you'd prefer fetch in parallel alongside other expensive data.

Merge props

Requires Inertia v2.x or later on the client-side.

If you have prop data that should get merged with the existing data on the client-side on subsequent requests (for example, an array of paginated data being presented in an "infinite scroll" interface), then you can tag the prop value using the inertia_merge/1 helper:

conn
|> assign_prop(:paginated_list, inertia_merge(["a", "b", "c"]))

Merge props can also accept deferred props:

conn
|> assign_prop(:paginated_list, inertia_defer(&calculate_next_page/0) |> inertia_merge())

If you are working with complex data structures or nested objects you can use inertia_deep_merge(value)

conn
|> assign_prop(:complex_object, inertia_deep_merge(%{a: %{b: %{c: %{d: 1}}}}))

Deduplication with match_on

When merging list data, you can provide a match_on key to enable client-side deduplication of items. This is useful for infinite scroll interfaces where the same item might appear in multiple pages of data:

conn
|> assign_prop(:users, inertia_merge(users, match_on: "id"))

The match_on option is also supported by inertia_prepend/2 and inertia_deep_merge/2. The key is included in the matchPropsOn metadata in the page response.

Prepend props

If you want merged data to be prepended (instead of appended) to the existing client-side data, use inertia_prepend/1:

conn
|> assign_prop(:messages, inertia_prepend(new_messages))

Prepend props appear in both mergeProps and prependProps in the page response. This is useful for scenarios like chat interfaces where new messages should appear at the top.

Like inertia_merge, prepend props also support the match_on option for deduplication:

conn
|> assign_prop(:messages, inertia_prepend(new_messages, match_on: "id"))

Once props

Requires Inertia v2.x or later on the client-side.

Some data rarely changes, is expensive to compute, or is simply large. Rather than including this data in every response, you can use once props. These props are cached on the client-side and reused on subsequent pages that include the same prop, making them ideal for shared data like user roles or configuration.

conn
|> assign_prop(:plans, inertia_once(fn -> Plans.list_all() end))

The client will remember the prop value and reuse it on subsequent page visits. Navigating to a page without the once prop will clear the cached value.

Forcing a refresh

You can force a once prop to be refreshed using the fresh option:

conn
|> assign_prop(:plans, inertia_once(fn -> Plans.list_all() end, fresh: true))

This also accepts a boolean condition:

conn
|> assign_prop(:plans, inertia_once(fn -> Plans.list_all() end, fresh: plans_changed?))

Expiration

You can set an expiration time using the until option. This accepts a DateTime or an integer representing seconds from now:

conn
# Expires in 1 hour
|> assign_prop(:rates, inertia_once(fn -> ExchangeRates.current() end, until: 3600))

# Expires at a specific time
|> assign_prop(:rates, inertia_once(fn -> ExchangeRates.current() end,
  until: DateTime.utc_now() |> DateTime.add(1, :day)
))

Custom keys

You can assign a custom key using the as option. This is useful when you want to share data across multiple pages with different prop names:

# Team member list page
conn
|> assign_prop(:member_roles, inertia_once(fn -> Roles.list_all() end, as: "roles"))

# Invite form page
conn
|> assign_prop(:available_roles, inertia_once(fn -> Roles.list_all() end, as: "roles"))

Both pages share the same cached data because they use the same custom key.

Combining with other prop types

Once props can be combined with deferred, merge, and optional props:

conn
# Deferred + once: loaded after initial render, then cached
|> assign_prop(:permissions, inertia_once(inertia_defer(fn -> Permissions.for_user(user) end)))

# Merge + once: merged with existing data and cached
|> assign_prop(:activity, inertia_once(inertia_merge(fn -> Activity.recent(user) end)))

Scroll props

Requires Inertia v2.x or later on the client-side.

For infinite scroll pagination, you can use inertia_scroll/1 to wrap paginated data. This automatically configures merge behavior so new data is appended to existing content, and extracts pagination metadata for the client-side <InfiniteScroll> component.

conn
|> assign_prop(:users, inertia_scroll(paginated_users))
|> render_inertia("Users/Index")

The function expects paginated data with a structure like:

%{
  data: [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}],
  meta: %{
    current_page: 1,
    next_page: 2,
    previous_page: nil,
    page_name: "page"  # optional, defaults to "page"
  }
}

This will produce a response with:

{
  "props": {
    "users": {
      "data": [...],
      "meta": {...}
    }
  },
  "mergeProps": ["users.data"],
  "scrollProps": {
    "users": {
      "pageName": "page",
      "currentPage": 1,
      "previousPage": null,
      "nextPage": 2
    }
  }
}

Options

The inertia_scroll/2 function accepts the following options:

# Custom wrapper key (for data structures that use "items" instead of "data")
conn
|> assign_prop(:users, inertia_scroll(data, wrapper: "items"))

# Custom page name for multiple scroll containers on one page
conn
|> assign_prop(:users, inertia_scroll(users, page_name: "users_page"))
|> assign_prop(:orders, inertia_scroll(orders, page_name: "orders_page"))

Lazy evaluation

Like other prop helpers, inertia_scroll supports lazy evaluation with functions:

conn
|> assign_prop(:users, inertia_scroll(fn -> User.paginate(params) end))

Custom metadata

For pagination libraries that use different data structures, you can provide a custom metadata extraction function:

conn
|> assign_prop(:users, inertia_scroll(scrivener_page,
  wrapper: "entries",
  metadata: fn page ->
    %{
      page_name: "page",
      current_page: page.page_number,
      previous_page: if(page.page_number > 1, do: page.page_number - 1),
      next_page: if(page.page_number < page.total_pages, do: page.page_number + 1)
    }
  end
))

ScrollMetadata protocol

For reusable metadata extraction, you can implement the Inertia.ScrollMetadata protocol for your pagination library's struct:

defimpl Inertia.ScrollMetadata, for: Scrivener.Page do
  def to_scroll_metadata(page) do
    %{
      page_name: "page",
      current_page: page.page_number,
      previous_page: if(page.page_number > 1, do: page.page_number - 1),
      next_page: if(page.page_number < page.total_pages, do: page.page_number + 1)
    }
  end
end

Then you can use inertia_scroll directly with the struct:

conn
|> assign_prop(:users, inertia_scroll(scrivener_page, wrapper: "entries"))

Shared data

To share data on every request, you can use the assign_shared_prop/3 function inside of a shared plug in your response pipeline. This marks the prop as "shared", which tells the Inertia.js client which props are set globally so it can carry them forward optimistically during instant visits.

For example, suppose you have a UserAuth plug responsible for fetching the currently-logged in user and you want to be sure all your Inertia components receive that user data. Your plug might look something like this:

defmodule MyApp.UserAuth do
  import Inertia.Controller
  import Phoenix.Controller
  import Plug.Conn

  def authenticate_user(conn, _opts) do
    user = get_user_from_session(conn)

    conn
    |> assign(:user, user)
    |> assign_shared_prop(:user, serialize_user(user))
  end

  # ...
end

Anywhere this plug is used, the serialized user prop will be passed to the Inertia component, and the key "user" will appear in the sharedProps array in the page response.

You can also use inertia_share/1 to mark a prop as shared when using inline prop maps:

conn
|> render_inertia("Home", %{
  current_user: inertia_share(serialize_user(user)),
  other: "value"
})

Shared props are composable with other prop types like inertia_merge/1 and inertia_defer/1:

conn
|> assign_shared_prop(:notifications, inertia_merge(notifications))
|> assign_shared_prop(:permissions, inertia_defer(fn -> fetch_permissions() end))

[!NOTE] You can still use assign_prop/3 for shared data if you don't need the sharedProps metadata. The assign_shared_prop/3 function is a convenience wrapper that additionally tags the prop for inclusion in the sharedProps page metadata.

Validations

Validation errors follow some specific conventions to make wiring up with Inertia's form helpers seamless. The errors prop is managed by this library 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 assign_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 the assign_errors function. For other error data types, you may implement the Inertia.Errors protocol (see the Inertia.Errors module docs for more information).

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
      |> assign_errors(changeset)
      |> redirect(to: ~p"/settings")
  end
end

The assign_errors function will automatically convert the changeset errors into a shape compatible with the client-side adapter. Since Inertia.js 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
|> assign_errors(%{
  name: "Name can't be blank",
  password: "Password must be at least 5 characters"
})

Flash messages

This library automatically includes Phoenix flash data in the Inertia page object as a top-level flash key (alongside component, props, url, and version).

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: ~p"/settings")

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

When Inertia (or the browser) redirects to the /settings page, the Inertia component will receive the flash data:

{
  "component": "...",
  "props": {
    // ...
  },
  "flash": {
    "info": "Settings updated"
  }
}

On the client-side, you can access flash data via usePage().flash.

CSRF protection

This library automatically sets the XSRF-TOKEN cookie on each response. Inertia's built-in HTTP client reads this cookie and forwards the value on subsequent requests, but it sends it via the X-XSRF-TOKEN header by default. Since Phoenix expects to receive the CSRF token via the x-csrf-token header, override the header name when initializing your Inertia app:

// assets/js/app.js

createInertiaApp({
  http: {
    xsrfHeaderName: "x-csrf-token",
  },
  // the rest of your Inertia client setup...
})

History

Requires Inertia v2.x or later on the client-side.

Encryption

If your page props contain sensitive data (such as information about the currently-authenticated user), you can opt to encrypt the history data that's cached in the browser.

conn
|> encrypt_history()

You can also enable history encryption globally in your application config:

config :inertia,
  history: [encrypt: true]

Clearing history

To instruct the client to clear this history (for example, when a user logs out), you can use the clear_history/1 helper when building your response.

conn
|> clear_history()

Testing

The Inertia.Testing module includes helpers for testing your Inertia controller responses. The following helpers are available:

Helper Description
inertia_component/1 Returns the component name
inertia_props/1 Returns the props map
inertia_errors/1 Returns validation errors (from props or session)
inertia_flash/1 Returns the flash map
inertia_page/1 Returns the full page object
inertia_shared_props/1 Returns shared prop keys
inertia_deferred_props/1 Returns deferred prop groups
inertia_merge_props/1 Returns merge prop paths
inertia_scroll_props/1 Returns scroll pagination metadata
inertia_once_props/1 Returns once prop metadata
use MyAppWeb.ConnCase

import Inertia.Testing

describe "GET /" do
  test "renders the home page", %{conn: conn} do
    conn = get("/")
    assert inertia_component(conn) == "Home"
    assert %{user: %{id: 1}} = inertia_props(conn)
    assert inertia_flash(conn) == %{}
  end
end
use MyAppWeb.ConnCase

import Inertia.Testing

describe "POST /users" do
  test "fails when name empty", %{conn: conn} do
    conn = post("/users", %{"name" => ""})

    assert redirected_to(conn) == ~p"/users"
    assert inertia_errors(conn) == %{"name" => "can't be blank"}
  end
end

We recommend importing Inertia.Testing in your ConnCase helper, so that it will be at the ready for all your controller tests:

defmodule MyApp.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      import Inertia.Testing

      # ...
    end
  end
end

Server-side rendering

The Inertia.js client library comes with with server-side rendering (SSR) support, which means you can have your Inertia-powered client hydrate HTML that has been pre-rendered on the server (instead of performing the initial DOM rendering).

[!NOTE] The steps for enabling SSR in Phoenix are similar to other backend frameworks, but instead of running a separate Node.js server process to render HTML, this library 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.js docs.

Add a server-side rendering module

You'll need to create a JavaScript module that exports a render function to perform the actual server-side rendering of pages. For the purpose of these instructions, we'll assume you're using React. The steps would be similar for other front-end environments supported by Inertia.js, such as Vue and Svelte.

Suppose your main app.jsx file looks something like this:

// assets/js/app.jsx

import React from "react";
import { createInertiaApp } from "@inertiajs/react";
import { createRoot } from "react-dom/client";

createInertiaApp({
  resolve: async (name) => {
    return await import(`./pages/${name}.jsx`);
  },
  setup({ App, el, props }) {
    createRoot(el).render(<App {...props} />);
  },
});

You'll need to create a second JavaScript file (alongside your app.jsx) that exports a render function. Let's name it ssr.jsx.

// assets/js/ssr.jsx

import React from "react";
import ReactDOMServer from "react-dom/server";
import { createInertiaApp } from "@inertiajs/react";

export function render(page) {
  return createInertiaApp({
    page,
    render: ReactDOMServer.renderToString,
    resolve: async (name) => {
      return await import(`./pages/${name}.jsx`);
    },
    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.

Next, configure esbuild to compile the ssr.jsx bundle.

  # config/config.exs

  config :esbuild,
    version: "0.21.5",
    app: [
      args: ~w(js/app.jsx --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
      cd: Path.expand("../assets", __DIR__),
      env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
    ],
+   ssr: [
+     args: ~w(js/ssr.jsx --bundle --platform=node --outdir=../priv --format=cjs),
+     cd: Path.expand("../assets", __DIR__),
+     env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
+   ]

Add the ssr build to the watchers in your dev environment, alongside the other asset watchers:

  # config/dev.exs
  config :my_app, MyAppWeb.Endpoint,
    # Binding to loopback ipv4 address prevents access from other machines.
    # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
    http: [ip: {127, 0, 0, 1}, port: 4000],
    check_origin: false,
    code_reloader: true,
    debug_errors: true,
    secret_key_base: "4Z2yyTu6Uy8AM+MguG3oldEf4aIdswR2BsCm1OtqDK0lEv++T02KktRaXfMbC/Zs",
    watchers: [
      esbuild: {Esbuild, :install_and_run, [:app, ~w(--sourcemap=inline --watch)]},
+     ssr: {Esbuild, :install_and_run, [:ssr, ~w(--sourcemap=inline --watch)]},
      tailwind: {Tailwind, :install_and_run, [:my_app, ~w(--watch)]}
    ]

Add the ssr build step to the asset build and deploy scripts.

  # mix.exs

  defp aliases do
    [
      setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
      "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
      "ecto.reset": ["ecto.drop", "ecto.setup"],
      test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
      "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
-     "assets.build": ["tailwind app", "esbuild app"],
+     "assets.build": ["tailwind app", "esbuild app", "esbuild ssr"],
      "assets.deploy": [
        "tailwind app --minify",
        "esbuild app --minify",
+       "esbuild ssr",
        "phx.digest"
      ]
    ]
  end

As configured, this will place the generated ssr.js bundle into the priv directory. Since it's generated code, add it to your .gitignore file.

  # .gitignore

+ /priv/ssr.js

Configuring your app for server-rendering

Now that you have a Node.js module capable of server-rendering your pages, youll need to tell the Inertia.js Phoenix library to perform SSR.

First, add the Inertia.SSR module to your application's supervision tree.

  # lib/my_app/application.ex

  defmodule MyApp.Application do
    use Application

    @impl true
    def start(_type, _args) do
      children = [
        MyAppWeb.Telemetry,
        MyApp.Repo,
        {DNSCluster, query: Application.get_env(:MyApp, :dns_cluster_query) || :ignore},
        {Phoenix.PubSub, name: MyApp.PubSub},
        # Start the Finch HTTP client for sending emails
        {Finch, name: MyApp.Finch},
        # Start a worker by calling: MyApp.Worker.start_link(arg)
        # {MyApp.Worker, arg},

+       # Start the SSR process pool
+       # You must specify a `path` option to locate the directory where the `ssr.js` file lives.
+       {Inertia.SSR, path: Path.join([Application.app_dir(:my_app), "priv"])},

        # Start to serve requests, typically the last entry
        MyAppWeb.Endpoint,
      ]

Then, update your config to enable SSR (if you'd like to enable it globally).

  # config/config.exs

  config :inertia,
    # The Phoenix Endpoint module for your application. This is used for building
    # asset URLs to compute a unique version hash to track when something has
    # changed (and a reload is required on the frontend).
    endpoint: MyAppWeb.Endpoint,

    # An optional list of static file paths to track for changes. You'll generally
    # want to include any JavaScript assets that may require a page refresh when
    # modified.
    static_paths: ["/assets/app.js"],

    # The default version string to use (if you decide not to track any static
    # assets using the `static_paths` config). Defaults to "1".
    default_version: "1",

    # Enable server-side rendering for page responses (requires some additional setup,
    # see instructions below). Defaults to `false`.
-   ssr: false
+   ssr: true

    # Whether to raise an exception when server-side rendering fails (only applies
    # when SSR is enabled). Defaults to `true`.
    #
    # Recommended: enable in non-production environments and disable in production,
    # so that SSR failures will not cause 500 errors (but instead will fallback to
    # CSR).
    raise_on_ssr_failure: config_env() != :prod

Excluding paths from SSR

If you want to disable SSR for certain paths (e.g. pages that don't need SEO or are too expensive to server-render), you can configure ssr_exclude_paths:

config :inertia,
  ssr: true,
  ssr_exclude_paths: [
    "/admin",             # String prefix: matches /admin, /admin/users, etc.
    ~r/^\/dashboard\//    # Regex: matches /dashboard/stats, /dashboard/reports, etc.
  ]

Installing Node.js in your production

You need to have Node.js installed in your production server environment, so that we can call the SSR script when serving pages. These steps assume you are deploying your application using a Dockerfile and releases.

If you haven't installed node into your runner image, add the following command to your Dockerfile (after the FROM ${RUNNER_IMAGE} step).

  FROM ${RUNNER_IMAGE}

  # install curl (and a few other packages)
  RUN apt-get update -y && \
-     apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates && \
+     apt-get install -y libstdc++6 openssl curl libncurses5 locales ca-certificates && \
      apt-get clean && rm -f /var/lib/apt/lists/*_*

  # install Node.js
+ RUN curl -fsSL https://deb.nodesource.com/setup_x.x | bash - && \
+    apt-get update && \
+    apt-get install -y nodejs

  # ...

  ENV MIX_ENV="prod"

  # ensure node is running in production mode
+ ENV NODE_ENV="production"

[!IMPORTANT] Be sure to set NODE_ENV=production, so that the SSR script is cached in memory. Otherwise, your page rendering times will be very slow!

Client side hydration

Follow the instructions from the Inertia.js docs for updating your client-side code to hydrate the pre-rendered HTML coming from the server.

Using our example React script from above, the adaptation looks like this:

  // assets/js/app.jsx

  import React from "react";
  import { createInertiaApp } from "@inertiajs/react";
- import { createRoot } from "react-dom/client";
+ import { hydrateRoot } from "react-dom/client";

  createInertiaApp({
    resolve: async (name) => {
      return await import(`./pages/${name}.jsx`);
    },
    setup({ App, el, props }) {
-     createRoot(el).render(<App {...props} />);
+     hydrateRoot(el, <App {...props} />);
    },
  });

Maintained by the team at SavvyCal