I’ve been a huge fan of Ruby on Rails since the early days, and although the web world has moved on a lot since then, Rails still has a place in this landscape, for rapid app development with clean, maintainable code.
But the web world has changed. The rise of Progressive Web Apps (and mobile apps before them) has led to a world where users want a strong interactive interface from their web apps: clicking on links and downloading the next page of UI from the server feels like something people did in the 2000s.
I’ve recently become a big fan of Nuxt.js, which I have started to think of as “the Rails of frontend”. Nuxt is an app development framework based around Webpack and Vue.js. It does have a backend but, in my experience, it really shines when it is used in generate mode to build serverless applications that can be deployed on services that normally do static content, like GitHub Pages or S3. Examples of serverless applications I’ve built using Nuxt include Maple and my business website, both running on GitHub Pages.
A lot of buzz in the Rails world right now is around Rails API: a stripped down version of Rails that can be used to build API-only server apps that then hook up to whatever frontends you like: mobile apps or browser-based apps such as those built with Nuxt.
But there seems to be a dearth of guides about how you would actually do this, especially when considering authentication. How can you provide a login interface in the frontend that logs your users into the backend?
So here goes. A somewhat lengthy blog post that goes through, step-by-step, the process of building a simple decoupled web application in Rails API and Nuxt, and building an incredibly basic authentication system using Devise-JWT.
Note: This guide assumes a reasonably good knowledge of Rails, a passing knowledge of Vue.js and at least a basic grasp of Docker.
If you want to see the finished application, or you get stuck at any point, you can refer to the source code on GitHub.
Part 1: Creating a development environment
Before we start coding, we’ll need a development environment. I like to use Docker for this, because it keeps everything separated. Chances are in production your database, backend and frontend will all be running in different places so let’s start as we mean to go on in order to avoid accidental coupling.
First, let’s create blank Rails API and Nuxt applications:
This assumes you have the ‘rails’ and ‘vue’ CLIs installed on your host machine. You can of course use the Docker images for these CLIs if you don’t.
The various options to rails new cause it to skip adding tests, Action Cable and Spring, and also to stop it from running bundle on your host machine (we’ll want to run this inside Docker). Similarly, we use yarn generate-lock-entry to generate the yarn.lock file without having to install the packages.
Next, create small Dockerfiles to build the frontend and backend environments, and a docker-compose.yml to describe how they and the database fit together:
Some things to note here:
- Gems and yarn packages are installed into the mounted volumes. This will stop you from needing to rebuild the whole Docker image every time you change the Gemfile or package.json.
- This assumes your host user ID is 1001 and creates a user inside each container with the same user ID. If your user ID is different, you can set the UID environment variable before building and it will pass that as an arg to the build.
- You’ll probably want to add node_modules to the frontend .dockerignore & .gitignore and vendor/bundle, log, tmp, to the backend ones. That way they won’t be copied during build time.
Now it’s time to build everything:
The reason you need to run bundle and yarn after building is because your docker-compose file mounts your host volumes into the containers so you need to install the packages into the host volumes as well as the images that are used to create the containers.
A couple of last things before you can bring this environment up:
- Edit database.yml to set the hostname to ‘db’ and the user to ‘postgres’.
- Edit package.json to change the ‘dev’ script to ‘HOST=0.0.0.0 nuxt’ (so it’s visible on your host machine).
- Run ‘docker-compose run backend rails db:create’ to create the development database.
All being well, you should now be able to type:
And your environment (database, backend and frontend) will spin up. You can check the web interfaces on :8080 and :3000 and you should see the default pages for each.
The frontend one is actually a webpack-dev-server, which you might not be used to if you’re an old-school Rails coder. Changes you make to the UI will be reflected immediately in your browser, without needing to reload. This is an exciting change!
Right! Get your project committed to version control and then let’s start building some stuff!
Part 2: Getting them talking to each other
Let’s build our first API method. We’re just going to create a resource called “example” that has a name and a colour, and create 3 of these for testing.
Edit routes.rb to move the new route into an api scope, and add a simple index method to the ExamplesController:
Make sure your ApplicationController tells all its children they can respond to requests for JSON (this is necessary for some gems, like Devise):
[Edit: You might actually need to wait until Devise is installed to do this, because it looks like there’s a dependency issue if you do it this early. Let me know if you have any ideas about how to introduce these this early.]
Now immediately you should be able to visit http://localhost:8080/api/examples to see your API in action:
Now for the fun part: we’ll hook the frontend up to the backend. But first, don’t forget to commit to version control!
We’re going to use axios to talk to the API, and vuetify as a UI kit. (You can use HTML and CSS in Vue, but there are also deeper UI integrations: vuetify is a UI kit based on Material Design.) Both of these have integrations with Nuxt available in the community, so let’s install those:
and add to the Nuxt config file:
(Obviously in production you’ll want to move the axios config to the environment, but for now it’s OK to hardcode it.)
Now let’s replace the layouts/default.vue and pages/index.vue that Nuxt has created for us:
The default.vue is a standard vuetify layout, with a toolbar containing a link to the homepage. The nuxt option to the link tells it to use Nuxt’s router to handle the link, rather than doing it in the browser.
index.vue is a little more complex: the mounted() method is called when the template is initialized, and that in turn calls the updateExamples(), which uses the axios integration to set the examples variable to the results of the API method. The <v-list> contains a reactive set of tiles that is automatically populated based on whatever is in examples (so it will be empty at first and then fill out when the API method completes).
But if you try to visit this now you get an unfortunate error:
Uncomment the ‘rack-cors’ line in the Gemfile and uncomment the code in cors.rb, changing example.com to localhost:3000 (or *). Install the gem:
docker-compose run -u root backend bundle
and restart your docker containers by hitting Ctrl-C on the running ones and doing docker-compose up again. Fingers crossed now you’ll see something like this:
Woohoo! Your frontend app is displaying data directly from the backend! Try adding a new example to the database (e.g. ‘qux/cyan’) to confirm your frontend is really retrieving data from your backend.
Commit to version control and get yourself a coffee. Next up is the authentication and it’s a little trickier!
Part 3: Authentication with Devise-JWT
Devise-JWT is an extension to the popular Rails authentication library Devise to add support for JSON Web Token, a popular implementation of single sign-on. If you Google around, you’ll find lots of people saying it’s easy to add JWT to Devise yourself, but not if you want to properly take advantage of all the features of Devise, and not if you want to log out (i.e. make a token invalid).
First, add ‘devise’ and ‘devise-jwt’ to your Gemfile and run:
docker-compose run -u root backend bundle
Then you’re going to want to follow the instructions to install Devise. Since we’re using an API you don’t have to worry too much about the view layer stuff. I just did:
> rails g devise:install
> rails g devise user
> rails db:migrate
Devise-JWT doesn’t have an installer, but it has good installation instructions. You need to decide how you want to invalidate tokens. I went for the blacklist option. For example:
> rails g model jwt_blacklist jti:string:index exp:datetime
you need to update the devise line in your user model to add:
:jwt_authenticatable, jwt_revocation_strategy: JwtBlacklist
Run “rails db:migrate” again to create your blacklist.
Move the devise_for route in routes.rb into your :api scope and restart your containers.
You should now be able to test your API using something like YARC. First, create yourself a user in the Rails console:
> rails c
>> User.create!(email: ‘email@example.com’, password: ‘password’)
Then try creating a POST message to your /api/users/sign_in endpoint that looks like this:
And you should see an Authorization header come back with the successful sign-in:
Awesome. We’re getting there, I promise!
We want to use Nuxt’s own auth library, which expects the JWT to be returned in the body of the sign in request, and also wants to make a separate API request to find out the user’s details such as their profile information. So let’s override Devise’s session controller.
Change the devise config in routes.rb to say you’ve overridden the controller, and add an additional ‘current’ endpoint:
Then let’s add that controller file and two views. If you use jbuilder here you’ll need to uncomment jbuilder in the Gemfile, run “docker-compose run -u root backend bundle” and restart your containers again.
Now try these methods out. Do the earlier POST and this time the token should come back in the response body as well as the headers.
Try doing a GET to the /api/users/current endpoint with that “Authorization: Bearer $token” header and see if you can get back the user’s ID and email.
Woop! One last thing: let’s make the ExamplesController require authentication. Add to that controller:
If you go back to your frontend app, you should now see it’s failing because of a 401 Unauthorized error. Time to add authentication to the frontend!
Install the Nuxt auth library:
Restart your containers before continuing to enable this new config.
Let’s create a user page login.vue with login/logout controls based on the user’s current status:
There’s quite a lot going on here but it should be reasonably easy to work out. The login() method calls the equivalent method in the auth library, passing the appropriate JSON object that Devise is expecting. If there’s an error, it sets error, which is being watched for by the <v-alert>.
And finally, you’ll want users to be redirected if they’re not signed in. By default, the auth middleware redirects users to login.vue if they’re not signed in, so we just have to enable the auth middleware on index.vue:
You might also want to add a link to /login to the toolbar in default.vue. It’s up to you.
Now try out your frontend app again!
This is where I leave you! It really is just the very beginning of the auth integration, but I hope you can see it’s not a huge amount of work to get going. You’ll want to add registration, and do things like make sure expired tokens don’t take precedence over credentials when signing in. [Edit: The maintainers of the auth module have agreed that expired tokens shouldn’t be sent with login request, and @nuxtjs/auth 4.2.0 fixes the issue.] The auth documentation and demo apps are a good place to start.
If this article’s been helpful, please share it around, throw me a few claps, or let me know how you’re getting on in the comments.