This post was originally written for Neoteric Design.
At Neoteric, we’ve been developing more sites and apps using the JAMstack framework, especially when it’s a better fit than a full CMS web application. But what about projects that still need sprinkles of a web app?
For these cases, we build a quick, minimal Rails API to serve the app needs, and get the best of both worlds. On the one hand, we get the speed, security, and ease of growth that comes with a static site generator for most of the site. On the other hand, we get rapid and reliable Rails development tools to implement business logic that drives features like registrations, e-commerce, accounts, and more.
Developing an API with Rails is well-worn territory, but when it comes to building the code that consumes it, there are many different ways to approach it. How to choose? When it comes time to solve new problems, I find it best to try the simplest possible way until I feel the pain.
I started just writing client Javascript code within the static site project, with no build system, and only a couple of plain script includes. This Javascript quickly (and not too surprisingly) exceeded the scope I was comfortable with to ship it live.
We want to keep static site deploys lean and uncomplicated. So another option, introducing a Node dependency, was tossed out: it’s the opposite of lean and uncomplicated.
How about developing the client as its own project, as an NPM/ad-hoc package? That option seemed a bit overkill for something used for only one site, that was not very large, and would likely be low-churn after launch.
But in developing the Rails API, we already have a perfectly good Webpack pipeline, testing infrastructure, and hosting. That’s when I realized: maybe we can use Webpack to develop and serve the client.
Expose your functions
Webpack doesn’t pollute the global namespace by default, which is sensible. But, what if you really do need other code to use it? There’s a loader for that! expose-loader can take exports from a file and make them available under a namespace. Here’s a basic expose-loader configuration. The options
it takes is the namespace the exports will be accessible under. In this case, we export a class called Client
, and we can call it from the outside as Courses.Client
// config/webpack/loaders/expose.js
module.exports = {
rules: [{
test: /courses_client/,
use: [{
loader: 'expose-loader',
options: 'Courses'
}]
}]
}
Then, add the loader to webpack:
// config/webpack/environment.js
const { environment } = require('@rails/webpacker')
const expose = require('./loaders/expose')
environment.loaders.prepend('expose', expose)
module.exports = environment
Link the pack
Webpacker packs are still fingerprinted like Asset Pipeline assets. So, we have to get the current path from Rails. The most straightforward approach is to set up a controller action with a redirect. There are likely more lightweight ways to do this, but this has worked well enough for us:
class ClientAssetsController < ApplicationController
include ActionView::Helpers::AssetUrlHelper
include Webpacker::Helper
def courses_client
redirect_to asset_pack_path("courses_client.js")
end
end
Set up some examples
Setting up a reference implementation inside the Rails app was hugely helpful, even in our case where there’s only one consumer of the library. Having both keeps us honest about the contract the API promises and gives us an easy way to do full-stack system tests.
To isolate the reference implementation from the rest of the Rails app, and to simulate being an outside site, I create a layout and JS pack for it:
<!-- app/layouts/examples.html.erb -->
<script src="<%= courses_client_url %>"
type="text/javascript"></script>
<%= javascript_pack_tag "examples" %>
// app/javascript/packs/examples.js
document.addEventListener("DOMContentLoaded", () => {
// ...
let client = new Courses.Client()
client.doThings()
}
I then add the controller and routes, taking care not to have them accessible in production:
# config/routes.rb
Rails.application.routes.draw do
# ...
scope "/examples", constraints: -> { !Rails.env.production? } do
get "register" => "examples#register"
end
end
# app/controllers/examples_controller.rb
class ExamplesController < ApplicationController
layout "examples"
def register
end
end
From there, we’re ready to start test cases and system tests to execute against them. It’s still useful to have unit tests for the API and JS separately. But testing the entire stack using friendly and familiar tools dramatically increases our confidence in the reliability of our shipped product. It’s also tremendously useful to reference in the documentation. Unlike usual documentation, it won’t fall out of date with the code, because it’s a part of the test suite/CI.
With this in place, we have all the pieces needed to sprinkle app-like features onto a static site, and all covered by the easy-to-use Rails tooling.