Deploying Storybook on Rails


At work, we’ve been using Storybook to help build out our design system.

We then ran into the issue of how to make it accessible to the wider team. We wanted it to be easily accessible to everyone, but not public.

Heroku is what we use for hosting, so the easiest thing seemed to be build it into our Rails app. I went down the path of looking at creating a separate engine to serve the Storybook files, before realising that Rack::Static had everything I need.

Turns out if you go hunting around in rack, theres a super handy Rack::Builder and we can combine this with Rack::Static to serve the static files storybook can generate.

# app/lib/storybook.rb
class Storybook
  def self.available?
    static_build_enabled? && storybook_static_present?
  end

  # In development, we want to run storybook so we get automatic recompiling
  # on file changes
  def self.static_build_enabled?
    !Rails.env.development? && ENV["DISABLE_STORYBOOK_PRECOMPILE"].blank?
  end

  # Make sure that we static content is available
  def self.storybook_static_present?(file: File)
    return @storybook_static_present if defined?(@storybook_static_present)

    storybook_directory = Rails.root.join("storybook-static/storybook")
    @storybook_static_present ||= file.directory?(storybook_directory)
  end

  def self.app
    Rack::Builder.app do
      use Rack::Static,
          index: "index.html",
          cascade: false,
          # Only serve files in the /storybook path, relative to the root
          urls: ["/storybook"],
          root: Rails.root.join("storybook-static"),
          # Need to disable the Rails CSP, as otherwise all the scripts
          # on the storybook index.html will not run
          header_rules: [
            [:all, { "Content-Security-Policy" => "" }]
          ]
      # This run will never be called, as the Rack::Static middleware is
      # serving everything, but it needs a 'run' to keep Rack::Builder happy
      run(proc { |_env| ["404", { "Content-Type" => "text/html" }, [""]] })
    end
  end
end

We use devise for authentication, so the if we add this to our routes file, storybook won’t be accessible unless the request is authenticated as an admin role.

As an alternative to using devise auth, you could mount storybook with rack basic auth if you wanted to keep it private.

# config/routes.rb
authenticate :admin do # devise authentication
  # These files might not be compiled, so only allow this route if the
  # compiled storybook files are present.
  # This directory needs to match the one defined in the 'build-storybook'
  # script in package.json
  # Use `yarn storybook` to run storybook in development
  constraints -> { Storybook.available? } do
    mount Storybook.app, at: "/storybook-static"
  end
end
}, at: "/storybook"

Initially I tried using heroku-buildpack-storybook to build the storybook, but there were some issues. Rails on Heroku automatically runs assets:precompile, which in turn runs assets:cleanup, which if you are using webpacker, results in the node_modules directory being removed. This makes it a bit of a pain to run build-storybook.

The solution I came up with was to add an action to the assets:precompile step so that the storybook files are compiled at the same time.

# config/initializers/storybook.rb

# If you are on a version of rack <= 3.0.8, then you need to
# map mjs files to application/javascript as Storybook uses mjs files.
# The next version of rack should have this built in when this is shipped:
# https://github.com/rack/rack/commit/1bd0f1597d8f4a90d47115f3e156a8ce7870c9c8
Rack::Mime::MIME_TYPES[".mjs"] = "application/javascript"

Rails.application.reloader.to_prepare do
  # Compile storybook when precompiling assets so we don't have
  # to remember to do it each time.
  if Rake::Task.task_defined?("assets:precompile") && Storybook.static_build_enabled?

    Rake::Task["assets:precompile"].enhance do
      Rake::Task["storybook:build"].invoke
    end
  end
end

You will also need the rake task invoked above, which looks like this:

# lib/tasks/storybook.rake

namespace :storybook do
  desc "Run the 'yarn build-storybook' command"
  task build: :environment do |_t|
    Dir.chdir(Rails.root) do
      puts `yarn build-storybook`
    end
  end
end

# See config/initializers/storybook.rb for the hook of storybook
# build into the assets:precompile step

Then we need a script in yarn to run the build.

// package.json

"scripts": {
  "storybook": "start-storybook -p 6006",
  "build-storybook": "build-storybook -o storybook-static/storybook"
},

And et voilĂ , you have storybook accessible at yourdomain.com/storybook-static