preloader
blog post

Rails 7 + Vuetify 3 (MPA Style)

Preface

In last decade, there has been a significant emphasis on single-page applications (SPA) and many modern frontend frameworks encourage their use. However, we believe that although the SPA approach has its advantages, the amount of additional complexity it introduces is not justified. Most of the time, when developing web applications, the goal is to build the best possible product efficiently. At Mailsnag, we have chosen to utilize modern frontend frameworks like Vue and Vuetify. However, instead of following the typical Vue + Router SPA approach, we have opted for a Multi-Page Application (MPA) approach.

In this article, we will demonstrate how to set up a Rails 7 project with Vuetify 3 (and Vue 3 ) using the MPA approach. We will also use Turbo Drive to get the performance benefits of SPA without the added complexity.

You can access the project’s source code in our GitHub repository . Additionally, each section will include a link to the corresponding changes.

Get Ready

This guide was written using Ruby v3.2, Node.js v18, and Yarn v1.22. Please ensure that you have these tools available in your development environment so that you can follow along.

Create a New Rails App

To begin, install the rails 7 gem and create a new app named rails7-vuetify3. Since we won’t be using default JavaScript bundler and the Asset Pipeline, we’ll instruct Rails to skip them during app creation. Feel free to customize the app as needed, such as specifying database options. Additionally, we’ll add a simple blogs controller with index and show pages, which will be used later to render our Vue pages.

$ gem install rails -v 7.0.5
$ rails new --skip-asset-pipeline --skip-javascript rails7-vuetify3
$ cd rails7-vuetify3
$ rails g controller blogs index show

Fix routes by replacing contents of config/routes.rb with:

Rails.application.routes.draw do
  resources :blogs

  # Defines the root path route ("/")
  root "blogs#index"
end

Link to the changes

Add Vite support

Vuetify 3 is using vite for dev server and building production assets. It has a good support for rails through vite-rails gem. Let’s add it to our project:

$ bundle add vite_rails
$ bundle exec vite install
$ yarn install

Link to the changes

Add Vuetify

Now that we have vite working with rails, we need to add and configure vuetify app. First, let’s add vuetify package to our package.json and couple extra packages that will be used by vuetify and vite:

$ yarn add vue vuetify @mdi/js
$ yarn add -D vite-plugin-rails vite-plugin-vuetify @vitejs/plugin-vue

Since we will be using some of the vue syntax in our rails views, we will need to use vue with runtime compiler. Take a look at vite.js.ts to see how that is achieved. We will also need to configure vite and rails to create vuetify app. Take a look at changes needed below.

Replace vite.config.ts with:

import { defineConfig } from "vite";
import RailsPlugin from "vite-plugin-rails";
import vue from "@vitejs/plugin-vue";
import vuetify from "vite-plugin-vuetify";

export default defineConfig({
  plugins: [RailsPlugin({ stimulus: false }), vue(), vuetify()],
  resolve: {
    alias: {
      vue: "vue/dist/vue.esm-bundler.js",
    },
    extensions: [
      ".mjs",
      ".js",
      ".ts",
      ".jsx",
      ".tsx",
      ".json",
      ".vue",
      ".sass",
      ".scss",
      ".css",
      ".png",
      ".svg",
    ],
  },
  appType: "mpa",
  clearScreen: false,
});

replace app/frontend/entrypoints/application.js with:

import { createApp } from "vue";
import vuetify from "~/plugins/vuetify";

import App from "~/app";

const app = createApp({});

app.use(vuetify);

app.component("App", App);

app.mount("#app");

replace app/views/layouts/application.html.erb with:

<!DOCTYPE html>
<html>
  <head>
    <title>Rails7Vuetify3</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= vite_client_tag %>
    <%= vite_javascript_tag 'application' %>
  </head>

  <body>
    <div id="app">
      <app>
        <%= yield %>
      </app>
    </div>
  </body>
</html>

Let’s create vuetify plugin in app/frontend/plugins/vuetify.js:

import { createVuetify } from "vuetify";
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";

export default createVuetify({
  icons: {
    defaultSet: "mdi",
    aliases,
    sets: {
      mdi,
    },
  },
});

And finally let's add our `app.vue` component `app/frontend/app.vue`:
<template>
  <v-app id="vuetify-app">
    <v-main>
      <p>Vuetify 3 App Running 🍾🎉</p>
      <slot></slot>
    </v-main>
  </v-app>
</template>

<script>
  export default {};
</script>

Now if you refresh blogs#index route (http://0.0.0.0:3000/blogs) you should see Vuetify 3 App Running 🍾🎉

Link to the changes

Add Blog Pages

Now that we have vuetify app working in our rails views, we can start adding individual pages for Blog index and show actions. For this we will create pages/blogs/index.vue and pages/blogs/show.vue Vue SFC components in our app/frontend directory. Then we will reference these components in our rails views and pass data to it.

Create app/frontend/pages/blogs/index.vue component:

<template>
  <v-container>
    <v-row>
      <v-col>
        <v-card>
          <v-card-text>
            <v-table hover>
              <template #default>
                <thead>
                  <tr>
                    <th class="text-start" style="width: 70px;">ID</th>
                    <th class="text-start">Name</th>
                  </tr>
                </thead>
                <tbody>
                  <tr v-for="blog in blogs" :key="blog.id">
                    <td>
                      <code>{{ blog.id }}</code>
                    </td>
                    <td>
                      <v-btn variant="plain" :href="blogLink(blog)">
                        <v-icon start>{{ mdiPost }}</v-icon> {{ blog.name }}
                      </v-btn>
                    </td>
                  </tr>
                </tbody>
              </template>
            </v-table>
          </v-card-text>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
  import { mdiPost } from "@mdi/js";

  export default {
    props: {
      blogs: { type: Array, required: true },
    },
    data() {
      return {
        mdiPost,
      };
    },
    methods: {
      blogLink(blog) {
        return `/blogs/${blog.id}`;
      },
    },
  };
</script>

And app/frontend/pages/blogs/show.vue component:

<template>
  <v-container>
    <v-row>
      <v-col>
        <v-card>
          <v-card-title>
            <v-icon start>{{ mdiPost }}</v-icon>
            Showing Blog {{ blog.id }}
          </v-card-title>
          <v-card-text> Blog contents go here: {{ blog.name }} </v-card-text>
          <v-card-actions>
            <v-btn variant="plain" :href="blogsLink()"> Back to Blogs </v-btn>
          </v-card-actions>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
  import { mdiPost } from "@mdi/js";

  export default {
    props: {
      blog: { type: Object, required: true },
    },
    data() {
      return {
        mdiPost,
      };
    },
    methods: {
      blogsLink() {
        return `/blogs`;
      },
    },
  };
</script>

Let’s register those components as async components so they are only loaded when rendered. Update app/frontend/entrypoints/application.js with:

import { createApp, defineAsyncComponent } from "vue";
import vuetify from "~/plugins/vuetify";

import App from "~/app";
const BlogsIndex = defineAsyncComponent(() => import("~/pages/blogs/index"));
const BlogsShow = defineAsyncComponent(() => import("~/pages/blogs/show"));

const app = createApp({});

app.use(vuetify);

app.component("App", App);
app.component("BlogsIndex", BlogsIndex);
app.component("BlogsShow", BlogsShow);

app.mount("#app");

Now, lets add some content in our BlogsController and render components in erb view files.

Update app/controllers/blogs_controller.rb with:

class BlogsController < ApplicationController
  def index
    @blogs = [
      { id: 1, name: "My first blog"},
      { id: 2, name: "Another blog"}
    ]
  end

  def show
    @blog = { id: params[:id], name: "Some random blog #{params[:id]}"}
  end
end

Update app/views/blogs/index.html.erb with:

<blogs-index :blogs="<%= @blogs.to_json %>" />

And app/views/blogs/show.html.erb with:

<blogs-show :blog="<%= @blog.to_json %>" />

If you refresh toy blogs#index (http://0.0.0.0:3000/blogs) page, you should see our vue components rendering the blogs table with links to navigate to individual blog pages.

Link to the changes

Getting SPA Benefits Without SPA Complexity

Every time we navigate to a different page, we usually have to wait for the browser to fetch the necessary assets and render the page. This process can cause some slowdown, even if we have effective caching settings in place. The browser often needs to “recompile” or “run” these assets, further contributing to the delay. However, we can take advantage of Turbo Drive to circumvent the need for loading and “compiling” assets with every page load. This approach leads to a faster and smoother user experience, resembling that of SPA.

First, let’s add required packages:

$ yarn add @hotwired/turbo-rails vue-turbolinks

Then update app/views/layouts/application.html.erb to use turbolinks by adding 'data-turbo-track': 'reload' to vite_javascript_tag. Your vite_javascript_tag should like following:

  <%= vite_javascript_tag 'application', 'data-turbo-track': 'reload' %>

Finally, update app/frontend/entrypoints/application.js to utilize Turbo Drive:

import * as Turbo from "@hotwired/turbo";
Turbo.start();

import { createApp, defineAsyncComponent } from "vue";
import TurbolinksAdapter from "vue-turbolinks";
import vuetify from "~/plugins/vuetify";

import App from "~/app";
const BlogsIndex = defineAsyncComponent(() => import("~/pages/blogs/index"));
const BlogsShow = defineAsyncComponent(() => import("~/pages/blogs/show"));

document.addEventListener("turbo:load", () => {
  const app = createApp({});

  app.use(TurbolinksAdapter);
  app.use(vuetify);

  app.component("App", App);
  app.component("BlogsIndex", BlogsIndex);
  app.component("BlogsShow", BlogsShow);

  app.mount("#app");
});

Now if you inspect your networks tab in your browser’s dev tools, you will see that when navigating between pages, related assets are only loaded once. You should also notice that rendering of the page is very fast compared to previous experience.

Link to the changes

TL;DR

We showed you how to set up brand new Rails 7 project with modern frontend framework. We also showed how to utilize Vue components within your App’s erb view templates, keep your frontend MPA while having speed close to SPA. We hope this will help you in your next project.

Until next time amigos 🙌

Related Articles

Ready to get started?

No credit card required to sign up. All paid plans come with 30 day cancellation and full refund.

Get Started for Free