Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

January 25, 2019

Last updated November 5, 2023

Ruby on Rails API with Vue.js

Did you know Ruby on Rails can be used as a strict API based backend application? What's the benefit to this? Think of it as a single source of truth for multiple future applications to absorb and use this data directly. Anything from a native mobile application, to a front-end framework, can talk with this data. Many apps can essentially communicate with a "source of truth" in return which means more consistent applications for all.

In this build, I'll be crafting a simple but thorough application where Ruby on Rails is our backend and Vue.js + Axios is our front-end. I'll create two apps that communicate in order to achieve the same result of a normal Rails-based app but with all the perks of an API.

Used in this build

What are we building exactly?

This app at its core is simple. It will be an archive of vinyl records for sale and categorized by artist. We won't be implementing a ton of foreign logic but rather just getting the foundations of an API-based application in order. We'll touch on authentication (not using Devise 😉) and basic CRUD.

There will be two apps.

  • A Ruby on Rails backend - This will handle our data, sessions, and authentication.
  • A Vue.js frontend - This will be the view layer but also the one responsible for sending and receiving data to our rails-based backend. The front-end will run on a different instance using the Vue-CLI to help us set up an app.

Download the source code

The videos

Part 1

Part 2

Part 3

Part 4

Part 5

Part 6

Part 7

Part 8

The Backend

Our backend will be a very trimmed down Rails app with no view-based layer. Rails has a handy "api" mode which you can initialize by passing the flag --api during the creation of a new app. Let's dive in.

Create the app in API mode

$ rails new recordstore-back --api

Add gems

  1. Uncomment rack-cors and bcrypt .
  2. add redis and jwt_sessions
  3. bundle install

Here's the current state of my Gemfile

# Gemfile - Jan 2019
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.5.3'

# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 5.2.2'
# Use sqlite3 as the database for Active Record
gem 'sqlite3'
# Use Puma as the app server
gem 'puma', '~> 3.11'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
# gem 'jbuilder', '~> 2.5'
# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.7'

# Use ActiveStorage variant
# gem 'mini_magick', '~> 4.8'

# Use Capistrano for deployment
# gem 'capistrano-rails', group: :development

# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.1.0', require: false

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem 'rack-cors'
gem 'redis', '~> 4.1'
gem 'jwt_sessions', '~> 2.3'

group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end

group :development do
  gem 'listen', '>= 3.0.5', '< 3.2'
  # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

Create a User model

We won't be using Devise this time around! Rails has some handy built-ins to help users set up authentication. This route is certainly more involved but I recommend doing this to learn more about how popular gems like Devise work (and solve a lot of headaches).

To avoid too much complexity upfront our User model won't associate with the Record or Artist model just yet. Later we can add that so a User can add both an Artist and Record to the app with the front-end interface.

$ rails g model User email:string password_digest:string

The password_digest field will make use of the bcrypt gem we uncommented during initial setup. It creates a tokenized version of your password for better security.

We'll need to modify the migration file to include a default of null: false on the email andpassword_digest columns.

# db/migrate/20190105164640_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :password_digest, null: false

      t.timestamps
    end
  end
end

Let's migrate that in

$ rails db:migrate

Update the user model to use the method has_secure_password which is supplied by the bcrypt gem.

# app/models/user.rb

class User < ApplicationRecord
  has_secure_password
end

Create an Artist Model

The Artist model will be the parent relation in our app. A record (soon to come) will belong to an artist

$ rails g scaffold Artist name

Notice how no views are created when that resource gets scaffolded? That's again our API-mode at work. Our controllers also render JSON but default.

Create a Record Model

Our Record model will have a few more fields and belong to an artist.

$ rails g scaffold Record title year artist:references user:references

Migrate both models in

$ rails db:migrate

Namespacing our API

Having scaffolded the models and data structures we need let's talk routing. APIs often change. A common trend is to introduce versions which allow third-parties to opt into a new API version when they see fit. Doing this presents fewer errors for everyone but comes with a little more setup on the backend which mostly deals with routing and file location.

To namespace our app I want to do a v1 type of concept which ultimately looks like this:

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do      
     # routes go here
    end
  end
end

Namespacing allows us to extend things further at any point say if we roll out a new version or decide to build more with the backend. All of our data will live within the namespace but our user-related data won't. We probably won't change a lot with the userbase on the backend that would need to be in an API. Your results may vary as your app scales.

Update the routes

Next, we need to add our recently scaffolded resources to the mix

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :artists
      resources :records
    end
  end
end

Having updated our namespacing we need to move our controllers to accommodate. Move artists_controller.rb and records_controller.rb to app/controllers/api/v1/ . Be sure to modify both to include the new namespacing like so. By the way, If your server was running you should restart it.

Here is the artists controller:

# app/controllers/api/v1/artists_controller.rb
module Api
  module V1
    class ArtistsController < ApplicationController
      before_action :set_artist, only: [:show, :update, :destroy]

      def index
        @artists = Artist.all

        render json: @artists
      end

      def show
        render json: @artist
      end

      def create
        @artist = Artist.new(artist_params)

        if @artist.save
          render json: @artist, status: :created
        else
          render json: @artist.errors, status: :unprocessable_entity
        end
      end

      def update
        if @artist.update(artist_params)
          render json: @artist
        else
          render json: @artist.errors, status: :unprocessable_entity
        end
      end

      def destroy
        @artist.destroy
      end

      private
      def set_artist
          @artist = Artist.find(params[:id])
      end

      def artist_params
          params.require(:artist).permit(:name)
      end
    end
  end
end


And here's the records_controller.rb file

module Api
  module V1
    class RecordsController < ApplicationController
      before_action :set_record, only: [:show, :update, :destroy]

      def index
        @records = current_user.records.all

        render json: @records
      end

      def show
        render json: @record
      end

      def create
        @record = current_user.records.build(record_params)

        if @record.save
          render json: @record, status: :created
        else
          render json: @record.errors, status: :unprocessable_entity
        end
      end

      def update
        if @record.update(record_params)
          render json: @record
        else
          render json: @record.errors, status: :unprocessable_entity
        end
      end

      def destroy
        @record.destroy
      end

      private
      def set_record
        @record = current_user.records.find(params[:id])
      end

      def record_params
        params.require(:record).permit(:title, :year, :artist_id)
      end
    end
  end
end

Getting JWT_Sessions Setup

JSON Web Tokens are how we will handle authentication in this app. Rails apps that aren't API-based use session-based tokens to verify logins/sessions of a given User. We don't have the same session logic available to do such a thing with an API driven frontend app. We also want our API available to other applications or things we build like a mobile app, native app, and more (the possibilities are kinda endless). This concept is why API-based applications are all the craze.

Let's setup JWTSessions.

# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
   include JWTSessions::RailsAuthorization
end

Inside your application_controller.rb file add the following include. We get this from the gem we installed previously.

Note how your controller inherits from ActionController::API instead of the default ApplicationController. That's the API mode in full force!

We need some exception handling for unauthorized requests. Let's extend the file to the following:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include JWTSessions::RailsAuthorization
  rescue_from JWTSessions::Errors::Unauthorized, with: :not_authorized

  private

  def not_authorized
    render json: { error: 'Not Authorized' }, status: :unauthorized
  end
end

We'll also need an encryption key. The JWTSessions gem by default uses HS256 algorithm, and it needs an encryption key provided.

The gem uses Redis as a token store by default so that's why you saw it in our Gemfile. We need a working redis-server instance running. It is possible to use local memory for testing but we'll be using redis for this build as it's what would run in production anyway. Check out the readme for more information

Create a new initializer file called jwt_sessions.rb and add the following

# config/initializers/jwt_sessions.rb

JWTSessions.encryption_key = 'secret'

Definitely worth using something other than your secret key here if you prefer!

Signup Endpoint

Because we are going the token-based route we can choose to either store those on the client side cookies or localStorage. It boils down to preference where you land. Either choice has its pros and cons. Cookies being vulnerable to CSRF and localStorage being vulnerable to XSS attacks.

The JWT_Sessions gem provides the set of tokens - access, refresh, and CSRF for cases when cookies are chosen as the token store option.

We'll be making use of cookies with CSRF validations

The session within the gem comes as a pair of tokens called access and refresh. The access token has a shorter life span with a default of 1 hour. Refresh on the other hand has a longer life span of ~ 2 weeks. All of which is configurable.

We'll do quite a bit of logic in a signup_controller file of which we can generate.

$ rails g controller signup create

For now we can omit the route that gets generated in config/routes.rb

Rails.application.routes.draw do
    get 'signup/create' # remove this line
    ...
end

Let's add the logic for signup to the controller. We'll be harnessing the JWT_Sessions gem for this.

# app/controllers/signup_controller.rb

class SignupController < ApplicationController
  def create
    user = User.new(user_params)
    if user.save
      payload  = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login

      response.set_cookie(JWTSessions.access_cookie,
                          value: tokens[:access],
                          httponly: true,
                          secure: Rails.env.production?)
      render json: { csrf: tokens[:csrf] }
    else
      render json: { error: user.errors.full_messages.join(' ') }, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.permit(:email, :password, :password_confirmation)
  end
end

A lot is going on here but it's not too impossible to understand. We'll point the user to the endpoint signup/create method. In doing so we accomplish the following if all goes well.

  • Create a new user with permitted parameters (email, password, password_confirmation)
  • Assign the user_id as the payload
  • Create a new token-based session using the payload & JWTSessions.
  • Set a cookie with our JWTSession token [:access]
  • render final JSON & CSRF tokens to avoid cross-origin request vulnerabilities.
  • If none of that works we render the errors as JSON

Signin/Signout Endpoint

The Sign in Controller is quite similar to the signup minus the creation of a user and what happens if a user can't sign in successfully. There's the create method but also a destroy method for signing a user out.

# app/controllers/signin_controller.rb

aclass SigninController < ApplicationController
  before_action :authorize_access_request!, only: [:destroy]

  def create
    user = User.find_by!(email: params[:email])
    if user.authenticate(params[:password])
      payload = { user_id: user.id }
      session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
      tokens = session.login
      response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        secure: Rails.env.production?)
      render json: { csrf: tokens[:csrf] }
    else
      not_authorized
    end
  end

  def destroy
    session = JWTSessions::Session.new(payload: payload)
    session.flush_by_access_payload
    render json: :ok
  end

  private

  def not_found
    render json: { error: "Cannot find email/password combination" }, status: :not_found
  end
end

We render the not_authorized method which comes from our Application controller private methods if a sign in is unsuccessful.

The Refresh Endpoint

Sometimes it's not secure enough to store the refresh tokens in web / JS clients. We can operate with token-only with the help of the refresh_by_access_allowed method you've been seeing so far. This links the access token to the refresh token and refreshes it.

Create a refresh_controller.rb file and include the following:

# app/controllers/refresh_controller.rb
class RefreshController < ApplicationController
  before_action :authorize_refresh_by_access_request!

  def create
    session = JWTSessions::Session.new(payload: claimless_payload, refresh_by_access_allowed: true)
    tokens = session.refresh_by_access_payload do
      raise JWTSessions::Errors::Unauthorized, "Somethings not right here!"
    end
    response.set_cookie(JWTSessions.access_cookie,
                        value: tokens[:access],
                        httponly: true,
                        secure: Rails.env.production?)
    render json: { csrf: tokens[:csrf] }
  end
end

Here I'm expecting only expired access tokens to be used for a refresh so within the refresh_by_access_payload method we added an exception. We could do more here like send a notification, flush the session, or ignore it altogether.

The JWT library checks for expiration claims automatically. To avoid the except for an expired access token we can harness the claimless_payload method.

The before_action :authorized_refresh_by_access_request! is used as a protective layer to protect the endpoint.

Update controllers to add access request

Much like Devise's built-in authorize_user! method we can use one from JWT on our controllers.

# app/controllers/api/v1/artists_controller.rb

module Api
  module V1
    class ArtistsController < ApplicationController
        before_action :authorize_access_request!, except: [:show, :index]
      ...
      end
   end
  end
end

And our records controller:

# app/controllers/api/v1/records_controller.rb

module Api
  module V1
    class RecordsController < ApplicationController
        before_action :authorize_access_request!, except: [:show, :index]
      ...
      end
   end
  end
end

Creating current_user

Again much like Devise we want a helper for the given user who is logged in. We'll have to establish this ourselves inside the application controller.

# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include JWTSessions::RailsAuthorization
  rescue_from JWTSessions::Errors::Unauthorized, with: :not_authorized

  private

  def current_user
    @current_user ||= User.find(payload['user_id'])
  end

  def not_authorized
    render json: { error: 'Not authorized' }, status: :unauthorized
  end
end

Making sure we can authorize certain Cross-Origin requests

Rails comes with a cors.rb file within config/initializers/. There we can specify specific origins to allow to send/receive requests. Our front-end will run on a different local server so this is where we could pass that. When your app is live you'll probably point this to a living domain/subdomain.

If you haven't already, be sure to add/uncomment rack-cors in your Gemfile and run bundle install. Restart your server as well if it is running.

# config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:8081'

    resource '*',
      headers: :any,
      credentials: true,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

Your origin will be whatever your frontend port is running on. In my case, it's 8081. You can comma separate more origins to allow secure access.

Moar Routing!

With all of our endpoints defined we can add those to our routes outside of our API namespaces. My current routes file looks like the following:

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :artists do
        resources :records
      end
    end
  end

  post 'refresh', controller: :refresh, action: :create
  post 'signin', controller: :signin, action: :create
  post 'signup', controller: :signup, action: :create
  delete 'signin', controller: :signin, action: :destroy
end

We can define the request, controller, name of URL path, and action to fire all in one line of ruby. Love it!

Data

Create some test data in the rails console by running rails c in your terminal. I'll create a few artists at random just so we have some data to display when testing out our front-end app coming up.

Artist.create!(name: "AC/DC")
Artist.create!(name: "Jimi Hendrix")
Artist.create!(name: "Alice in Chains")
....
# repeat for however many artists you would like to add

The Frontend

Let's adopt Vue.js for the frontend and tackle that portion of the build. This app will live within the rails app but run separately altogether. Rather than keeping source code separate we can house it within a root folder in our app.

Our toolbox will consist of Node.js, VueJS CLI, Yarn and Axios.

If you're new to Vue this might be a little overwhelming to grasp at first but it's quite a convention driven like Rails. The fact that you can sprinkle it throughout any type of app sold me as opposed to frameworks like Angular or React.

At the time of this writing/recording I'm using the following version of node:

$ node -v
v11.4.0
$ yarn -v
1.12.3

Install Vue CLI

$ yarn global add @vue/cli

global means this installs at the system level rather than directly in your project node_modules though still depends on them.

We can check the version of vue to verify install

$ vue --version
2.9.6

Create a new project

cd into your rails app if you haven't already and run the following:

$ vue init webpack recordstore-front

This will ask a slew of questions. Here are my responses if you're wanting to follow along:

? Project name recordstore-front
? Project description A Vue.js front-end app for a Ruby on Rails backend app.
? Author Andy Leverenz <[email protected]>
? Vue build standalone
? Install vue-router? Yes
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Standard
? Set up unit tests Yes
? Pick a test runner karma
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been created? (recommended) yarn

Starting the app

$ cd recordstore-front
$ yarn dev

Webpack should do its magic here and you should be able to open your browser to see the new Vue app on localhost:8081

My working directory looks like this:

$ tree . -I "node_modules"
.
β”œβ”€β”€ README.md
β”œβ”€β”€ build
β”‚   β”œβ”€β”€ build.js
β”‚   β”œβ”€β”€ check-versions.js
β”‚   β”œβ”€β”€ logo.png
β”‚   β”œβ”€β”€ utils.js
β”‚   β”œβ”€β”€ vue-loader.conf.js
β”‚   β”œβ”€β”€ webpack.base.conf.js
β”‚   β”œβ”€β”€ webpack.dev.conf.js
β”‚   β”œβ”€β”€ webpack.prod.conf.js
β”‚   └── webpack.test.conf.js
β”œβ”€β”€ config
β”‚   β”œβ”€β”€ dev.env.js
β”‚   β”œβ”€β”€ index.js
β”‚   β”œβ”€β”€ prod.env.js
β”‚   └── test.env.js
β”œβ”€β”€ index.html
β”œβ”€β”€ package.json
β”œβ”€β”€ src
β”‚   β”œβ”€β”€ App.vue
β”‚   β”œβ”€β”€ assets
β”‚   β”‚   └── logo.png
β”‚   β”œβ”€β”€ components
β”‚   β”‚   └── HelloWorld.vue
β”‚   β”œβ”€β”€ main.js
β”‚   └── router
β”‚       └── index.js
β”œβ”€β”€ static
β”œβ”€β”€ test
β”‚   └── unit
β”‚       β”œβ”€β”€ index.js
β”‚       β”œβ”€β”€ karma.conf.js
β”‚       └── specs
β”‚           └── HelloWorld.spec.js
└── yarn.lock

10 directories, 25 files

Note: if you want tree to work on your system you'll need to install it. I used homebrew and ran the following:

$ brew install tree

Add Tailwind CSS

Installing Tailwind CSS

I've been loving Tailwind so I'm adding it to my project. You can use something more complete like Bootstrap and simply link it via CDN but like I said Tailwind is pretty sweet. I'll add it with Yarn

$ yarn add tailwindcss --dev

Per the tailwind docs we need to run and init command directly from the node_modules folder

$ ./node_modules/.bin/tailwind init
   tailwindcss 0.7.3
   βœ… Created Tailwind config file: tailwind.js

A tailwind.js file should appear in your project ready to configure.

Add a CSS file

Our CSS will compile down but we need it to have a place for it to do so. In our src directory add a main.css file.

src/
 assets/
 components/
 routes/
 App.vue
 main.js
 main.css

Insie main.css we need the following:

/* recordstore-frontend/src/main.css */

@tailwind preflight;

@tailwind components;

@tailwind utilities;

In main.js add the following

// recordstore-frontend/src/main.js
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import router from './router'
import './main.css'

Vue.config.productionTip = false

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>'
})


Almost done we just need to tell our app about tailwind.js

PostCSS config

We need to declare tailwind as a plugin in our .postcss.config.js file and configure purge css as well.

// recordstore-frontend/.postcss.config.js

module.exports = {
  "plugins": {
    "postcss-import": {},
    "tailwindcss": "./tailwind.js",
    "autoprefixer": {}
  }
}

Cleanup

I'll remove the default HelloWorld component from src/components and the line referencing it inside main.js

Install and Configure Axios

$ yarn add axios vue-axios

Having installed both of those packages I'll make a home for our axios internals

Create a new folder called backend within src Within that folder create a folder called axios and finally inside that create an index.js file. Here we'll give axios some global defaults and assign our API URL as a constant which gets used throughout each request.

// recordstore-frontend/src/backend/axios/index.js

import axios from 'axios'

const API_URL = 'http://localhost:3000'

const securedAxiosInstance = axios.create({
  baseURL: API_URL,
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json'
  }
})

const plainAxiosInstance = axios.create({
  baseURL: API_URL,
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json'
  }
})

securedAxiosInstance.interceptors.request.use(config => {
  const method = config.method.toUpperCase()
  if (method !== 'OPTIONS' && method !== 'GET') {
    config.headers = {
      ...config.headers,
      'X-CSRF-TOKEN': localStorage.csrf
    }
  }
  return config
})

securedAxiosInstance.interceptors.response.use(null, error => {
  if (error.response && error.response.config && error.response.status === 401) {
    // If 401 by expired access cookie, we do a refresh request
    return plainAxiosInstance.post('/refresh', {}, { headers: { 'X-CSRF-TOKEN': localStorage.csrf } })
      .then(response => {
        localStorage.csrf = response.data.csrf
        localStorage.signedIn = true
        // After another successfull refresh - repeat original request
        let retryConfig = error.response.config
        retryConfig.headers['X-CSRF-TOKEN'] = localStorage.csrf
        return plainAxiosInstance.request(retryConfig)
      }).catch(error => {
        delete localStorage.csrf
        delete localStorage.signedIn
        // redirect to signin if refresh fails
        location.replace('/')
        return Promise.reject(error)
      })
  } else {
    return Promise.reject(error)
  }
})

export { securedAxiosInstance, plainAxiosInstance }


The gist of what we just did is that axios doesn't have all the logic we were after. We built two wrappers around axios to get what we desire. We are passing through credentials that check against our CSRF tokens from Rails. In doing so we can establish some logic on if the right criteria are met to log the user in and out, send the right data, and more.

Main Vue configuration

The main.jsfile is our next stop. We'll import our dependencies and configure a bit more:

// recordstore-frontend/src/main.js

import Vue from 'vue'
import App from './App'
import router from './router'
import VueAxios from 'vue-axios'
import { securedAxiosInstance, plainAxiosInstance } from './backend/axios'
import './main.css' // tailwind

Vue.config.productionTip = false
Vue.use(VueAxios, {
  secured: securedAxiosInstance,
  plain: plainAxiosInstance
})

/* eslint-disable no-new */
new Vue({
  el: '#app',
  router,
  securedAxiosInstance,
  plainAxiosInstance,
  components: { App },
  template: '<App/>'
})

Notice how we make use of VueAxios, and our new secured and plain instances. Think of these as scoped logic which we will use during runtime on our Vue components. You'll see how this works coming up when we create each component.

Routing on the frontend

I'll start with the signin component we've been building but focus on the front-end routing using Vue router.

// recordstore-frontend/router/index.js

import Vue from 'vue'
import Router from 'vue-router'
import Signin from '@/components/Signin'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Signin',
      component: Signin
    }
  ]
})

Build the Signin Vue Component

<!-- recordstore-frontend/src/components/Signin.vue -->

<template>
  <div class="max-w-sm m-auto my-8">
    <div class="border p-10 border-grey-light shadow rounded">
      <h3 class="text-2xl mb-6 text-grey-darkest">Sign In</h3>
      <form @submit.prevent="signin">
        <div class="text-red" v-if="error">{{ error }}</div>

        <div class="mb-6">
          <label for="email" class="label">E-mail Address</label>
          <input type="email" v-model="email" class="input" id="email" placeholder="[email protected]">
        </div>
        <div class="mb-6">
          <label for="password" class="label">Password</label>
          <input type="password" v-model="password" class="input" id="password" placeholder="Password">
        </div>
        <button type="submit" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center">Sign In</button>

        <div class="my-4"><router-link to="/signup" class="link-grey">Sign up</router-link></div>
      </form>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Signin',
  data () {
    return {
      email: '',
      password: '',
      error: ''
    }
  },
  created () {
    this.checkSignedIn()
  },
  updated () {
    this.checkSignedIn()
  },
  methods: {
    signin () {
      this.$http.plain.post('/signin', { email: this.email, password: this.password })
        .then(response => this.signinSuccessful(response))
        .catch(error => this.signinFailed(error))
    },
    signinSuccessful (response) {
      if (!response.data.csrf) {
        this.signinFailed(response)
        return
      }
      localStorage.csrf = response.data.csrf
      localStorage.signedIn = true
      this.error = ''
      this.$router.replace('/records')
    },
    signinFailed (error) {
      this.error = (error.response && error.response.data && error.response.data.error) || ''
      delete localStorage.csrf
      delete localStorage.signedIn
    },
    checkSignedIn () {
      if (localStorage.signedIn) {
        this.$router.replace('/records')
      }
    }
  }
}
</script>

This component is a basic login form with a link to our sign up form if you don't already have an account. We leverage Tailwind for styles and Vue for functionality. In the script block I check if the user is already signed in upon component creation if so they will redirect to /records and if not they'll see this form. Our actual signin method performs a post request when the form submission is triggered.

Signup Component

<!-- recordstore-frontend/src/components/Signup.vue -->

<template>
  <div class="max-w-sm m-auto my-8">
    <div class="border p-10 border-grey-light shadow rounded">
      <h3 class="text-2xl mb-6 text-grey-darkest">Sign Up</h3>
      <form @submit.prevent="signup">
        <div class="text-red" v-if="error">{{ error }}</div>

        <div class="mb-6">
          <label for="email" class="label">E-mail Address</label>
          <input type="email" v-model="email" class="input" id="email" placeholder="[email protected]">
        </div>

        <div class="mb-6">
          <label for="password" class="label">Password</label>
          <input type="password" v-model="password" class="input" id="password" placeholder="Password">
        </div>

        <div class="mb-6">
          <label for="password_confirmation" class="label">Password Confirmation</label>
          <input type="password" v-model="password_confirmation" class="input" id="password_confirmation" placeholder="Password Confirmation">
        </div>
        <button type="submit" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center">Sign Up</button>

        <div class="my-4"><router-link to="/" class="link-grey">Sign In</router-link></div>
      </form>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Signup',
  data () {
    return {
      email: '',
      password: '',
      password_confirmation: '',
      error: ''
    }
  },
  created () {
    this.checkedSignedIn()
  },
  updated () {
    this.checkedSignedIn()
  },
  methods: {
    signup () {
      this.$http.plain.post('/signup', { email: this.email, password: this.password, password_confirmation: this.password_confirmation })
        .then(response => this.signupSuccessful(response))
        .catch(error => this.signupFailed(error))
    },
    signupSuccessful (response) {
      if (!response.data.csrf) {
        this.signupFailed(response)
        return
      }

      localStorage.csrf = response.data.csrf
      localStorage.signedIn = true
      this.error = ''
      this.$router.replace('/records')
    },
    signupFailed (error) {
      this.error = (error.response && error.response.data && error.response.data.error) || 'Something went wrong'
      delete localStorage.csrf
      delete localStorage.signedIn
    },
    checkedSignedIn () {
      if (localStorage.signedIn) {
        this.$router.replace('/records')
      }
    }
  }
}
</script>

Much of the logic is the same for the Signup.vue component. Here we introduce a new field and different POST route on the signup path. This points to /signup on our rails app as defined in config/routes.rb.

Header.vue component

I want to have a global header component above our router. In doing so we need to import that into our main App.vue file. In the end the Header.vue file looks like the following:

<!-- recordstore-frontend/src/components/Header.vue -->

<template>
  <header class="bg-grey-lighter py-4">
    <div class="container m-auto flex flex-wrap items-center justify-end">
      <div class="flex-1 flex items-center">
        <svg class="fill-current text-indigo" viewBox="0 0 24 24" width="24" height="24"><title>record vinyl</title><path d="M23.938 10.773a11.915 11.915 0 0 0-2.333-5.944 12.118 12.118 0 0 0-1.12-1.314A11.962 11.962 0 0 0 12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12c0-.414-.021-.823-.062-1.227zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"></path></svg>

        <a href="/" class="uppercase text-sm font-mono pl-4 font-semibold no-underline text-indigo-dark hover:text-indigo-darker">Record Store</a>
      </div>
      <div>
        <router-link to="/" class="link-grey px-2 no-underline" v-if="!signedIn()">Sign in</router-link>
        <router-link to="/signup" class="link-grey px-2 no-underline" v-if="!signedIn()">Sign Up</router-link>
        <router-link to="/records" class="link-grey px-2 no-underline" v-if="signedIn()">Records</router-link>
        <router-link to="/artists" class="link-grey px-2 no-underline" v-if="signedIn()">Artists</router-link>
        <a href="#" @click.prevent="signOut" class="link-grey px-2 no-underline" v-if="signedIn()">Sign out</a>
      </div>
    </div>
  </header>
</template>

<script>
export default {
  name: 'Header',
  created () {
    this.signedIn()
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    signedIn () {
      return localStorage.signedIn
    },
    signOut () {
      this.$http.secured.delete('/signin')
        .then(response => {
          delete localStorage.csrf
          delete localStorage.signedIn
          this.$router.replace('/')
        })
        .catch(error => this.setError(error, 'Cannot sign out'))
    }
  }
}
</script>

This file get's imported here:

<!-- src/components/App.vue-->
<template>
  <div id="app">
    <Header/>
    <router-view></router-view>
  </div>
</template>

<script>
import Header from './components/Header.vue'

export default {
  name: 'App',
  components: {
    Header
  }
}
</script>

Artists

We have data already in the database so let's start with our Artists.vue component

<!-- recordstore-frontend/src/components/artists/Artists.vue -->

<template>
  <div class="max-w-md m-auto py-10">
    <div class="text-red" v-if="error">{{ error }}</div>
    <h3 class="font-mono font-regular text-3xl mb-4">Add a new artist</h3>
    <form action="" @submit.prevent="addArtist">
      <div class="mb-6">
        <input class="input"
          autofocus autocomplete="off"
          placeholder="Type an arist name"
          v-model="newArtist.name" />
      </div>
      <input type="submit" value="Add Artist" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center" />
    </form>

    <hr class="border border-grey-light my-6" />

    <ul class="list-reset mt-4">
      <li class="py-4" v-for="artist in artists" :key="artist.id" :artist="artist">

        <div class="flex items-center justify-between flex-wrap">
          <p class="block flex-1 font-mono font-semibold flex items-center ">
            <svg class="fill-current text-indigo w-6 h-6 mr-2" viewBox="0 0 20 20" width="20" height="20"><title>music artist</title><path d="M15.75 8l-3.74-3.75a3.99 3.99 0 0 1 6.82-3.08A4 4 0 0 1 15.75 8zm-13.9 7.3l9.2-9.19 2.83 2.83-9.2 9.2-2.82-2.84zm-1.4 2.83l2.11-2.12 1.42 1.42-2.12 2.12-1.42-1.42zM10 15l2-2v7h-2v-5z"></path></svg>
            {{ artist.name }}
          </p>

          <button class="bg-tranparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 mr-2 rounded"
          @click.prevent="editArtist(artist)">Edit</button>

          <button class="bg-transprent text-sm hover:bg-red text-red hover:text-white no-underline font-bold py-2 px-4 rounded border border-red"
         @click.prevent="removeArtist(artist)">Delete</button>
        </div>

        <div v-if="artist == editedArtist">
          <form action="" @submit.prevent="updateArtist(artist)">
            <div class="mb-6 p-4 bg-white rounded border border-grey-light mt-4">
              <input class="input" v-model="artist.name" />
              <input type="submit" value="Update" class=" my-2 bg-transparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 rounded cursor-pointer">
            </div>
          </form>
        </div>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'Artists',
  data () {
    return {
      artists: [],
      newArtist: [],
      error: '',
      editedArtist: ''
    }
  },
  created () {
    if (!localStorage.signedIn) {
      this.$router.replace('/')
    } else {
      this.$http.secured.get('/api/v1/artists')
        .then(response => { this.artists = response.data })
        .catch(error => this.setError(error, 'Something went wrong'))
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    addArtist () {
      const value = this.newArtist
      if (!value) {
        return
      }
      this.$http.secured.post('/api/v1/artists/', { artist: { name: this.newArtist.name } })

        .then(response => {
          this.artists.push(response.data)
          this.newArtist = ''
        })
        .catch(error => this.setError(error, 'Cannot create artist'))
    },
    removeArtist (artist) {
      this.$http.secured.delete(`/api/v1/artists/${artist.id}`)
        .then(response => {
          this.artists.splice(this.artists.indexOf(artist), 1)
        })
        .catch(error => this.setError(error, 'Cannot delete artist'))
    },
    editArtist (artist) {
      this.editedArtist = artist
    },
    updateArtist (artist) {
      this.editedArtist = ''
      this.$http.secured.patch(`/api/v1/artists/${artist.id}`, { artist: { title: artist.name } })
        .catch(error => this.setError(error, 'Cannot update artist'))
    }
  }
}
</script>

This component is responsible for a few things. I realize this could be condensed down further to multiple components but for the sake of time, I contained everything. In this file, we have a form, a listing of artists, and an update form when editing an artist. We'll loop through the data from our Rails app to display data in the database and use Vue to perform basic CRUD operations with JavaScript and Axios.

Note how I point to api/v1/artists in a lot of axios requests. This is the namespace in full effect we created prior on the rails application. Cool stuff!

The Records.vue component

<!-- recordstore-frontend/src/components/artists/Records.vue -->

<template>
  <div class="max-w-md m-auto py-10">
    <div class="text-red" v-if="error">{{ error }}</div>
    <h3 class="font-mono font-regular text-3xl mb-4">Add a new record</h3>
    <form action="" @submit.prevent="addRecord">
      <div class="mb-6">
        <label for="record_title" class="label">Title</label>
        <input
          id="record_title"
          class="input"
          autofocus autocomplete="off"
          placeholder="Type a record name"
          v-model="newRecord.title" />
      </div>

      <div class="mb-6">
        <label for="record_year" class="label">Year</label>
        <input
          id="record_year"
          class="input"
          autofocus autocomplete="off"
          placeholder="Year"
          v-model="newRecord.year"
        />
       </div>

      <div class="mb-6">
        <label for="artist" class="label">Artist</label>
        <select id="artist" class="select" v-model="newRecord.artist">
          <option disabled value="">Select an artist</option>
          <option :value="artist.id" v-for="artist in artists" :key="artist.id">{{ artist.name }}</option>
        </select>
        <p class="pt-4">Don't see an artist? <router-link class="text-grey-darker underline" to="/artists">Create one</router-link></p>
       </div>

      <input type="submit" value="Add Record" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center" />
    </form>

    <hr class="border border-grey-light my-6" />

    <ul class="list-reset mt-4">
      <li class="py-4" v-for="record in records" :key="record.id" :record="record">

        <div class="flex items-center justify-between flex-wrap">
          <div class="flex-1 flex justify-between flex-wrap pr-4">
            <p class="block font-mono font-semibold flex items-center">
              <svg class="fill-current text-indigo w-6 h-6 mr-2" viewBox="0 0 24 24" width="24" height="24"><title>record vinyl</title><path d="M23.938 10.773a11.915 11.915 0 0 0-2.333-5.944 12.118 12.118 0 0 0-1.12-1.314A11.962 11.962 0 0 0 12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12c0-.414-.021-.823-.062-1.227zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" ></path></svg>
              {{ record.title }} &mdash; {{ record.year }}
            </p>
            <p class="block font-mono font-semibold">{{ getArtist(record) }}</p>
          </div>
          <button class="bg-transparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 mr-2 rounded"
          @click.prevent="editRecord(record)">Edit</button>

          <button class="bg-transparent text-sm hover:bg-red text-red hover:text-white no-underline font-bold py-2 px-4 rounded border border-red"
         @click.prevent="removeRecord(record)">Delete</button>
        </div>

        <div v-if="record == editedRecord">
          <form action="" @submit.prevent="updateRecord(record)">
            <div class="mb-6 p-4 bg-white rounded border border-grey-light mt-4">

              <div class="mb-6">
                <label class="label">Title</label>
                <input class="input" v-model="record.title" />
              </div>

              <div class="mb-6">
                <label class="label">Year</label>
                <input class="input" v-model="record.year" />
              </div>

              <div class="mb-6">
                <label class="label">Artist</label>
                <select id="artist" class="select" v-model="record.artist">
                  <option :value="artist.id" v-for="artist in artists" :key="artist.id">{{ artist.name }}</option>
                </select>
              </div>

              <input type="submit" value="Update" class="bg-transparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 mr-2 rounded">
            </div>
          </form>
        </div>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'Records',
  data () {
    return {
      artists: [],
      records: [],
      newRecord: [],
      error: '',
      editedRecord: ''
    }
  },
  created () {
    if (!localStorage.signedIn) {
      this.$router.replace('/')
    } else {
      this.$http.secured.get('/api/v1/records')
        .then(response => { this.records = response.data })
        .catch(error => this.setError(error, 'Something went wrong'))

      this.$http.secured.get('/api/v1/artists')
        .then(response => { this.artists = response.data })
        .catch(error => this.setError(error, 'Something went wrong'))
    }
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },
    getArtist (record) {
      const recordArtistValues = this.artists.filter(artist => artist.id === record.artist_id)
      let artist

      recordArtistValues.forEach(function (element) {
        artist = element.name
      })

      return artist
    },
    addRecord () {
      const value = this.newRecord
      if (!value) {
        return
      }
      this.$http.secured.post('/api/v1/records/', { record: { title: this.newRecord.title, year: this.newRecord.year, artist_id: this.newRecord.artist } })

        .then(response => {
          this.records.push(response.data)
          this.newRecord = ''
        })
        .catch(error => this.setError(error, 'Cannot create record'))
    },
    removeRecord (record) {
      this.$http.secured.delete(`/api/v1/records/${record.id}`)
        .then(response => {
          this.records.splice(this.records.indexOf(record), 1)
        })
        .catch(error => this.setError(error, 'Cannot delete record'))
    },
    editRecord (record) {
      this.editedRecord = record
    },
    updateRecord (record) {
      this.editedRecord = ''
      this.$http.secured.patch(`/api/v1/records/${record.id}`, { record: { title: record.title, year: record.year, artist_id: record.artist } })
        .catch(error => this.setError(error, 'Cannot update record'))
    }
  }
}
</script>

The Records.vue component is quite similar to the Artists.vue component in that the same basic CRUD operations are in full effect. I introduce the artist to record relation with a new select field which grabs data from our backend and saves it once a new record is saved. We loop through both Record and Artist data to get the necessary ids and fields back to save, edit, update and delete the fields correctly.

Where to go next?

Our app is far from complete but it is functioning nicely. We have JWT-based authentication and a full CRUD based Vue app working on the frontend. Our backend is talking to the frontend the way we intend. I found one final bug in my Rails artists_controller.rb and records_controller.rb files that dealt with the location:property. Normally those would exist but I have removed them due to an odd namespacing issue I can't quite figure out. Maybe you know the solution?

From here I invite you to extend the app and/or use it as a guide in your own projects. I learned a lot from this build. I have to admit, this was the hardest one I've taken on thus far. Hopefully, it's enough to show you a new way to use Ruby on Rails with modern frontend frameworks and more.

The Series so far

Shameless plug time

Hello Rails Course

I have a new course called Hello Rails. Hello Rails is modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. View the course!

Follow @hello_rails and myself @justalever on Twitter.

Link this article
Est. reading time: 35 minutes
Stats: 26,027 views

Categories

Collection

Part of the Let's Build: With Ruby on Rails collection

Products and courses