Why this pattern

This guide documents a simple, repeatable way to add small, focused React apps to a Rails project—bundled with Bun, loaded via Importmap, and mounted only where needed. It avoids heavy toolchains (no webpack/vite), keeps assets cacheable, and plays nicely with Turbo.

React application conventions

Below we will get your first app setup!

A Simple React Timer

Directory template

mkdir frontend
mkdir frontend/AwesomeReactTimer

Make your files

touch frontend/AwesomeReactTimer/index.jsx
touch frontend/AwesomeReactTimer/AwesomeReactTimer.jsx

index.jsx (pattern)

import React from "react";
import { createRoot } from "react-dom/client";
import App from "./AwesomeReactTimer.jsx";

export function mount(el) {
  createRoot(el).render(<App />);
}

Build and scripts (Bun)

We use Bun for bundling. Do not use webpack or Vite.

Add each new app folder to the build scripts in package.json:

{
  "scripts": {
    "dev": "bun build frontend/AwesomeReactTimer  --outdir=vendor/javascript/apps --target=browser --format=esm --minify --watch",
    "build": "bun build frontend/AwesomeReactTimer  --outdir=vendor/javascript/apps --target=browser --format=esm --minify"
  }
}

Run dev via Procfile.dev:

web: bin/rails server
css: bin/rails tailwindcss:watch
js: bun install && bun run dev

This emits ESM bundles to vendor/javascript/apps/<AppName>/index.js suitable for Importmap pins.

Your React timer will be at vendor/javascript/apps/AwesomeReactTimer/index.js

Importmap pin

Add a pin in config/importmap.rb for each app:

pin "apps/<alias>", to: "apps/<AppName>/index.js"

Example:

pin "apps/timer", to: "apps/AwesomeReactTimer/index.js"

Wiring in Rails

Import and mount conditionally on presence of the mount element in app/javascript/application.js:

import * as AwesomeReactTimer from "apps/timer";

document.addEventListener("turbo:load", () => {
  const timerElement = document.getElementById("react-timer");
  if (timerElement) {
    console.log("mounting your React Application)
    AwesomeReactTimer.mount(timerElement);
  }
});

Place a container in the relevant view:

<div id="react-timer"></div>

This ensures the React app only initializes on pages where it’s needed, and plays nicely with Turbo navigation.

Naming conventions

Dependencies

Adding a new app (checklist)

  1. Create frontend/<AppName>/{index.jsx,<AppName>.jsx} exporting mount(el).
  2. Add the folder to package.json dev and build scripts.
  3. Pin in config/importmap.rb as apps/<alias>apps/<AppName>/index.js.
  4. Import in app/javascript/application.js and mount on a unique element id.
  5. Add the mount container to the relevant .erb template.

Troubleshooting tips

Production

Inside HatchBox i’m running prebuild step:

bun install && bun run build

We don’t want to use dev because it adds --watch and your build command will hang forever.