Having Fun with React Apps in Rails with Bun and Importmap

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

  • Location: Each app lives in frontend/<AppName> (PascalCase folder).
  • Entry: App exposes mount(el) from index.jsx that renders the top-level component with createRoot(el).render(<App />).
  • Top component: frontend/<AppName>/<AppName>.jsx exports the default React component.
  • Output: Bundled ESM is emitted under vendor/javascript/apps/<AppName>/index.js.
  • Import alias: Pin as apps/<app_name> (kebab/snake acceptable) to apps/<AppName>/index.js in config/importmap.rb.
  • Mount point: In app/javascript/application.js, import the alias and mount on an element id you control (e.g., #yard-area-calculator).

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

  • Folder: PascalCase (e.g., AwesomeReactTimer).
  • Import alias: apps/<lowercase_or_kebab> for clarity in application.js.
  • Element id: kebab-case (e.g., yard-area-calculator).

Dependencies

  • React 19 (react, react-dom). Keep dependencies minimal; prefer standard DOM APIs.
  • Tailwind classes are available via the Rails pipeline; no extra CSS tooling required for apps.

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

  • If the app doesn’t mount, confirm the element id matches and that turbo:load is firing (Turbo might be disabled on the page).
  • If the import fails, verify the Importmap pin path matches the emitted bundle under vendor/javascript/apps.
  • If changes don’t appear in dev, ensure the Bun --watch process is running from Procfile.dev.

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.