Posts Rails and Inertia.js - The Modern Monolith
Post
Cancel

Rails and Inertia.js - The Modern Monolith

Rails is a powerful framework that gives us out of the box, tools like Hotwire, this helps us to build reactive applications, and also, we have external tools like Stimulus Reflex which like Hotwire, offer interactions that user is looking for (you can see this post about reactive applications with Reflex).

But sometimes, you are looking for something else, maybe things that, tools like React or Vue can give you, can be that feeling of a SPA (Single Page Application). Rails has libraries like Webpacker that help us to use modern JS frameworks in an easy way but after installation, you need to do a lot of other things to get them to work in your applications, for example, you need to create some API’s and then call them from JS or in some cases, is necessary to use client-side routing and it feels like you have two applications even when you have a monolith.

Here, is where Inertia.js comes to the action! 🎉

What’s Inertia.js?

It’s a JS library that allows us to build a SPA (Single Page Applications) using the classic server-side render, I mean, using the views render, routing, and controllers as we would commonly do from the server-side without using API’s or client-side routing, it only changes the way we render views, instead of returning HTML, views here are JavaScript Page Components.

At this moment, Inertia just have three official client-side adapters (React, Vue.js, and Svelte) and two server-side adapters (Laravel and Rails).

How does it work?

If you want to create a SPA, it’s not enough to have your views with JS, because if you click in a link to navigate to another page, your browser would do a full reload page so this is not a behavior of a SPA 😞

Inertia, actually, is a client-side routing library that intercepts your clicks 😧 using its own anchor link wrapper called <inertia-link>, when you click on this element, Inertia intercepts this event and makes the visit via XHR (XMLHttpRequest) instead.

So, when Inertia makes an XHR visit, the server can detect that it’s an Inertia request and instead of returning HTML, it returns a JSON response with the JavaScript page component name and data, finally, Inertia just replace this new component into the page and updates the history state.

How does the server know if it’s an Inertia request?

When someone makes the first request to your application, it’s a full reload page and it returns HTML, there aren’t any special headers at this point.

The previous HTML response serves the root div as a mounting point for the JS side, and this root contains the data-page attribute with a JSON page object.

Inertia uses this page object to share data between client and server, this object contains these properties:

  1. Component: The name of the JavaScript page component.
  2. Props
  3. URL: The page URL
  4. Version: The current asset version (if there’s asset changes, Inertia will automatically make a full page visit instead of an XHR visit).

The next requests are made via XHR with the X-Inertia header set to true (using the Inertia element anchor), this means that this request is through Inertia so, this the way that server knows if return just HTML or JS Page Components.

Next section will do an example with Rails and Vue.js! 🤟🏼

Let’s code!

Creating the Rails project

When we create a new project, it’s necessary skip turbolinks because it’s not compatible with Inertia, but if you have a project with it and you want to add Inertia, you can disable Turbolinks for any response sent to Inertia, for example redirect_to root_path, turbolinks: false, for now, this the command to create the project:

1
rails new inertia_example --skip-turbolinks

Installing Vue and Inertia

We could have installed Vue from the beginning like this:

1
rails new inertia_example --webpacker=vue --skip-turbolinks

But I want to show you how we can do it after installation in case anyone has a project already started, you can install Vue in this way:

1
rails webpacker:install:vue

Once Vue is installed, we need to add Inertia with this command:

1
yarn add @inertiajs/inertia @inertiajs/inertia-vue @inertiajs/progress

With this, we are installing Inertia, the Inertia Vue adapter and a progress bar, Inertia has an optional progress bar, so if you don’t want to install it, just remove it from the command.

Next step is to install Inertia to the server-side running this command in the terminal:

1
bundle add 'inertia_rails'

This will install the latest version of inertia_rails gem, and finally it’s necessary add defer: true to the javascript_pack_tag into the app/views/layouts/application.html.erb page:

1
<%= javascript_pack_tag 'application', defer: true %>

Adding defer we are telling to the application.js that will be executed after the page has been parsed, avoiding render errors with the dataset.

Setting up

After Vue installation, we can delete these files app/javascript/packs/hello_vue.js and app/javascript/app.vue because we don’t need them for now and let’s initialize Vue inside app/javascript/packs/application.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app/javascript/packs/application.js

...
import Vue from 'vue'
import { App, plugin } from '@inertiajs/inertia-vue'
import { InertiaProgress } from '@inertiajs/progress'

Vue.use(plugin)
InertiaProgress.init()

// Instead of using App.vue page, Inertia
// will use Rails application.html.erb layout page
const el = document.getElementById('app')

new Vue({
  render: h => h(App, {
    props: {
      initialPage: JSON.parse(el.dataset.page),
      resolveComponent: name => require(`../pages/${name}`).default,
    },
  }),
}).$mount(el)

You don’t need to add the ID app to the root element, the Inertia adapter will do it for you 👍, initialPage gets the page where the response from the controller will be inserted, this page is a data attribute inside the root element and resolveComponent will look at the pages directory for the views, this is a common convention if you ever used Vue or Nuxt.

Adding Tailwindcss

I’m going to use the tailwindcss-rails gem, the only thing you need to do is to run these two commands in the console:

1
2
bundle add tailwindcss-rails
rails tailwindcss:install

After that, you already have Tailwindcss running in your project, you can also remove or comment the stylesheet_link_tag from your app/views/layouts/application.html.erb.

Creating the Home page with Inertia

Let’s create the HomeController here app/controllers/home_controller.rb with this 👇🏼

1
2
3
4
5
class HomeController < ApplicationController
  def index
    render inertia: 'Home', props: {}
  end
end

We are just telling it that we’re going to render a view called Home placed into app/javascript/pages without any props, just the view, remember the folder pages was defined in our application.js in Setting up section.

Next step is to add the root in the config/routes.rb file:

1
2
3
Rails.application.routes.draw do
  root 'home#index'
end

Finally, we create the folder pages within app/javascript dir and we’re going to add the Home.vue file

1
2
3
4
5
6
7
8
<!-- app/javascript/pages/Home.vue -->

<template>
  <div>
    <h1 class="text-2xl text-center">Home</h1>
    <h2 class="text-xl text-center">This is a Vue page rendered with Inertiajs</h2>
  </div>
</template>

If you prefer, you can add these classes to the body element in application.html.erb:

1
2
3
4
5
6
<!-- app/views/layouts/application.html.erb -->

...
<body class="p-5 bg-gray-100 mx-auto">
  <%= yield %>
</body>

Run your Rails server with rails s and to see the changes without reloading the page manually, just run ./bin/webpack-dev-server in other console tab to watch the frontend changes, if you go to the browser, you’ll see something like this:

Home Page

You got it! 🎉 Your first Vue page rendered with Rails and Inertiajs!

Passing data to views

Now, we want to pass some data to Vue, for this, let’s create a model called Book 👇🏼

1
2
3
4
rails g model Book title author edition editorial
...

rails db:migrate

We have our model with some attributes and it’s time to create the Books Controller:

1
2
3
4
5
6
7
8
9
10
11
# app/controllers/books_controller.rb

class BooksController < ApplicationController
  def index
    books = Book.all

    render inertia: 'Books/Index', props: {
      books: books.as_json(only: [:id, :title, :author, :edition, :editorial])
    }
  end
end

Books/Index means that we have a folder Books within app/javascript/pages and an Index.vue inside of and we are passing it a books array with the attributes we defined previously, so let’s create the Books/Index.vue file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!-- app/javascript/pages/Books/Index.vue -->

<template>
  <div>
    <h1 class="text-2xl text-center">Books</h1>
    <table class="min-w-max w-full table-auto">
      <thead>
        <tr class="bg-gray-200 text-gray-600 uppercase text-sm leading-normal">
          <th class="py-3 px-6 text-left">Title</th>
          <th class="py-3 px-6 text-left">Author</th>
          <th class="py-3 px-6 text-center">Edition</th>
          <th class="py-3 px-6 text-center">Editorial</th>
          <th class="py-3 px-6 text-center">Actions</th>
        </tr>
      </thead>
      <tbody class="text-gray-600 text-sm font-light">
        <tr v-for="book in books" :key="book.id" class="border-b border-gray-200 hover:bg-gray-100">
          <td class="py-3 px-6 text-left"><span class="font-medium">{{ book.title }}</span></td>
          <td class="py-3 px-6 text-left"><span class="font-medium">{{ book.author }}</span></td>
          <td class="py-3 px-6 text-left"><span class="font-medium">{{ book.edition }}</span></td>
          <td class="py-3 px-6 text-left"><span class="font-medium">{{ book.editorial }}</span></td>
          <td class="py-3 px-6 text-left">
            <inertia-link :href="$routes.book(book.id)">See</inertia-link>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
  export default {
    props: {
      books: {
        type: Array,
        required: true,
      }
    }  
  }
</script>

And don’t miss to add the routes for our Books resource:

1
2
3
4
5
6
# config/routes.rb

Rails.application.routes.draw do
  root 'home#index'
  resources :books, only: %i[index show]
end

Before of going to the browser, I want to add some example data with the purpose of see something when we render the books page, you can add this code to db/seeds.rb and run rails db:seed to save those data into database

1
2
3
4
5
6
7
8
10.times do |i|
  Book.create(
    title: "Metaprogramming Ruby Version-#{i}",
    author: "Paolo Perrotta",
    edition: "Edition #{i}",
    editorial: "The Pragmatic Programmers",
  )
end

So, go to http://localhost:3000/books and you should see something like this:

Books page

Great! 🤟🏼 We are passing data from Rails to Vue using Inertia!

Before using the <inertia-link> element, go to books_controller.rb and create the show method to get a book:

1
2
3
4
5
6
7
def show
  book = Book.find(params[:id])

  render inertia: 'Books/Show', props: {
    book: book.as_json(only: [:id, :title, :author, :edition, :editorial])
  }
end

Now, we’re going to install a gem called js-routes, this gem defines all Rails named routes as Javascript helpers, and just need to run this command in the console:

1
bundle add "js-routes"

After installation create this file config/initializers/jsroutes.rb with this 👇🏼

1
2
3
4
5
6
7
8
# config/initializers/jsroutes.rb

JsRoutes.setup do |config|
  config.exclude = [/rails_/] # excludes rails generated routes
  config.compact = true       # removes the _path from the route name
  path = "app/javascript/packs" # destination folder
  JsRoutes.generate!("#{path}/routes.js")
end

This help us to generate automatically a file routes.js and you can use your Rails routes in Javascript, to use them in all places add the following within application.js:

1
2
3
4
5
// app/javascript/packs/application.js

...
import Routes from "./routes.js"
Vue.prototype.$routes = Routes

We can modify our Books/Index.vue file to add the <inertia-link> for each book:

1
2
3
4
...
<td class="py-3 px-6 text-left">
  <inertia-link :href="$routes.book(book.id)">See</inertia-link>
</td>

That is similar to use book_path(book.id) in Rails, we are passing the Booki ID to the book URL and finally, we can add the Books/Show.vue file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
  <div class="text-center">
    <h1 class="text-4xl font-bold mb-5"></h1>
    <h2 class="text-xl mb-3"></h2>
    <p class="font-light italic mb-5"> - </p>
    <inertia-link :href="$routes.books()" class="text-blue-500">Back</inertia-link>
  </div>
</template>

<script>
  export default {
    props: {
      book: {
        type: Object,
        required: true
      }
    },
  }
</script>

To be able to see the changes, go to the browser to /books path and click in some Book on the See link, and should see the Book view, something like this 👇🏼

Book page

If you open the devtools and navigate to the Network tab, choose All and then the request with the Book ID (in my case ID 2), yo can see on Request Headers section the X-Inertia: true and the X-Requested-With: XMLHttpRequest:

Book page

But remember, if you want to see this on the devtools, you need to come from the <inertia-link> element, because if you reload the page, you’re doing a full reload page.

So with this, you’ve implemented the Vue framework on Rails without needing an API or client-side routing, it’s just a common Rails application rendering Vue pages 🤟🏼.

See you next! 👋🏼

Reference

https://inertiajs.com/

This post is licensed under CC BY 4.0 by the author.