|||

Video Transcript

X

A Rock Solid, Modern Web Stack—Rails 5 API + ActiveAdmin + Create React App on Heroku

How to blend a rock-solid CMS and API with the absolute best in front-end tooling, built as a single project and hosted seamlessly on Heroku.

Rails is an incredible framework, but modern web development has moved to the front-end, meaning sometimes you don’t need all the bulk of the asset pipeline and the templating system. In Rails 5 you can now create an API-only Rails app, meaning you can build your front-end however you like—using Create React App, for example. It’s no longer 100% omakase.

An image of four logos, React, Rails, Activeadmin, and Heroku

And for projects that don’t need CMS-like capabilities, Rails and that works pretty great straight away. Create React App even supports proxying API requests in development, so you can be running two servers locally without having to litter your app with if NODE_ENV === ‘development’.

Still, I’ve worked with ActiveAdmin on a few projects, and as an interface between you and the database, it’s pretty unmatched for ease of use. There are a host of customisation options, and it’s pretty easy for clients to use if you need a CMS. The issue is that removing the non-API bits of Rails breaks it. Not ideal. But all is not lost—with a couple of steps you can be running a Rails 5 app, API-only, serving your Create React App client on the front end, with full access to ActiveAdmin.

We’re going to build it, then we’re going to deploy it to Heroku, and then we’re going to celebrate with a delicious, healthy beverage of your choosing. Because we will have earned it. And given that theme, we’re going to build an app that shows us recipes for smoothies. It’s thematically appropriate!

So, what are we going to use?

  • Create React App
    All the power of a highly-tuned Webpack config without the hassle.

  • Rails in API-only mode
    Just the best bits, leaving React to handle the UI.

  • ActiveAdmin
    An instant CMS backend.

  • Seamless deployment on Heroku
    Same-origin (so no CORS complications) with build steps to manage both Node and Ruby.

  • Single page app support with React Router
    So you can have lightning fast rendering on the front end.

And it’ll look something like this:

An image of the app we'll be building, showing recipes for two drinks
Our app, List of Ingredients, which really does what it says on the tin.

If you want to skip ahead to the finished repo, you can do so here, and if you want to see it in action, you do that here.

Let’s get started, shall we?


Step 1: Getting Rails 5 set up

With that delicious low-carb API-only mode


There are a ton of great tutorials on getting Ruby and Rails set up in your local development environment. https://gorails.com/setup/ will work out your operating system, and will walk you through getting Rails 5.2.0 installed.

If you’ve already got Rails 5, awesome. The best way to check that is to run rails -v in your terminal. If you see Rails 5.2.0, we’re ready to roll.

So, first up, start a new Rails app with the --api flag:

mkdir list-of-ingredients
cd list-of-ingredients
rails new . --api

Before you commit, add /public to .gitignore, as this will be populated at build by our front end. Your .gitignore file should look something like this:

# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# If you find yourself ignoring temporary files generated by your text editor
# or operating system, you probably want to add a global ignore instead:
#   git config --global core.excludesfile '~/.gitignore_global'

# Ignore bundler config.
/.bundle

# Ignore the default SQLite database.
/db/*.sqlite3
/db/*.sqlite3-journal

# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep

# Ignore uploaded files in development
/storage/*

.byebug_history

# Ignore master key for decrypting credentials and more.
/config/master.key

# Ignore public, as it is built on deploy
# Place files for /public in /client/public
/public

Right. We are already part of the way to making a delicious smoothie. Maybe use this time to congratulate yourself, because you’re doing great.

Once the install process has finished, you can fire up Rails:

bin/rails s -p 3001

It’ll do some stuff, eventually telling you that it’s listening on http://localhost:3001. If you visit it, you should see something like this:

The initial Rails page, showing version 5.2.0
Yay Rails!

Look—there’s even a kitten in that illustration! So great. Let's quit Rails and get ready for step 2.


Step 2: Getting ActiveAdmin working

With a couple of small tweaks to Rails


(Thanks to Roman Rott for inspiring this bit.)

So, why do we need to make any changes at all to get Rails up and running? It's because when we make a Rails API app, Rails isn't expecting to serve HTML pages, and because we're adding ActiveAdmin, we actually need it to.

Before you install ActiveAdmin, you'll need to switch a couple of Rails classes and add some middleware that it relies on.

First, you’ll need to swap your app/controllers/application_controller.rb from using the API to using Base, being sure to add in protect_from_forgery with: :exception.

So your application_controller.rb should go from looking like this:

class ApplicationController < ActionController::API
end

To something more like this:

class ApplicationController < ActionController::Base
    protect_from_forgery with: :exception
end

As Carlos Ramirez mentions, this requirement is due to a design decision from ActiveAdmin, meaning any controllers we make that inherit from ApplicationController won’t take advantage of the slimmed down API version.

There is a work around, though. Add a new api_controller.rb file to your app/controllers:

class ApiController < ActionController::API
end

Now you can get any new controllers you make to inherit from ApiController, not ApplicationController. For example, if you were making an ExampleController, it might look like this:

class ExampleController < ApiController
end

From there we’ll need to ensure that the middleware has the stuff it needs for ActiveAdmin to function correctly. API mode strips out cookies and the flash, but we can 100% put them back. In your config/application.rb add these to the Application class:

# Middleware for ActiveAdmin
config.middleware.use Rack::MethodOverride
config.middleware.use ActionDispatch::Flash
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore

You’ll also need to add sprockets/railtie back in by uncommenting it:

require "sprockets/railtie"

Your config/application.rb should look something like this:

require_relative 'boot'

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "action_cable/engine"
require "sprockets/railtie"
require "rails/test_unit/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module ListOfIngredients
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 5.2

    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration can go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded after loading
    # the framework and any gems in your application.

    # Only loads a smaller set of middleware suitable for API only apps.
    # Middleware like session, flash, cookies can be added back manually.
    # Skip views, helpers and assets when generating a new resource.
    config.api_only = true

    # Middleware for ActiveAdmin
    config.middleware.use Rack::MethodOverride
    config.middleware.use ActionDispatch::Flash
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use ActionDispatch::Session::CookieStore
  end
end

Next up, your Gemfile. You’ll need to add the ActiveAdmin gems in:

# ActiveAdmin
gem 'devise'
gem 'activeadmin'

You should also move gem 'sqlite3' into the :development, :test group and add gem 'pg' into a new :production group. This is because Heroku doesn’t support sqlite's local disk storage (see factor six in The Twelve-Factor App), so you’ll need to ensure you're using Postgres for production.

group :development, :test do
  # Use sqlite3 as the database for Active Record
  gem 'sqlite3'
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end
group :production do
  # Use postgres as the database for production
  gem 'pg'
end

Your Gemfile should now look something like this:

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

ruby '2.5.1'

# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
gem 'rails', '~> 5.2.0'
# 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 Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# 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'

group :development, :test do
  # Use sqlite3 as the database for Active Record
  gem 'sqlite3'
  # 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

group :production do
  # Use postgres as the database for production
  gem 'pg'
end

# ActiveAdmin
gem 'devise'
gem 'activeadmin'

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

Okay, okay. Someone out there will probably be sharpening their pitchfork right now because you should 100% run Postgres locally if you’re developing a Real Application to ensure your local environment matches your production one. But to make this tutorial a little less verbose, we’re going to bend the rules, together.

Bundle install everything, and then install ActiveAdmin into your Rails app:

bundle
bin/rails g active_admin:install

You should see something like the following:

Running via Spring preloader in process 57692
      invoke  devise
    generate    devise:install
      create    config/initializers/devise.rb
      create    config/locales/devise.en.yml
  ===============================================================================

Some setup you must do manually if you haven't yet:

  1. Ensure you have defined default url options in your environments files. Here
     is an example of default_url_options appropriate for a development environment
     in config/environments/development.rb:

       config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

     In production, :host should be set to the actual host of your application.

  2. Ensure you have defined root_url to *something* in your config/routes.rb.
     For example:

       root to: "home#index"

  3. Ensure you have flash messages in app/views/layouts/application.html.erb.
     For example:

       <p class="notice"><%= notice %></p>
       <p class="alert"><%= alert %></p>

  4. You can copy Devise views (for customization) to your app by running:

       rails g devise:views

===============================================================================
      invoke    active_record
      create      db/migrate/20180501170855_devise_create_admin_users.rb
      create      app/models/admin_user.rb
      invoke      test_unit
      create        test/models/admin_user_test.rb
      create        test/fixtures/admin_users.yml
      insert      app/models/admin_user.rb
       route    devise_for :admin_users
        gsub    app/models/admin_user.rb
        gsub    config/routes.rb
      append    db/seeds.rb
      create  config/initializers/active_admin.rb
      create  app/admin
      create  app/admin/dashboard.rb
      create  app/admin/admin_users.rb
      insert  config/routes.rb
    generate  active_admin:assets
Running via Spring preloader in process 57711
      create  app/assets/javascripts/active_admin.js
      create  app/assets/stylesheets/active_admin.scss
      create  db/migrate/20180501170858_create_active_admin_comments.rb

If you're using a newer version of Rails and get an error, like the one below, you may be missing a manifest file:

railtie.rb:105:in `block in <class:Railtie>': Expected to find a manifest file in `app/assets/config/manifest.js`
But did not, please create this file and use it to link any assets that need to be rendered by your app

You'll need to run the following to create it:

mkdir -p app/assets/config && echo '{}' > app/assets/config/manifest.js

Once ActiveAdmin is installed, migrate and seed the database:

bin/rake db:migrate db:seed

Once again you can fire up Rails:

bin/rails s -p 3001

This time hit http://localhost:3001/admin. You should see something like this:

The ActiveAdmin login page

And you should take a moment to feel pretty great, because that was a lot.

You can log into ActiveAdmin with the username admin@example.com and the password password. Security! You can change it really easily in the rad ActiveAdmin environment, though, so fear not.


Step 3: Adding Create React App as the client

Yay! Super-speedy Webpack asset handling!


(Shout out to Full Stack React for inspiring this bit.)

So. We need a front end. If you don’t have Create React App yet, install it globally with:

npx create-react-app client

npx comes with npm 5.2+ and higher. If you’re using an older version, you can run:

npm install -g create-react-app
create-react-app client

It’ll take a bit. You probably have time for a cup of tea, if you’re feeling thirsty.

Once it’s done, jump into client/src/index.js and remove these two lines:

import registerServiceWorker from './registerServiceWorker';
registerServiceWorker();

This is because, in some cases, Create React App’s use of service workers clashes with Rails’ routing, and can leave you unable to access ActiveAdmin.

Once you’re done, your client/src/index.js should look something like this:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));

You can now fire it up:

yarn --cwd client start

It’ll automatically visit http://localhost:3000/, and you’ll have a simple Create React App running. That is good. Also, if you haven't seen yarn --cwd client before, that tells yarn to run the command in the client directory. It also saves us cd-ing into and out of directories. Winning!

The initial view when you generate a new Create React App

As I mentioned earlier, one of the best bits about working with Create React App and an API is that you can automatically proxy the API calls via the right port, without needing to swap anything between development and production. To do this, jump into your client/package.json and add a proxy property, like so:

"proxy": "http://localhost:3001"

Your client/package.json file will look like this:

{
  "name": "client",
  "version": "0.1.0",
  "private": true,
  "proxy": "http://localhost:3001",
  "dependencies": {
    "react": "^16.3.2",
    "react-dom": "^16.3.2",
    "react-scripts": "1.1.4"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}

(You might wonder why we’re proxying port 3001. Once we hook everything up our scripts will be running the API on port 3001, which is why we’ve been running Rails that way. Nice one picking up on that, though, eagle-eyes. Asking the right questions!)

fetch (along with a bunch of fancy new language features and polyfills you should 100% check out) is included with Create React App, so our front end is ready to make calls to the API. But right now that would be pretty pointless—we’ll need some data to actually fetch. So let’s get this smoothie party started.

We’ll need two relations, the Drinks, and the Ingredients that those drinks are made with. You’ll also need a blender, but honestly, if you don’t have one handy an apple juice with a couple of ice cubes is still so delicious. Promise.

Now normally I’d say avoid scaffolding in Rails, because you end up with a ton of boilerplate code that you have to delete. For the purposes of the exercise, we’re going to use it, and then end up with a ton of boilerplate code that we have to delete. Do what I say, not what I do.

Before that though, I should mention something. One downside to ActiveAdmin using inherited_resources, which reduces the boilerplate for Rails controllers, is that Rails then uses it when you scaffold anything in your app. That breaks stuff:

$ bin/rails g scaffold Drink title:string description:string steps:string source:string
Running via Spring preloader in process 38277
Expected string default value for '--serializer'; got true (boolean)
      invoke  active_record
      create    db/migrate/20170302183027_create_drinks.rb
      create    app/models/drink.rb
      invoke    test_unit
      create      test/models/drink_test.rb
      create      test/fixtures/drinks.yml
      invoke  resource_route
       route    resources :drinks
      invoke  serializer
      create    app/serializers/drink_serializer.rb
      invoke  inherited_resources_controller
Could not find "api_controller.rb" in any of your source paths. Your current source paths are:
/usr/local/var/rbenv/versions/2.4.0/lib/ruby/gems/2.4.0/bundler/gems/inherited_resources-615b0d5c37a4/lib/generators/rails/templates
"Could not find" is never a good start to the last line of output.

Fortunately, this is a solvable problem. You just need to tell Rails to use the regular scaffolding process. You know, from the good old days.

Just remind Rails which scaffold_controller to use in your config/application.rb and we can be on our way:

config.app_generators.scaffold_controller = :scaffold_controller

Your config/application.rb should look something like this, and everything should be right with the world again:

require_relative 'boot'

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "action_cable/engine"
require "sprockets/railtie"
require "rails/test_unit/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module ListOfIngredients
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 5.2

    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration can go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded after loading
    # the framework and any gems in your application.

    # Only loads a smaller set of middleware suitable for API only apps.
    # Middleware like session, flash, cookies can be added back manually.
    # Skip views, helpers and assets when generating a new resource.
    config.api_only = true
    config.app_generators.scaffold_controller = :scaffold_controller

    # Middleware for ActiveAdmin
    config.middleware.use Rack::MethodOverride
    config.middleware.use ActionDispatch::Flash
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use ActionDispatch::Session::CookieStore
  end
end

This seems like a good moment for a shout out to the hours I spent trying to understand this particular error by typing every variation of it into StackOverflow.

Back to scaffolding—let's start with the Drink model:

bin/rails g scaffold Drink title:string description:string steps:string source:string

Then, the Ingredient model:

bin/rails g scaffold Ingredient drink:references description:string

Notice that the Ingredient references the Drink. This tells the Ingredient model to belong_to the Drink, which is part of the whole has_many relative database association thing.

See, my Relational Databases 101 comp-sci class was totally worth it.

Unfortunately this won’t tell your Drink model to has_many of the Ingredient model, so you’ll also need to add that to app/models/drink.rb all by yourself:

class Drink < ApplicationRecord
  has_many :ingredients
end

Then we can migrate and tell ActiveAdmin about our new friends:

bin/rake db:migrate
bin/rails generate active_admin:resource Drink
bin/rails generate active_admin:resource Ingredient

Go team!

Now, Rails is a security conscious beast, so you’ll need to add some stuff to the two files ActiveAdmin will have generated, app/admin/drinks.rb and app/admin/ingredients.rb. Specifically, you’ll need to permit ActiveAdmin to edit the content in your database, which, when you think about it, is pretty reasonable.

First up, app/admin/drinks.rb:

ActiveAdmin.register Drink do
  permit_params :title, :description, :steps, :source

# See permitted parameters documentation:
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
#
# permit_params :list, :of, :attributes, :on, :model
#
# or
#
# permit_params do
#   permitted = [:permitted, :attributes]
#   permitted << :other if params[:action] == 'create' && current_user.admin?
#   permitted
# end

end

Then app/admin/ingredients.rb:

ActiveAdmin.register Ingredient do
  permit_params :description, :drink_id

# See permitted parameters documentation:
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
#
# permit_params :list, :of, :attributes, :on, :model
#
# or
#
# permit_params do
#   permitted = [:permitted, :attributes]
#   permitted << :other if params[:action] == 'create' && current_user.admin?
#   permitted
# end

end

Without permit_params, you can never edit your delicious drink recipes. Not on my watch.

In our routes, we’ll need to hook up the drinks resource. I like to scope my API calls to /api, so let’s do that.

scope '/api' do
  resources :drinks
end

You can also remove these two declarations:

resources :ingredients
resources :drinks

Your file should look something like this:

Rails.application.routes.draw do
  devise_for :admin_users, ActiveAdmin::Devise.config
  ActiveAdmin.routes(self)

  scope '/api' do
    resources :drinks
  end
end

Next up, start the server:

bin/rails s -p 3001

And you should be able to visit http://localhost:3001/api/drinks to see… drumroll...

[ ]

Nothing.

So, we should probably add some drinks. We can do that by populating db/seeds.rb, which is a file that allows you to add data to your database. You may notice a line is already here:

AdminUser.create!(email: 'admin@example.com', password: 'password', password_confirmation: 'password') if Rails.env.development?

To ensure we can log onto our CMS in production, let’s remove the if Rails.env.development? conditional that ActiveAdmin has added:

AdminUser.create!(email: 'admin@example.com', password: 'password', password_confirmation: 'password')
Just a friendly reminder to be a good internet citizen and update your password in production as soon as you seed it.

To save time, and so you don’t have to source your own recipes, I prepared two tasty smoothies and one terrible pun.

Add the recipes below:

breakfast_smoothie = Drink.create(
  title: "Two-Minute Breakfast Boost",
  description: "Whizz up a low-fat breakfast smoothie in no time. Use banana with other soft fruit, plus honey for a little sweetness and oats for slow-release fuel.",
  steps: "Put all the ingredients in a blender and whizz for 1 min until smooth. Pour the mixture into two glasses to serve.",
  source: "https://www.bbcgoodfood.com/recipes/two-minute-breakfast-smoothie"
)
breakfast_smoothie.ingredients.create(description: "1 banana")
breakfast_smoothie.ingredients.create(description: "1 tbsp porridge oats")
breakfast_smoothie.ingredients.create(description: "80g soft fruit (like mango or strawberries)")
breakfast_smoothie.ingredients.create(description: "150ml milk")
breakfast_smoothie.ingredients.create(description: "1 tsp honey")
breakfast_smoothie.ingredients.create(description: "1 tsp vanilla extract")

kale_smoothie = Drink.create(
  title: "Kale And Hearty Smoothie",
  description: "Give yourself a dose of vitamin C in the morning with this vegan green smoothie. Along with kale and avocado, there's a hit of zesty lime and pineapple.",
  steps: "Put all of the ingredients into a bullet or smoothie maker, add a large splash of water and blitz. Add more water until you have the desired consistency.",
  source: "https://www.bbcgoodfood.com/recipes/kale-smoothie",
)
kale_smoothie.ingredients.create(description: "2 handfuls kale")
kale_smoothie.ingredients.create(description: "½ avocado")
kale_smoothie.ingredients.create(description: "½ lime, juice only")
kale_smoothie.ingredients.create(description: "large handful frozen pineapple chunks")
kale_smoothie.ingredients.create(description: "medium-sized chunk ginger")
kale_smoothie.ingredients.create(description: "1 tbsp cashew nuts")
kale_smoothie.ingredients.create(description: "1 banana, optional")

Your db/seeds.rb file should now look something like this:

# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup).
#
# Examples:
#
#   movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }])
#   Character.create(name: 'Luke', movie: movies.first)
AdminUser.create!(email: 'admin@example.com', password: 'password', password_confirmation: 'password')

breakfast_smoothie = Drink.create(
  title: "Two-Minute Breakfast Boost",
  description: "Whizz up a low-fat breakfast smoothie in no time. Use banana with other soft fruit, plus honey for a little sweetness and oats for slow-release fuel.",
  steps: "Put all the ingredients in a blender and whizz for 1 min until smooth. Pour the mixture into two glasses to serve.",
  source: "https://www.bbcgoodfood.com/recipes/two-minute-breakfast-smoothie"
)
breakfast_smoothie.ingredients.create(description: "1 banana")
breakfast_smoothie.ingredients.create(description: "1 tbsp porridge oats")
breakfast_smoothie.ingredients.create(description: "80g soft fruit (like mango or strawberries")
breakfast_smoothie.ingredients.create(description: "150ml milk")
breakfast_smoothie.ingredients.create(description: "1 tsp honey")
breakfast_smoothie.ingredients.create(description: "1 tsp vanilla extract")

kale_smoothie = Drink.create(
  title: "Kale And Hearty Smoothie",
  description: "Give yourself a dose of vitamin C in the morning with this vegan green smoothie. Along with kale and avocado, there's a hit of zesty lime and pineapple.",
  steps: "Put all of the ingredients into a bullet or smoothie maker, add a large splash of water and blitz. Add more water until you have the desired consistency.",
  source: "https://www.bbcgoodfood.com/recipes/kale-smoothie",
)
kale_smoothie.ingredients.create(description: "2 handfuls kale")
kale_smoothie.ingredients.create(description: "½ avocado")
kale_smoothie.ingredients.create(description: "½ lime, juice only")
kale_smoothie.ingredients.create(description: "large handful frozen pineapple chunks")
kale_smoothie.ingredients.create(description: "medium-sized chunk ginger")
kale_smoothie.ingredients.create(description: "1 tbsp cashew nuts")
kale_smoothie.ingredients.create(description: "1 banana, optional")

Now it’s just a case of seeding the database with bin/rake db:reset.

bin/rake db:reset

It’s worth noting that this will recreate your database locally—including resetting your admin password back to password. If your server is running you’ll need to restart it, too:

Now when you refresh you should see:

Our recipes in RSS format
Text smoothies!

So, we’re pretty good to go on the database front. Let’s just massage our scaffolded controllers a little. First, let’s cut back the DrinksController. We can make sure def index only returns the id and title of each drink, and we can make sure def show includes the id and description of each ingredient of the drink. Given how little data is being sent back, you could just grab everything from index, but for the purposes of showing how this could work in the Real World, let’s do it this way.

You’ll want to make sure your controllers are inheriting from ApiController, too. Jump into your drinks_controller.rb and replace it with the following:

class DrinksController < ApiController
  # GET /drinks
  def index
    @drinks = Drink.select("id, title").all
    render json: @drinks.to_json
  end

  # GET /drinks/:id
  def show
    @drink = Drink.find(params[:id])
    render json: @drink.to_json(:include => { :ingredients => { :only => [:id, :description] }})
  end
end

And let’s just get rid of 99% of ingredients_controller.rb, because it’s not going to be doing a lot:

class IngredientsController < ApiController
end
So minimal!

And now we have some fancy data to feed the client. Good for us! This is a big chunk of the setup, and you’re doing great. Maybe celebrate by taking a break? You have earned it.

When you’re back, let’s create a Procfile in the root of the app for running the whole setup. If you haven’t used them before, you can read about them here.

We’ll call it Procfile.dev, because while we do need to run a Node server locally, we’ll be deploying a pre-built bundle to Heroku, and we won’t need to run a Node server there. Having a Node server and Rails server locally massively speeds up development time, and it is pretty great, but it’s overkill for production. Your Procfile.dev should look like this:

web: PORT=3000 yarn --cwd client start
api: PORT=3001 bundle exec rails s

Procfiles are managed by the heroku CLI, which, if you don’t have installed, you can get right here.

Once that’s sorted, just run:

heroku local -f Procfile.dev

But hey, who wants to type that every single time? Why not make a rake task to manage doing it for you? Just add start.rake to your /lib/tasks folder:

namespace :start do
  task :development do
    exec 'heroku local -f Procfile.dev'
  end
end

desc 'Start development server'
task :start => 'start:development'

And from there all you need to do to fire up your development environment is run:

bin/rake start
One command to fire up two servers? Magic!

That step was a lot. Let’s break down what’s happening here.

heroku will start the front end, /client, on port 3000, and the API on port 3001. It’ll then open the client, http://localhost:3000 in your browser. You can access ActiveAdmin via the API, at http://localhost:3001/admin, just like you’ve been doing all along.

Which means we can now sort out the React app.

The simplest thing is to just check it works. Edit your client/src/App.js:

import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';

class App extends Component {
  componentDidMount() {
    window.fetch('/api/drinks')
      .then(response => response.json())
      .then(json => console.log(json))
      .catch(error => console.log(error));
  }
  render() {
    return (
      <div className="App">
        <div className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <h2>Welcome to React</h2>
        </div>
        <p className="App-intro">
          To get started, edit <code>src/App.js</code> and save to reload.
        </p>
      </div>
    );
  }
}

export default App;

In your browser console, you should see the API call logged.

[{id: 1, title: "Two-Minute Breakfast Boost"}, {id: 2, title: "Kale And Hearty Smoothie"}]

We can 100% use those id’s to grab the actual details of each smoothie in Rails. Sure, we could’ve just sent everything from the server because it’s only two drinks, but I figure this is closer to how you’d really build something.

Now, if you'd rather skip setting up the front end application, you can grab the client folder from the repo. Otherwise, install the following dependencies:

yarn --cwd client add semantic-ui-react semantic-ui-css

And add them to your /client app. First, add the css to client/src/index.js:

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import 'semantic-ui-css/semantic.css'
import './index.css'

ReactDOM.render(<App />, document.getElementById('root'))

And add all the fancy bells and whistles to your client/src/App.js:

import React, { Component } from 'react'
import { Container, Header, Segment, Button, Icon, Dimmer, Loader, Divider } from 'semantic-ui-react'

class App extends Component {
  constructor () {
    super()
    this.state = {}
    this.getDrinks = this.getDrinks.bind(this)
    this.getDrink = this.getDrink.bind(this)
  }

  componentDidMount () {
    this.getDrinks()
  }

  fetch (endpoint) {
    return window.fetch(endpoint)
      .then(response => response.json())
      .catch(error => console.log(error))
  }

  getDrinks () {
    this.fetch('/api/drinks')
      .then(drinks => {
        if (drinks.length) {
          this.setState({drinks: drinks})
          this.getDrink(drinks[0].id)
        } else {
          this.setState({drinks: []})
        }
      })
  }

  getDrink (id) {
    this.fetch(`/api/drinks/${id}`)
      .then(drink => this.setState({drink: drink}))
  }

  render () {
    let {drinks, drink} = this.state
    return drinks
      ? <Container text>
        <Header as='h2' icon textAlign='center' color='teal'>
          <Icon name='unordered list' circular />
          <Header.Content>
            List of Ingredients
          </Header.Content>
        </Header>
        <Divider hidden section />
        {drinks && drinks.length
          ? <Button.Group color='teal' fluid widths={drinks.length}>
            {Object.keys(drinks).map((key) => {
              return <Button active={drink && drink.id === drinks[key].id} fluid key={key} onClick={() => this.getDrink(drinks[key].id)}>
                {drinks[key].title}
              </Button>
            })}
          </Button.Group>
          : <Container textAlign='center'>No drinks found.</Container>
        }
        <Divider section />
        {drink &&
          <Container>
            <Header as='h2'>{drink.title}</Header>
            {drink.description && <p>{drink.description}</p>}
            {drink.ingredients &&
              <Segment.Group>
                {drink.ingredients.map((ingredient, i) => <Segment key={i}>{ingredient.description}</Segment>)}
              </Segment.Group>
            }
            {drink.steps && <p>{drink.steps}</p>}
            {drink.source && <Button basic size='tiny' color='teal' href={drink.source}>Source</Button>}
          </Container>
        }
      </Container>
      : <Container text>
        <Dimmer active inverted>
          <Loader content='Loading' />
        </Dimmer>
      </Container>
  }
}

export default App

I should clarify that this is what I like to call “proof of concept code”, rather than “well refactored code”. But, given we're already having a look at it, the main bit worth reviewing is getDrink:

getDrink (id) {
  this.fetch(`/api/drinks/${id}`)
    .then(drink => this.setState({drink: drink}))
}

This allows us to grab a specific drink based on its id. You can test it in the browser by visiting http://localhost:3001/api/drinks/1:

A single recipe in RSS format, with ingredients

While we’re here, you can also add some simple styles to your client/src/index.css:

body {
  margin: 0;
  padding: 0;
  font-family: sans-serif;
}

#root {
  padding: 4rem 0;
}

You now should have a fancy front end that uses Semantic UI and looks something like this:

Your Create React App pulling from the Rails API
Kale and Hearty! Get it?

Step 4: Get everything ready for production

With Rails serving the Webpack bundle


So, how do we get our Rails app serving the Webpack bundle in production?

That’s where the magic of Heroku's heroku-postbuild comes in. Heroku will build the app, then copy the files into the /public directory to be served by Rails. We end up running a single Rails server managing our front end and our back end. It’s win-win! There are a couple of steps to make that happen.

First up, let’s make a package.json file in the root of the app, which tells Heroku how to compile the Create React App. The heroku-postbuild command will get run after Heroku has built your application, or slug.

You may also notice that the build command uses yarn --cwd client, which tells yarn to run those commands in the client directory.

{
  "name": "list-of-ingredients",
  "license": "MIT",
  "engines": {
    "node": "10.15.3",
    "yarn": "1.15.2"
  },
  "scripts": {
    "build": "yarn --cwd client install && yarn --cwd client build",
    "deploy": "cp -a client/build/. public/",
    "heroku-postbuild": "yarn build && yarn deploy"
  }
}

On the plus side, this step is super short, which is just as well because my hands are getting sore.


Step 5: Deploy it to Heroku

And celebrate, because you’ve earned it


The finish line approaches! Soon, everything the light touches will be yours, including a fresh, healthy beverage.

Let’s make a Procfile, in the root, for production. It will tell Heroku how to run the Rails app. Add the following:

web: bundle exec rails s
release: bin/rake db:migrate

Note the release command—this is run by Heroku just before a new release of the app is deployed, and we’ll use it to make sure our database is migrated. You can read more about release phase here.

We'll also need a secrets.yml file, which lives in config. This is required by Devise, which handles the authentication for ActiveAdmin. You'll need to make a config/secrets.yml file, and it should look like this:

development:
  secret_key_base: 

test:
  secret_key_base: 

production:
  secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>

We'll need to add two keys, one for development and one for test. Fortunately, Rails is here to help. Just run:

bin/rake secret | pbcopy

This will generate a secret key, and add it to your clipboard. Just paste it after secret_key_base below development. Repeat the same for test, and you should end up with a config/secrets.yml that looks something like this:

development:
  secret_key_base: A_LONG_STRING_OF_LETTERS_AND_NUMBERS

test:
  secret_key_base: A_DIFFERENT_LONG_STRING_OF_LETTERS_AND_NUMBERS

production:
  secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>

And then let’s create a new Heroku app to get this thing over the finish line:

heroku apps:create

If you commit and push to Heroku right now, this looks to Heroku like a dual Rails / Node app, which is great. The thing is, your Node code needs to be executed first so it can be served by Rails. This is where Heroku buildpacks come in — they transform your deployed code to run on Heroku. We can tell Heroku, via the terminal, to use two buildpacks (or build processes) in a specific order. First nodejs, to manage the front end build, and then ruby, to run Rails:

heroku buildpacks:add heroku/nodejs --index 1
heroku buildpacks:add heroku/ruby --index 2

With that sorted, we can deploy and build our beverage-based app:

git add .
git commit -vam "Initial commit"
git push heroku master

Heroku will follow the order of the buildpacks, building client, and then firing up Rails.

One last thing—you’ll need to seed your database on Heroku, or ActiveAdmin will not be thrilled (and you won’t be able to log in). We won't need to worry about migrating, because that'll happen behind the scenes through the release script in our Procfile. Let’s seed so we can login and change the /admin password:

heroku run rake db:seed

And finally:

heroku open

And there you have it:

The final site, hosted on Heroku
Check it out at https://list-of-ingredients.herokuapp.com/

When you visit your app you’ll see your Create React App on the client side, displaying some delicious smoothie recipes. You’ll also be able hit /admin (for example, https://list-of-ingredients.herokuapp.com/admin) and access your database using that truly terrible username and password ActiveAdmin chose for you. Again, I’d recommend changing those on production ASAP. I did, in case anyone was thinking of changing my demo recipes to be less delicious.


Bonus round: Single page apps

Handling routes with your single page app


Now, you may at this point want to add different pages, handled within your Create React App, using something like React Router. This will require a few additions to the Rails app as well. Let’s get started!

First up, we’re going to tell Rails to pass any HTML requests that it doesn’t catch to our Create React App.

In your app/controllers/application_controller.rb, add a fallback_index_html method:

def fallback_index_html
  render :file => 'public/index.html'
end

It should look something like this:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  def fallback_index_html
    render :file => 'public/index.html'
  end
end

And at the bottom of your config/routes.rb:

get '*path', to: "application#fallback_index_html", constraints: ->(request) do
  !request.xhr? && request.format.html?
end

So it looks something like this:

Rails.application.routes.draw do
  devise_for :admin_users, ActiveAdmin::Devise.config
  ActiveAdmin.routes(self)

  scope '/api' do
    resources :drinks
  end

  get '*path', to: "application#fallback_index_html", constraints: ->(request) do
    !request.xhr? && request.format.html?
  end
end

That way Rails will pass anything it doesn’t match over to your client/index.html so that React Router can take over. Winning!

From here, we can implement React Router and catch some 404’s. First off, let’s install React Router:

yarn --cwd client add react-router-dom

We’ll need to move our client/src/App.js into its own component, so we can use the App class to handle routes and navigation. Rename App.js to Home.js, and update the class name to Home. Your client/src/Home.js should look like this:

import React, { Component } from 'react'
import { Container, Header, Segment, Button, Icon, Dimmer, Loader, Divider } from 'semantic-ui-react'

class Home extends Component {
  constructor () {
    super()
    this.state = {}
    this.getDrinks = this.getDrinks.bind(this)
    this.getDrink = this.getDrink.bind(this)
  }

  componentDidMount () {
    this.getDrinks()
  }

  fetch (endpoint) {
    return window.fetch(endpoint)
      .then(response => response.json())
      .catch(error => console.log(error))
  }

  getDrinks () {
    this.fetch('/api/drinks')
      .then(drinks => {
        if (drinks.length) {
          this.setState({drinks: drinks})
          this.getDrink(drinks[0].id)
        } else {
          this.setState({drinks: []})
        }
      })
  }

  getDrink (id) {
    this.fetch(`/api/drinks/${id}`)
      .then(drink => this.setState({drink: drink}))
  }

  render () {
    let {drinks, drink} = this.state
    return drinks
      ? <Container text>
        <Header as='h2' icon textAlign='center' color='teal'>
          <Icon name='unordered list' circular />
          <Header.Content>
            List of Ingredients
          </Header.Content>
        </Header>
        <Divider hidden section />
        {drinks && drinks.length
          ? <Button.Group color='teal' fluid widths={drinks.length}>
            {Object.keys(drinks).map((key) => {
              return <Button active={drink && drink.id === drinks[key].id} fluid key={key} onClick={() => this.getDrink(drinks[key].id)}>
                {drinks[key].title}
              </Button>
            })}
          </Button.Group>
          : <Container textAlign='center'>No drinks found.</Container>
        }
        <Divider section />
        {drink &&
          <Container>
            <Header as='h2'>{drink.title}</Header>
            {drink.description && <p>{drink.description}</p>}
            {drink.ingredients &&
              <Segment.Group>
                {drink.ingredients.map((ingredient, i) => <Segment key={i}>{ingredient.description}</Segment>)}
              </Segment.Group>
            }
            {drink.steps && <p>{drink.steps}</p>}
            {drink.source && <Button basic size='tiny' color='teal' href={drink.source}>Source</Button>}
          </Container>
        }
      </Container>
      : <Container text>
        <Dimmer active inverted>
          <Loader content='Loading' />
        </Dimmer>
      </Container>
  }
}

export default Home

And let’s make a component to display our 404, client/src/NotFound.js.

import React, { Component } from 'react'
import { Container, Button } from 'semantic-ui-react'
import { Link } from 'react-router-dom'

class NotFound extends Component {
  render () {
    return <Container text textAlign='center'>
      <h1>404: Not found</h1>
      <Button as={Link} to='/'>Back to home</Button>
    </Container>
  }
}

export default NotFound

Make a new client/src/App.js, and add in some routing:

import React, { Component } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import Home from './Home'
import NotFound from './NotFound'

class App extends Component {
  render () {
    return <Router>
      <Switch>
        <Route path='/' exact component={Home} />
        <Route component={NotFound} />
      </Switch>
    </Router>
  }
}

export default App

Now you can run jump back into your root directly, run bin/rake start, and visit any URL that isn’t the root to get your 404.

An example of the 404 page
The very worst 404—no puppies

From there, you can add as many routes as you like, and if Rails doesn’t catch them first, they’ll be served by your client. Nice work!

To test this on your live app commit your changes and push:

git add .
git commit -vam "Added react router"
git push heroku master
heroku open

And visit any random page, like /puppies. You should see your 404, served by Create React App. Nice work!


This isn’t exactly the most thrilling demo (tasty as it may be) but hopefully it gets you up and running. All the ingredients to make a delicious Rails API / ActiveAdmin / Create React App flavoured beverage are here, and the sky’s the limit.

Again, you can see a ready-to-go repo here, too, including a Heroku button for instant deployment: http://github.com/heroku/list-of-ingredients

Thanks for taking the time to have a look, and I genuinely hope you celebrated with a smoothie.

Shout out to Roman Rott, Carlos Ramirez III, and Full Stack React for the inspiration to put this together. And a massive thank you to Glen and Xander for taking the time to make suggestions and proofread the first take, and to Chris for working with me on this one.

If you have any questions or comments say hi via Twitter.

Originally published: May 16, 2018

Browse the archives for engineering or all blogs Subscribe to the RSS feed for engineering or all blogs.