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)fromindex.jsxthat renders the top-level component withcreateRoot(el).render(<App />). - Top component:
frontend/<AppName>/<AppName>.jsxexports 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) toapps/<AppName>/index.jsinconfig/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 inapplication.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)
- Create
frontend/<AppName>/{index.jsx,<AppName>.jsx}exportingmount(el). - Add the folder to
package.jsondevandbuildscripts. - Pin in
config/importmap.rbasapps/<alias>→apps/<AppName>/index.js. - Import in
app/javascript/application.jsand mount on a unique element id. - Add the mount container to the relevant
.erbtemplate.
Troubleshooting tips
- If the app doesn’t mount, confirm the element id matches and that
turbo:loadis 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
--watchprocess is running fromProcfile.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.