Ruby2JS on Rails
Quick Start
No Ruby installation required. Node.js 22+ is all you need.
curl -L https://www.ruby2js.com/demo/ruby2js-on-rails.tar.gz | tar xz
cd ruby2js-on-rails
npm install
bin/dev
Open http://localhost:3000. The source is Ruby. The runtime is JavaScript.
How This Started
I needed offline capabilities for a Rails application—a scoring system for dance competitions where judges work on tablets and hotel WiFi is unreliable. The solution needed IndexedDB for offline storage, and I wanted to reuse my existing ERB templates.
The first attempt used Web Components. It worked, but 4,000 lines of JavaScript felt excessive. I tried a simpler approach with Turbo navigation interception—2,600 lines, cleaner architecture. But both approaches had the same problem: I was maintaining templates in two places. ERB on the server, JavaScript template strings on the client. They kept drifting apart.
Then it clicked: what if ERB templates were automatically converted to JavaScript? Ruby 3.4's Prism parser could handle the Ruby expressions embedded in templates. I built a converter, and suddenly I had a single source of truth—1,370 lines, guaranteed parity.
But the converter was custom code, buried in one application. It deserved tests, documentation, a community. That led me back to Ruby2JS—a project I'd started over a decade ago but hadn't touched in years. The project needed work: broken demos, outdated dependencies, stale issues.
So I started fixing things. Added Prism support. Built a self-hosting capability where Ruby2JS transpiles itself to JavaScript. Created an ERB filter. And somewhere along the way, I realized: if the converter can handle ERB templates, maybe it can handle models and controllers too?
That's how this demo happened. I wanted to stress-test the transpiler with something demanding. The Rails Getting Started blog tutorial seemed like a good target. I figured I'd get partway and hit blockers.
I was surprised how far it got.
Why Transpile?
When building browser applications, the requirement is rarely "run Ruby in the browser" — it's something concrete like "work offline," "reduce server load," or "enable real-time interaction." Ruby happens to be how your backend is written.
Opal and Ruby WASM are good tools. They bring Ruby to the browser faithfully — full semantics, metaprogramming, eval, access to gems. If that's what you need, use them. But they treat JavaScript as a target to sit on top of or replace entirely.
Ruby2JS takes a different path: it works with JavaScript rather than replacing it. The output is native JS — it uses import to access npm packages, async/await for asynchronous operations, interacts directly with IndexedDB and Service Workers, integrates with Hotwire or any browser framework, and can be packaged as a mobile PWA.
The tradeoff is real: no metaprogramming, no method_missing, no runtime code generation. But I've found that views and business logic rarely need these. What they need is to run fast, work offline, and share structure with server code.
This demo shows Rails patterns — models, controllers, routes, ERB templates — transpiled to JavaScript and running in your browser. The app/ directory looks like Rails. The runtime is JavaScript.
This is early work. Rough edges remain. But the core works, and I wanted to share it.
What You Get
The workflow will feel familiar if you've used Vite or similar tools:
- Development:
bin/devstarts a hot-reload server. Edit a Ruby file, save, and the browser refreshes. - Production:
npm run buildgenerates static assets. Deploy to nginx, S3, Cloudflare Pages — anywhere that serves files.
The source is idiomatic Rails:
class Article < ApplicationRecord
has_many :comments, dependent: :destroy
validates :title, presence: true
end
The output is idiomatic ESM JavaScript:
class Article extends ApplicationRecord {
static _associations = { hasMany: [["comments", { dependent: "destroy" }]] };
static _validations = { title: { presence: true } };
}
Browse the app/ directory. It's a standard Rails blog:
app/
├── models/
│ ├── application_record.rb
│ ├── article.rb
│ └── comment.rb
├── controllers/
│ ├── application_controller.rb
│ ├── articles_controller.rb
│ └── comments_controller.rb
└── views/
├── layouts/
│ └── application.html.erb
└── articles/
├── index.html.erb
├── list.html.erb
├── show.html.erb
├── new.html.erb
└── edit.html.erb
The models use has_many, belongs_to, and validates. The controllers use before_action, params, and redirect_to. The views are ERB with <%= %> and <% %>. Routes use resources with nested routes.
If you know Rails, you already know how to read this code.
Developer Experience
Open your browser's developer tools:
Console: Rails-style logging appears as you save records:
Article Create {title: "Hello", body: "World", created_at: "..."}
Article Update {id: 1, title: "Hello", body: "Updated", updated_at: "..."}
The detailed object output uses console.debug — enable "Verbose" in DevTools to see it.
Sources: Your Ruby files appear in the source tree — app/models/article.rb, app/controllers/articles_controller.rb. You can set breakpoints on Ruby lines, step through, inspect variables. The sourcemaps connect the running JavaScript back to the Ruby you wrote.
This works surprisingly well. You're debugging the code you wrote, not the code it became.
How It Works
The transpilation happens at build time (production) or on file change (development):
app/models/article.rb → dist/models/article.js
app/controllers/... → dist/controllers/...
app/views/...html.erb → dist/views/...js
config/routes.rb → dist/routes.js
db/schema.rb → dist/schema.js
Ruby2JS filters handle Rails-specific patterns:
| Filter | Transforms |
|---|---|
rails/model |
has_many, belongs_to, validates, callbacks |
rails/controller |
before_action, params, redirect_to, render |
rails/routes |
resources, root, nested routes, path helpers |
rails/schema |
create_table, column types, foreign keys |
erb |
ERB templates to JavaScript render functions |
The JavaScript runtime provides ApplicationRecord backed by Dexie (IndexedDB), ApplicationController with History API routing and DOM rendering, and the glue that makes it feel like Rails.
What's Different
This isn't Rails running in the browser — it's Rails patterns transpiled to JavaScript. Some things work differently:
Database: Dexie wraps IndexedDB, providing persistent client-side storage. Your data survives page reloads and browser restarts. The API mimics ActiveRecord — Article.all, Article.find(id), article.save — but underneath it's IndexedDB, not PostgreSQL.
I initially reached for sql.js (SQLite compiled to WebAssembly) because SQL felt familiar. But Dexie turned out to be the better choice: ~50KB vs ~2.7MB, persistent by default, and works with IndexedDB rather than replacing it. Sometimes the obvious path isn't the right one.
No Server: There's no request/response cycle. Controllers handle client-side navigation via the History API. Forms submit to JavaScript methods, not HTTP endpoints. The browser is both client and server.
Templates: ERB converts to JavaScript functions. Rails helpers (link_to, form_with) become JavaScript equivalents.
Limitations: Action Mailer won't work — browsers can't send SMTP. Action Cable needs a server. But for CRUD apps with offline requirements, this architecture handles the core use cases.
Most of the Rails Getting Started tutorial can be made to work. The goal is feature parity with the Rails Getting Started guide.
Try It
Run the Quick Start commands above, then experiment:
Edit app/views/articles/index.html.erb. Add a paragraph. Save. Watch the browser refresh.
Edit app/models/article.rb. Add a validation. Save. Watch the browser refresh. Try to create an article that violates your validation. Check the console log.
That's the workflow — edit Ruby, execute JavaScript.
Review the sources produced in the dist directory. Idiomatic JavaScript with no dependencies beyond what Node.js and the browser provide.
One More Thing
Up to this point everything ran in your browser. But the same Ruby source — the exact same app/ directory — can also run as a server.
bin/rails server -e production
The transpiler targets multiple runtimes. A config/database.yml file selects the database adapter:
| Database | Runtime | Storage |
|---|---|---|
| Dexie | Browser | IndexedDB |
| sql.js | Browser | SQLite in WebAssembly (in-memory) |
| better-sqlite3 | Server | SQLite file |
| pg | Server | PostgreSQL |
Same models. Same controllers. Same routes. Same ERB templates. Different runtime, different database, same Rails patterns.
bin/rails server --runtime node # Node.js server on port 3000
bin/rails server --runtime bun # Bun server on port 3000
bin/rails server --runtime deno # Deno server on port 3000
The server targets use native HTTP servers — http.createServer for Node.js, Bun.serve for Bun, Deno.serve for Deno. No Express, no framework overhead. Controllers receive parsed requests and return responses. The routing layer maps URLs to controller actions exactly like the browser version maps History API navigation.
This means you can:
- Develop in browser — instant feedback, no server restart
- Deploy to Node.js — connect to PostgreSQL, send email with nodemailer, access the filesystem
- Run on Bun — faster cold starts, native TypeScript if you want it
- Run on Deno — secure by default, built-in TypeScript, edge deployment ready
The browser demo is the simple story. But underneath, this is a portable MVC runtime.
The Bigger Picture
For Ruby2JS: This demo exists to stress-test the transpiler. Every bug I find here gets fixed. Every pattern that doesn't convert cleanly becomes a new filter or improvement. The showcase application that started this journey will eventually use these same filters — tested, documented, maintained as open source.
For everyone else: Even if all we ever achieve is 80% Rails compatibility, transpiled Rails patterns could be useful in places Ruby can't go. Unlike Opal (~5MB) or Ruby WASM (larger still), Ruby2JS produces compact, native JavaScript:
- Offline-first apps — Share validation logic and views between server and browser. Single source of truth, with a bundle small enough for fast initial loads on mobile.
- Static deployment — Deploy to GitHub Pages, Netlify, S3. Native JavaScript means direct access to IndexedDB, Web Crypto, and other browser APIs—no interop layers between your code and the platform.
- Edge computing — Run MVC patterns on Cloudflare Workers or Vercel Edge. Strict bundle size limits and cold start requirements rule out heavier runtimes.
- Service Workers — Offline caching, background sync, push notifications. Service Workers need small, self-contained JavaScript.
- npm ecosystem — Native
importgives direct access to npm packages: TensorFlow.js for on-device ML, Puppeteer for headless browsers, LangChain for LLM orchestration. - Embedded widgets — Distribute Rails-patterned functionality as a
<script>tag. Bundle size and native JS interop matter. - Auditable output — Readable JavaScript that security teams can review. Works in strict CSP environments without
eval().
Rails patterns are valuable because they're well-designed: MVC structure, convention over configuration, sensible defaults. Twenty years of refinement by DHH and the Rails core team produced something worth porting. You don't need 100% compatibility to benefit from that structure. If models, controllers, routes, and ERB templates work, that's a lot of Rails thinking that becomes portable.
Ruby2JS joins a broader movement of frameworks that compile away complexity: Svelte compiles components to minimal JS, SolidJS compiles reactivity to direct DOM updates, and AdonisJS proves Rails patterns work natively in Node.js. Ruby2JS contributes a unique angle: your existing Ruby knowledge becomes portable JavaScript.
If you're curious about the journey that led here, I've written about:
- Offline-first scoring with Web Components — the first attempt
- Simpler offline scoring with Turbo MVC — the second attempt
- ERB-to-JavaScript conversion — the breakthrough
- Ruby2JS gets Prism support — enabling the vision
- Three paths to Ruby2JS in the browser — comparing approaches
- The Ruby2JS story — coming home to a project after a decade
Roadmap
This demo currently implements the core blog tutorial:
- [x] Article CRUD (create, read, update, delete)
- [x] Comments with associations
- [x] Validations with error display
- [x] Nested resources and routing
- [x] ERB templates
- [x] Rails-style logging
- [x] Sourcemaps for debugging
Planned additions following the Rails Getting Started guide:
- [ ] Flash messages
- [ ] Basic authentication
- [ ] Callbacks (
before_save,after_create, etc.) - [ ] Concerns (shared model/controller code)
- [ ] More validations (length, format, etc.)
Additional Rails features:
- [ ] Active Storage —
has_one_attachedbacked by IndexedDB blobs - [ ] Action Text — rich text editing via Trix (npm package)
- [ ] Background jobs —
perform_latervia setTimeout/Promise (JavaScript's event-driven model makes this simple) - [ ] Database migrations — run
ALTER TABLEon schema version change - [ ] I18n — translation lookup with
t()helper
Future possibilities:
- [ ] Hotwire integration — Turbo Drive, Turbo Frames, Turbo Streams, Stimulus. These are npm packages (
@hotwired/turbo,@hotwired/stimulus), so they should work naturally with the transpiled output. - [ ] HTMX integration — hypermedia-driven interactions without client-side state management
- [ ] Phlex templates as alternative to ERB
- [ ] Offline-first with Service Worker
- [ ] Multi-tab sync via BroadcastChannel
- [ ] Tauri packaging — lightweight desktop apps with native performance
Developer experience (inspired by Ember.js):
- [ ] CLI with generators —
ruby2js new my-app,ruby2js generate model/controller/scaffold - [ ] Remote adapters — REST and JSON:API adapters for server-backed models, complementing local storage adapters
- [ ] Testing — transpile RSpec/Minitest specs to browser-runnable JavaScript tests
- [ ] Tracked properties —
tracked :title, :bodyfor fine-grained reactivity without manual DOM updates
Database infrastructure:
- [ ] Kysely adapter — unified SQL backend with connection pooling, transactions, and type-safe queries. One adapter supporting PostgreSQL, MySQL, SQLite, and MSSQL.
Rough Edges
This is working but not polished. Some things I know need attention:
- Error messages from the transpiler could be clearer
- Not all Rails helpers are implemented yet
- Not all filters work when self-hosted. More filters need to be added.
- The runtime API isn't documented
- Edge cases in ERB conversion exist
If you hit something broken, file an issue. Contributions welcome.
Learn More
- Ruby2JS Documentation
- Rails Getting Started Guide (the original we're transpiling)
- Source on GitHub
- Comparing Approaches: Opal, Ruby WASM, Ruby2JS
Ruby2JS is open source: github.com/ruby2js/ruby2js