Skip to content
stoic man
February 2, 2025

Using Phoenix with React and Inertia

Getting the best of both worlds; Phoenix's productivity, and React's ecosystem

Introduction

I love React. I've worked with various people and companies, and I've seen its power in action. I've also seen some of the worst things done with it, but fixing those buys "Little Dutch" toys for my daughter, so I have no complaints there.

Jokes aside, React has a massive ecosystem and, most importantly, known unknowns. It's considered boring technology now (at least the SPA parts), and the problem you might have has already been tackled and documented somewhere.

My pain point has always been the back-end of things in JavaScript. Next.js is utterly unreliable for reasons I won't expand on here, and everything else (Remix/RR, TanStack) doesn't have opinions on trivial stuff I don't want to configure. On the other hand, frameworks like Rails and Laravel have great solutions but use languages I don't enjoy that much. So, Phoenix strikes the perfect balance.

To put these two frameworks together without having to ship two separate applications, I use Inertia.

Why not LiveView

While writing this post, I realized that I had to justify why I'm not using LiveView. If you're aware of Phoenix, chances are that you've heard of LiveView as well; there's a lot of hype around it.

Ultimately, I wrote too much about it and had to move it to a separate post. You can find my ramblings here.

In short, LiveView is not a good fit for my apps. For the past few years, I've worked heavily on building charts and data visualizations across different companies. My ability to build these in React, prototype something quickly, and then hand it off to a designer to polish is unmatched.

LiveView is a phenomenal choice if your use case doesn't need a lot of interactivity. Use it, and don't bother what I think.

What's Inertia exactly?

The idea behind Inertia is simple.

We want to replace the server-rendered views with JavaScript page components.

On the very first user visit, we return the full page. We also include a JSON payload with the initial data in the HTML. Then, the JavaScript framework of our choosing boots up and takes over.

It will listen for clicks, form submissions, and other events and send XHR requests to the server. Even simple links will be intercepted, and an HXR request will be sent. Assuming these requests to the backend have the X-Inertia header, the server responds with a JSON payload.

This JSON payload contains everything the front-end needs to render the page. It includes the component to render, any shared props, errors from validations, and flash messages.

While the idea is simple, many small details make it great. For example, you can request a subset of the data if you're revisiting the same page.

So, Inertia is not exactly a framework but a protocol implemented by different adapters. There are three server-side adapters (Laravel, Rails, and Phoenix), and three client-side adapters (React, Vue, Svelte).

Feel free to check out the docs page for more details.

Setting up Inertia

Everything I needed was perfectly documented in the Phoenix adapter.

Surprisingly, I didn't have to do much—mostly just copy-pasting the instructions. I made a few modifications in the config.ex and added a plug in router.ex.

After setting everything up, the front-end side of things works exactly as expected. We take advantage of Phoenix's asset pipeline (esbuild), and we're good to go.

I honestly expected some friction, but there was none. Here's what my front-end entry point looks like:

assets/js/app.jsx
import {createInertiaApp} from '@inertiajs/react';
import axios from 'axios';
import React from 'react';
import {createRoot} from 'react-dom/client';

axios.defaults.xsrfHeaderName = 'x-csrf-token';

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

and what the rest of the assets folder looks like:

folder_structure.txt
assets/
├── css/
├── js/
│   ├── components/
│   ├── hooks/
│   ├── layouts/
│   ├── lib/
│   └── pages/
│       ├── auth/
│       ├── 404.jsx
│       ├── 500.jsx
│       ├── home-page.jsx
│       ├── user-preferences-page.jsx
│       ├── user-settings-page.jsx
│       └── app.jsx
├── .prettierrc
├── eslint.config.mjs
├── package-lock.json
├── package.json
├── tailwind.config.js # Luckily, soon to be removed in tailwindcss v4
└── tsconfig.json

I appreciate the simplicity of this setup.

On the backend, my business logic in lib is untouched, and I only have my controllers to update. For example, I only replaced the render function with render_inertia in my generated user_session_controller.

lib/horionos_web/controllers/user_session_controller.ex
defmodule HorionosWeb.UserSessionController do
  use HorionosWeb, :controller

  alias Horionos.Accounts
  alias HorionosWeb.UserAuth

  def new(conn, _params) do
    # render(conn, :new, error_message: nil) # Before
    render_inertia(conn, "auth/login-page")  # After
  end

  def create(conn, %{"user" => user_params}) do
    %{"email" => email, "password" => password} = user_params

    if user = Accounts.get_user_by_email_and_password(email, password) do
      UserAuth.log_in_user(conn, user, user_params)
    else
      # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
      conn
      |> put_flash(:error, "You have entered an invalid email or password.")
      |> redirect(to: ~p"/users/log_in")
    end
  end

  def delete(conn, _params) do
    UserAuth.log_out_user(conn)
  end
end

Code splitting

The first challenge I had to solve was optimizing the bundle size. Without any modifications, you download everything at once, even pages you won't visit, and that isn't ideal.

I took a jab to fix it with React.lazy but realized there's nothing React or Inertia specific here. Since Phoenix uses esbuild, this is where I should look.

Enabling code splitting was straightforward, with the caveat that I had to ship the code as ES modules. For the full instructions, here's my PR to the Phoenix adapter.

In short, the changes needed are --splitting & --format=esm.

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

With that out of the way, we only have to download the code we need.

Using ES modules might be a blocker for some. It's a limitation of esbuild, and not Inertia. Laravel for example, uses Vite which in turn uses Rollup for the production builds. I don't know what Rails uses this year, so I can't comment. (I'm still salty about a painful Webpacker migration)

Routing

Here's another thing that I love about Inertia. It has no opinions on routing. Our backend is responsible for these decisions.

Of course you still have to make some decisions on the front-end side, especially regarding how to structure your nested layouts (if applicable).

Here's what my router looks like:

lib/horionos_web/router.ex
# .. imports and plugs

# Guest routes
scope "/", HorionosWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]

  get "/users/register", UserRegistrationController, :new
  post "/users/register", UserRegistrationController, :create
  get "/users/log_in", UserSessionController, :new
  post "/users/log_in", UserSessionController, :create
  get "/users/reset_password", UserResetPasswordController, :new
  post "/users/reset_password", UserResetPasswordController, :create
  get "/users/reset_password/:token", UserResetPasswordController, :edit
  put "/users/reset_password/:token", UserResetPasswordController, :update
end

# Authenticated routes
scope "/", HorionosWeb do
  pipe_through [:browser, :require_authenticated_user]

  get "/", HomeController, :home

  get "/users/preferences", UserPreferencesController, :edit
  put "/users/preferences", UserPreferencesController, :update

  get "/users/settings", UserSettingsController, :edit
  put "/users/settings", UserSettingsController, :update
  get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
end

# Mixed routes
scope "/", HorionosWeb do
  pipe_through [:browser]

  delete "/users/log_out", UserSessionController, :delete
  get "/users/confirm", UserConfirmationController, :new
  post "/users/confirm", UserConfirmationController, :create
  get "/users/confirm/:token", UserConfirmationController, :edit
  post "/users/confirm/:token", UserConfirmationController, :update
end

# Catch all route
scope "/", HorionosWeb do
  pipe_through [:browser]

  get "/*path", ErrorController, :not_found
end

I love how clean and simple this is. Using live_sessions made this so hard for me to follow and reason about.

Pages and controllers

Now, let's see how we render a page in more detail. Let's take the users/preferences path and check out the controller:

lib/horionos_web/controllers/user_preferences_controller.ex
defmodule HorionosWeb.UserPreferencesController do
  use HorionosWeb, :controller

  alias Horionos.Accounts

  def edit(conn, _params) do
    render_inertia(conn, "user-preferences-page")
  end

  def update(conn, params) do
    user = conn.assigns.current_user

    case Accounts.update_user_preferences(user, params) do
      {:ok, _user} ->
        conn
        |> put_flash(:info, "Preferences updated successfully")
        |> redirect(to: ~p"/users/preferences")

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

There's nothing special here. Instead of rendering a template, we give the name of the JavaScript file to render.

You might notice the assign_errors function. It's a helper provided by the Phoenix adapter, which converts changeset errors to a client-side friendly format.

Now here's my user-preferences-page.jsx, which gets rendered:

assets/js/pages/user-preferences-page.jsx
import ComputerDesktopIcon from '@heroicons/react/20/solid/ComputerDesktopIcon';
import MoonIcon from '@heroicons/react/20/solid/MoonIcon';
import SunIcon from '@heroicons/react/20/solid/SunIcon';
import {Head} from '@inertiajs/react';
import {useForm} from '@inertiajs/react';
import React from 'react';

import {Button} from '~/components/button';
import {Divider} from '~/components/divider';
import {ErrorBoundary} from '~/components/error-boundary';
import {ErrorMessage, Field, Label} from '~/components/fieldset';
import {Heading, Subheading} from '~/components/heading';
import {Listbox, ListboxLabel, ListboxOption} from '~/components/listbox';
import {Text} from '~/components/text';
import {useCurrentUser} from '~/hooks/use-current-user';
import {MainLayout} from '~/layouts/main';

const THEMES = [
  {value: 'system', label: 'System', icon: ComputerDesktopIcon},
  {value: 'light', label: 'Light', icon: SunIcon},
  {value: 'dark', label: 'Dark', icon: MoonIcon},
];

const DATE_FORMATS = [
  {value: 'YYYY-MM-DD', label: 'YYYY-MM-DD'},
  {value: 'MM-DD-YYYY', label: 'MM-DD-YYYY'},
  {value: 'DD-MM-YYYY', label: 'DD-MM-YYYY'},
];

function Page() {
  const user = useCurrentUser();
  const preferencesForm = useForm({
    theme: user.preferences.theme,
    date_format: user.preferences.date_format,
  });

  function onPreferencesChangeSubmit(event) {
    event.preventDefault();

    preferencesForm.put('/users/preferences');
  }

  return (
    <MainLayout>
      <Head title="Preferences" />
      <div className="mx-auto max-w-4xl">
        <Heading>Preferences</Heading>
        <Divider className="my-10 mt-6" />
        <form onSubmit={onPreferencesChangeSubmit}>
          <section className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
            <div className="space-y-1">
              <Subheading>Theme</Subheading>
              <Text className="text-balance">
                Choose a theme for the application. The theme will be applied to
                all pages.
              </Text>
            </div>
            <Field>
              <Label>Theme</Label>
              <Listbox
                name="theme"
                value={preferencesForm.data.theme}
                onChange={(value) => preferencesForm.setData('theme', value)}
                disabled={preferencesForm.processing}
              >
                {THEMES.map(({value, label, icon: Icon}) => (
                  <ListboxOption key={value} value={value}>
                    <ListboxLabel className="flex gap-2 items-center">
                      <Icon className="size-4" />
                      <span>{label}</span>
                    </ListboxLabel>
                  </ListboxOption>
                ))}
              </Listbox>
              {preferencesForm.errors.theme && (
                <ErrorMessage>{preferencesForm.errors.theme}</ErrorMessage>
              )}
            </Field>
          </section>
          <Divider className="my-10" soft />
          <section className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
            <div className="space-y-1">
              <Subheading>Date format</Subheading>
              <Text className="text-balance">
                Choose how you want dates to be displayed.
              </Text>
            </div>
            <Field>
              <Label>Date format</Label>
              <Listbox
                name="date_format"
                value={preferencesForm.data.date_format}
                onChange={(value) =>
                  preferencesForm.setData('date_format', value)
                }
                disabled={preferencesForm.processing}
              >
                {DATE_FORMATS.map(({value, label}) => (
                  <ListboxOption key={value} value={value}>
                    <ListboxLabel>{label}</ListboxLabel>
                  </ListboxOption>
                ))}
              </Listbox>
              {preferencesForm.errors.date_format && (
                <ErrorMessage>
                  {preferencesForm.errors.date_format}
                </ErrorMessage>
              )}
            </Field>
          </section>
          <Divider className="my-10" soft />
          <div className="flex justify-end gap-4">
            <Button
              type="button"
              plain
              onClick={() => {
                preferencesForm.reset();
              }}
            >
              Reset
            </Button>
            <Button type="submit" disabled={preferencesForm.processing}>
              Save changes
            </Button>
          </div>
        </form>
      </div>
    </MainLayout>
  );
}

export default function UserPreferencesPage(props) {
  return (
    <ErrorBoundary>
      <Page {...props} />
    </ErrorBoundary>
  );
}

I'm bringing my favourite UI library, and I iterate without caring about the backend. I love it. I have complete front-end control.

If I want to tweak the backend response, I update my .ex files without opening a second repo. Everything works in harmony.

Now, you'll notice the useForm helper. This is the glue code that Inertia provides to handle form submissions.

You might also notice that I use snake_case sometimes. You have the option to camelize the props in the Inertia adapter config, but somehow mixing them doesn't annoy me 🤔

I won't lie. I miss the way I handle forms with Remix/RR. I don't enjoy having to event.preventDefault my forms or not using FormData. But I'm willing to make that trade-off for the productivity I get here. You can read more about the form helpers from Inertia here.

Authentication

No opinions here what so ever. I use the phx.gen.auth generator, and I'm good to go.

If the user is logged in, I just pass their details as shared data. Here's how.

First, I pick the fields I want to expose from the User schema:

lib/horionos/accounts/schemas/user.ex
defmodule Horionos.Accounts.Schemas.User do
# ...
  @derive {Jason.Encoder, only: [:id, :full_name, :email, :confirmed_at, :preferences]}
# ...
end

Then, I update my require_authenticated_user plug to include the user:

lib/horionos_web/user_auth.ex
def require_authenticated_user(conn, _opts) do
  user = conn.assigns[:current_user]

  if user do
    conn
    |> assign_prop(:current_user, user)
  else
    conn
    |> put_flash(:error, "You must log in to access this page.")
    |> maybe_store_return_to()
    |> redirect(to: ~p"/users/log_in")
    |> halt()
  end
end

And the front-end takes over with a simple re-usable hook.

assets/js/hooks/use-current-user.tsx
import {PageProps} from '@inertiajs/core';
import {usePage} from '@inertiajs/react';

interface UserPreferences {
  theme: 'light' | 'dark' | 'system';
  date_format: 'YYYY-MM-DD' | 'DD/MM/YYYY' | 'MM/DD/YYYY';
}

interface CurrentUser {
  email: string;
  full_name?: string;
  confirmed_at: string | null;
  preferences: UserPreferences;
}

export function useCurrentUser() {
  const {props} = usePage<PageProps & {current_user: CurrentUser}>();
  return props.current_user;
}

export type {CurrentUser, UserPreferences};

I haven't decided if using TypeScript makes sense here. I just wanted to see how easy it was to set up. Turns out it's trivial, and you only need to add the loader to your esbuild config.

Caveats

There are a few caveats we should be aware of.

  1. The Phoenix adapter is the biggest point of failure. We depend on the maintainers to keep it up to date with the latest Inertia changes. Inertia isn't that widely used in the Phoenix space (compared to Laravel which has first-class support), so it's good to keep that in mind.
  2. Phoenix bets heavily on LiveView. If you're picking Phoenix expecting some feature parity with frameworks like Laravel, you will be disappointed. You'll see releases that may not interest you.
  3. Now, this isn't related to Inertia, but React seems to be eyeing the server-side rendering space. I don't see that much love for SPA's anymore, and the React docs don't even mention Vite, the best way to run SPAs in production. If you want to use React, like I do, maybe we're not the target audience anymore.

I'm okay with React exporing a different space, and focusing on RSC. I'm also okay with Phoenix polishing its LiveView offering. I'm only keeping an eye on the Phoenix adapter, but there's always the option to fork it if things go south.

Conclusion

I tried hard to make full-stack JS work, but I keep working on the same (solved) problems that Javascript frameworks don't care about. Examples include:

  • emails, trapping them during development, and testing
  • async jobs
  • file uploads
  • orm or query builder, migrations
  • multi-tenancy, authorization, roles
  • impersonation
  • logs
  • tracing
  • .. more

I'm doing all of these because I'm silly and like to write JSX. It is probably not a reasonable tradeoff.

With Inertia, I can have my cake and eat it too. I bring the backend framework I like, and I let React take over my front-end. One toolchain, one repo, one deployment pipeline.

Being able to combine Elixir and React is a game changer. I've experienced some years of burnout and not enjoying coding professionally, and this has been a breath of fresh air.

So, to conclude, I'm very happy with this setup. If you love LiveView and it gives you that newfound enthusiasm for coding, I'm so happy for you. Likewise, for the Rails renaissance.

While this post is React-centric, nothing stops you from using Vue or Svelte with Phoenix instead. I hope you try Inertia and see if it fits your workflow.

Resources