intertwingly

It’s just data

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:

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:

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:

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:


Roadmap

This demo currently implements the core blog tutorial:

Planned additions following the Rails Getting Started guide:

Additional Rails features:

Future possibilities:

Developer experience (inspired by Ember.js):

Database infrastructure:


Rough Edges

This is working but not polished. Some things I know need attention:

If you hit something broken, file an issue. Contributions welcome.


Learn More


Ruby2JS is open source: github.com/ruby2js/ruby2js