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.
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 customization 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:
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:
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:
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!
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:
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:
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:
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:
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:
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:
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:
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:
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/login) 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.
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: https://github.com/superhighfives/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.