Build and combine a Ruby on Rails API with a React front end and deploy to Heroku
By Steve Carey - 7/1/2019
This Tutorial covers:Finished code: Github
This is part of a series of tutorials on APIs and the React library. See the full list at Tutorials. Throughout this series we build a Create-Read-Update-Delete (CRUD) application with a React front end and a Ruby on Rails back end. Each section is self-contained.
The first tutorial in this series showed you how to build a Rails Application Programming Interface (API). The second tutorial added the React front end. This section will show you how to deploy it to Heroku. We will build the whole app again with some changes for production.
A Rails API-only app is a slimmed down version of a regular Rails web app, skipping the Asset Pipeline and generation of view and helper files. You can read the details in the Rails Guides - API App.
From the command line create a directory for your app, then generate a Rails app using the --api flag to make it API only.
mkdir appname; cd appname
rails new . --api
In production we want to use the PostgreSQL database instead of the Rails default SQLite. For a real app we would want to use the same database for development, test and production. For a practice app like this, it's fine to use SQLite on our local environment. Move the sqlite3 gem into the development and test group. Then create a production group and add the pg gem.
# Gemfile group :development, :test do gem 'sqlite3' ... end group :production do gem 'pg' end
bundle install
Change the database.yml file to the below. For the Production section, replace "appname" with the name of your app.
# config/database.yml development: adapter: sqlite3 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 database: db/development.sqlite3 test: adapter: sqlite3 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 database: db/test.sqlite3 production: adapter: postgresql encoding: unicode pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> database: appname_production username: appname password: <%= ENV['APPNAME_DATABASE_PASSWORD'] %>
Create an Article resource.
rails generate scaffold Article title:string content:text
rails db:migrate
Add some seed data to db/seeds.rb
# db/seed.rb articles = Article.create([ {title: "Learn Ruby", content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."}, {title: "Learn Rails", content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."}, {title: "Learn React", content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."} ])
rails db:seed
Add a namespace to the api. We'll use "api" but it could be any name. Using a namespace avoids collisions between the rails routes and the react app's routes. Defining the namespace as a scope means it only affects the route and you don't need to add a namespace to the controller.
# config/routes.rb scope '/api' do resources :articles end
rails server -p 3001
We'll use the create-react-app npm package to build the React app within our Rails API project in a directory named "client". I'll skip the detailed explanations since they are already covered in the React CRUD app tutorial. From the Rails API project's root directory run:
create-react-app client
cd client
This will add a directory named "client" and install the react app and all the npm packages inside it.
Install the react-router-dom and axios packages:
yarn add react-router-dom axios
Run the React app. Adding the cwd option followed by the path from the current working directory allows you to run it without having to cd into the client folder.
yarn start
And this should open your browser to localhost:3000 and greet you with the Welcome to React web page including a spinning logo.
In the client/package.json file add a proxy property telling the React app to call port 3001.
# client/package.json { "name": "client", "version": "0.1.0", "private": true, "proxy": "http://localhost:3001", ... }
Use UNIX commands to create the file structure. Make sure you are in the client directory. Note, we are moving the React favicon.ico to the images folder but in a real app you would of course put your own favicon image there.
mkdir public/images mv public/favicon.ico public/images/favicon.ico mkdir src/images mkdir src/stylesheets mkdir src/components mv src/logo.svg src/images/logo.svg mv src/index.css src/stylesheets/index.css mv src/App.css src/stylesheets/App.css mv src/App.js src/components/App.jsx mv src/App.test.js src/components/App.test.js touch src/components/Home.jsx touch src/components/ArticleList.jsx touch src/components/ArticleInfo.jsx touch src/components/ArticleAdd.jsx touch src/components/ArticleEdit.jsx
We temporarily broke our app. Fix the broken links and imports to get it working again:
# public/index.html <link rel="shortcut icon" href="%PUBLIC_URL%/images/favicon.ico">
# src/index.js import './stylesheets/index.css'; import App from './components/App';
# src/components/App.jsx import logo from '../images/logo.svg'; import '../stylesheets/App.css';
Save each file after you make the changes, then go check the browser. The app should be working again.
Now let's make our own React content. Start by removing the css classes from App.css.
# src/stylesheets/App.css /* Remove all the CSS classes */
For convenience we'll use the Bootstrap CDN to style our app:
# client/src/stylesheets/index.css @import url('https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/css/bootstrap.min.css');
This is a Single-Page Application (SPA) with CRUD capabilities and this is the single page. It is a standard HTML page with one element in the body. A div with id of "root" which is where all our React components will be displayed via JavaScript.
Then in the src/index.js file we import the React library and render our main component. Your src/index.js file should look like this.
import React from 'react'; import ReactDOM from 'react-dom'; import './stylesheets/index.css'; import App from './components/App'; import * as serviceWorker from './serviceWorker'; ReactDOM.render(<App />, document.getElementById('root')); serviceWorker.unregister();
Let's start out with a very simple component called Home. We already created an empty Home.jsx file. Populate it with the below:
import React from 'react'; const Home = () => { return ( <div className="jumbotron"> <h1>Home Page</h1> </div> ); } export default Home;
The central component for our app is "App". That is what is returning the Welcome to React page we see in the browser. Replace that with a Navigation bar and a place to render the other components.
import React, { Component } from 'react'; import '../stylesheets/App.css'; import Home from './Home'; import ArticleList from './ArticleList'; import ArticleInfo from './ArticleInfo'; import ArticleAdd from './ArticleAdd'; import ArticleEdit from './ArticleEdit'; import {BrowserRouter as Router, Route, NavLink, Switch} from 'react-router-dom' class App extends Component { render() { return ( <div className="App"> <Router> <div className="container"> <Navigation /> <Main /> </div> </Router> </div> ); } } const Navigation = () => ( <nav className="navbar navbar-expand-lg navbar-dark bg-dark"> <ul className="navbar-nav mr-auto"> <li className="nav-item"><NavLink exact className="nav-link" activeClassName="active" to="/">Home</NavLink></li> <li className="nav-item"><NavLink exact className="nav-link" activeClassName="active" to="/articles">Articles</NavLink></li> </ul> </nav> ); const Main = () => ( <Switch> <Route exact path="/" component={Home} /> <Route exact path="/articles" component={ArticleList} /> <Route exact path="/articles/new" component={ArticleAdd} /> <Route exact path="/articles/:id" component={ArticleInfo} /> <Route exact path="/articles/:id/edit" component={ArticleEdit} /> </Switch> ); export default App;
Once you add the code and save the file, go to the browser and make sure you didn't get any errors. You should see a navigation bar with two links, and a simple home page jumbotron. If you click on the articles link you'll get a blank page because we haven't populated that yet.
Populate the ArticleList component. This returns the equivalent of the Rails Articles index page. We call the API to retrieve a list of all the articles in the database. We set a proxy in the package.json file so we don't need to add the full URL in our API calls, just the path.
import React, { Component } from 'react'; import { get } from 'axios'; import { Link } from 'react-router-dom'; class ArticleList extends Component { constructor() { super(); this.state = { articles: [] }; } componentDidMount() { get('/api/articles.json') .then(response => { this.setState({articles: response.data}); }) .catch(error => console.log('error', error)); } render() { return ( <div> {this.state.articles.map((article) => { return( <div key={article.id}> <h2><Link to={`/articles/${article.id}`}>{article.title}</Link></h2> {article.content} <hr/> </div> ) })} <Link to="/articles/new" className="btn btn-outline-primary">Create Article</Link> </div> ) } } export default ArticleList;
Check to make sure it works. Make sure the server is running on port 3001.
rails server -p 3001
In the browser on localhost:3000 if you click on the articles link you should see a list of articles. This corresponds with the JSON view at http://localhost:3001/api/articles.json which you can view in a separate browser tab.
Rather than changing into the client directory to start the React app, you can use the change-working-directory option to run it from the Rails product root directory like this: yarn --cwd client start
. That still requires you to open two Terminal windows and start two separate servers. Instead, you can add the Foreman gem to run them both with one command. Foreman is a utility you call from the Terminal so you save it directly to your system, not though your Gemfile.
gem install foreman
Then create a Procfile for your development environment and populate it with the below.
touch Procfile.dev
# Procfile.dev web: cd client && PORT=3000 yarn start api: PORT=3001 && bundle exec rails s
By default Foreman looks for a file called Procfile to execute. The force option -f tells Foreman to use the provided file name instead. To start both servers run:
foreman start -f Procfile.dev
On to the ArticleInfo component which would correspond in Rails with the Articles show page. Populate the file with the below and save it.
import React, { Component } from 'react'; import axios from 'axios'; import { Link } from 'react-router-dom'; class ArticleInfo extends Component { constructor() { super(); this.state = { article: {} }; this.handleDelete = this.handleDelete.bind(this); } componentDidMount() { axios.get(`/api/articles/${this.props.match.params.id}.json`) .then((response) => { this.setState({ article: response.data }) }) .catch(error => console.log('error', error)); } handleDelete() { axios.delete(`/api/articles/${this.props.match.params.id}.json`) .then(() => { this.props.history.push("/articles") }) .catch(error => console.log('error', error)); } render() { return ( <div> <h2>{this.state.article.id}: {this.state.article.title}</h2> <p>{this.state.article.content}</p> <p> <Link to={`/articles/${this.state.article.id}/edit`} className="btn btn-outline-dark">Edit</Link> <button onClick={this.handleDelete} className="btn btn-outline-dark">Delete</button> <Link to="/articles" className="btn btn-outline-dark">Close</Link> </p> <hr/> </div> ) } } export default ArticleInfo;
Now test it out in the browser. From the Articles page, if you click on an article title it should take you to the article page.
ArticleAdd is the React equivalent of the Rails articles new page.
import React, { Component } from 'react'; import { post } from 'axios'; class ArticleAdd extends React.Component { constructor() { super(); this.state = { title: '', content: ''}; this.handleChange = this.handleChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.handleCancel = this.handleCancel.bind(this); } handleSubmit(event) { event.preventDefault(); post('/api/articles.json', this.state) .then((response) => { this.props.history.push(`/articles/${response.data.id}`); }) .catch(error => console.log('error', error)); } handleChange(event) { this.setState({ [event.target.name]: event.target.value }); } handleCancel() { this.props.history.push("/articles"); } render() { return ( <div> <h1>Create Article Post</h1> <form onSubmit={this.handleSubmit}> <div className="form-group"> <label>Title</label> <input type="text" name="title" value={this.state.title} onChange={this.handleChange} className="form-control" /> </div> <div className="form-group"> <label>Content</label> <textarea name="content" rows="5" value={this.state.content} onChange={this.handleChange} className="form-control" /> </div> <div className="btn-group"> <button type="submit" className="btn btn-dark">Create</button> <button type="button" onClick={this.handleCancel} className="btn btn-secondary">Cancel</button> </div> </form> </div> ); } } export default ArticleAdd;
ArticleEdit is the React version of Rails articles edit page.
import React from 'react'; import { get, patch } from 'axios'; class ArticleEdit extends React.Component { constructor() { super(); this.state = { title: '', content: ''}; this.handleChange = this.handleChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.handleCancel = this.handleCancel.bind(this); } componentDidMount() { get(`/api/articles/${this.props.match.params.id}.json`) .then((response) => { this.setState(response.data); }) .catch(error => console.log('error', error)); } handleSubmit(event) { event.preventDefault(); patch(`/api/articles/${this.state.id}.json`, this.state) .then(() => { this.props.history.push(`/articles/${this.state.id}`); }) .catch(error => console.log('error', error)); } handleChange(event) { this.setState({ [event.target.name]: event.target.value }); } handleCancel() { this.props.history.push(`/articles/${this.state.id}`); } render() { return ( <div> <h1>Edit {this.state.title}</h1> <form onSubmit={this.handleSubmit}> <div className="form-group"> <label>Title</label> <input type="text" name="title" value={this.state.title} onChange={this.handleChange} className="form-control" /> </div> <div className="form-group"> <label>Content</label> <textarea name="content" rows="5" value={this.state.content} onChange={this.handleChange} className="form-control" /> </div> <div className="btn-group"> <button type="submit" className="btn btn-dark">Update</button> <button type="button" onClick={this.handleCancel} className="btn btn-secondary">Cancel</button> </div> </form> </div> ); } } export default ArticleEdit;
Save it and test it out. We are now done with our React components and we have a functionining CRUD application, at least in our development environment.
Add a package.json file to the root of your project. yarn init
or npm init
will create a basic one and ask you a series of questions for populating it. Adding the --yes flag will use the default answers which is fine for our purposes.
yarn init -y
So now we have a package.json file in our client directory for React and in the root directory of our Rails app. Open the latter that we just created and add engines and scripts properties.
# package.json { "name": "appname", "version": "1.0.0", "main": "index.js", "author": "your_name", "license": "MIT", "engines": { #1 "node": "10.16.0", "yarn": "1.16.0" }, "scripts": { #2 "build": "yarn --cwd client install && yarn --cwd client build", "deploy": "cp -a client/build/. public/", "heroku-postbuild": "yarn build && yarn deploy" } }
npm show package-name version
will return the latest package versions. But for node you might want to use the more conservative LTS version which you can get from nodejs.org.Heroku uses the Foreman gem which by default will look for a file named "Procfile". We can put some instructions to use the puma server (or a different server if you prefer) and what port to run on.
touch Procfile
# Procfile web: bundle exec puma -t 5:5 -p ${PORT:-3001} -e ${RACK_ENV:-development}
This tutorial assumes you are already familiar with Heroku. If not, you need to first create an account on Heroku: heroku.com
Then download and install the Heroku Command Line Interface on your computer devcenter.heroku.com/articles/heroku-cli.
From the CLI, create a new Heroku App:
heroku create appname
If your appname is already taken you'll have to pick another one. Or leave off the appname and let Heroku generate a super awesome one for you. If you have a real domain name you can configure Heroku to use that devcenter.heroku.com/articles/custom-domains
Check that your app is connected to Heroku. The below command will list the project's remote git repositories (if any) and their urls. Rails automatically initiates a git repository when you create an app (so you don't need to run git init
)
git remote -v
Tell Heroku to execute the Node.js buildpack before the Ruby buildpack. So in the CLI run:
heroku buildpacks:add heroku/nodejs --index 1
heroku buildpacks:add heroku/ruby --index 2
Commit all your changes to git then push the app to Heroku:
git add .
git commit -m "initial commit"
git push heroku master
Run the database migrations then seed the database. Heroku automatically adds the Heroku Postgres addon for you.
heroku run rake db:migrate
heroku run rake db:seed
Assuming you got no errors in the above processes, you can run:
heroku open
The app will open to https://appname.herokuapp.com.
To see the API JSON data go to https://appname.herokuapp.com/api/articles