intertwingly

It’s just data

The Case for Ruby2JS on Rails


Disclaimer: work in progress. New functions may only be lightly tested. What worked yesterday may be broken today. Recent work has been a product of vibe engineering.

Over 2,500 tests—including self-hosting: transpiling the converter and 15 out of 28 filters to JavaScript, running the same specs against both Ruby and JS versions, and verifying identical output on the demo app.

What's New

Last week's demo was hand-crafted: carefully selected Rails patterns, manually tuned to work with the transpiler. It proved the concept, but left a question: could this work with real Rails code?

Now it does.

Full scaffold transpilation. Run rails generate scaffold Article title:string body:text, and the generated models, controllers, and views transpile to JavaScript. Not a subset. Not hand-tuned. The actual scaffold output.

# From test/spa-ideal/Dockerfile
FROM ruby:3.4 AS builder
WORKDIR /app

# Create Rails app with scaffold
RUN gem install rails && rails new blog --skip-git --skip-docker
WORKDIR /app/blog

RUN bundle add ruby2js --github ruby2js/ruby2js --require ruby2js/spa
RUN rails generate scaffold Article title:string body:text && rails db:migrate

# Set root route to articles#index
RUN sed -i 's/# root "posts#index"/root "articles#index"/' config/routes.rb

# Build the SPA
RUN rails generate ruby2js:spa:install && rails ruby2js:spa:build

# Stage 2: Run with Node only (no Ruby)
FROM node:22-slim
COPY --from=builder /app/blog/public/spa/blog /spa
RUN npm install && npm run build
CMD ["npx", "serve", "-s", "-p", "3000"]

Ruby generates the scaffold. JavaScript runs it. No Ruby runtime in production.

Cloudflare D1 support. The same transpiled code now targets Cloudflare Workers with D1 (SQLite at the edge):

# config/database.yml
edge:
  adapter: d1
  binding: DB

One config change. Same models, same controllers, same views. Your Rails patterns running on Cloudflare's global network.

Continuous Ruby development. This isn't just a one-time export. You can maintain Ruby source, and transpile it on every change:

bin/dev  # Start hot-reload server

Edit app/models/article.rb. Save. Browser refreshes with the transpiled JavaScript. The same Ruby source can run on Rails (with PostgreSQL) or transpile to JavaScript (with IndexedDB, D1, or any supported adapter). Develop in Ruby. Deploy to JavaScript. Or deploy to both.

Sourcemaps and logging. When something breaks, you debug Ruby—not generated JavaScript. Your Ruby files appear in browser DevTools (app/models/article.rb, app/controllers/articles_controller.rb). Set breakpoints on Ruby lines. Step through Ruby code. Inspect variables with Ruby names. The sourcemaps connect running JavaScript back to the code you wrote.

Rails-style logging appears in the console as you work:

Article Create {title: "Hello", body: "World", created_at: "..."}
Article Update {id: 1, title: "Updated", updated_at: "..."}

Enable "Verbose" in DevTools to see detailed output. The developer experience mirrors Rails—you just happen to be running JavaScript.


The Full Adapter Matrix

Seven database adapters, three runtime categories:

Adapter Runtime Storage Use Case
dexie Browser IndexedDB Offline-first PWAs
sqljs Browser SQLite/WASM Full SQL in browser
pglite Browser PostgreSQL/WASM PG compatibility client-side
better_sqlite3 Node/Bun/Deno SQLite file Server-side, fast
pg Node/Bun/Deno PostgreSQL Production servers
mysql2 Node/Bun/Deno MySQL Enterprise servers
d1 Cloudflare SQLite/edge Global edge deployment

Same Ruby source. Different database.yml. Deploy anywhere JavaScript runs.


Why This Matters

Rails is built on metaprogramming. Write has_many :comments and Rails generates methods at runtime. Write validates :title, presence: true and validation logic weaves into callbacks. Powerful, but with trade-offs: debugging is archaeology, IDEs can't autocomplete generated methods, the code that runs isn't the code you wrote.

Ruby2JS inverts this. Instead of runtime method generation, it pre-computes what Rails would generate:

# You write:
class Article < ApplicationRecord
  has_many :comments, dependent: :destroy
  validates :title, presence: true
end
// Transpiled output:
export class Article extends ApplicationRecord {
  static table_name = "articles";

  get comments() {
    let _id = this.id;
    return {
      create(params) {
        return Comment.create(Object.assign({article_id: _id}, params))
      },
      then(resolve, reject) {
        return Comment.where({article_id: _id}).then(resolve, reject)
      }
    }
  };

  async destroy() {
    for (let record of await(this.comments)) {
      await record.destroy()
    };
    return super.destroy()
  };

  validate() {
    this.validates_presence_of("title")
  }
}

Idiomatic Rails source. Idiomatic JavaScript output with ESM, async/await, and object literals with function properties.

The magic gets materialized. Every method has a source location. IDEs autocomplete. Static analysis works. The transparency that Rails traded away comes back—without sacrificing the expressive DSLs.

This works because Rails applications rarely use raw metaprogramming in application code. The metaprogramming lives in the framework. Your models use has_many, not define_method. Your controllers use before_action, not method_missing. These DSLs are finite and declarative—filters can recognize and expand them at compile-time.


What You Give Up

No arbitrary metaprogramming in application code. If your model needs method_missing, you'll need a custom filter or restructured code. In practice, scaffold-generated code doesn't use these patterns.

Type hints via pragmas. Ruby2JS doesn't do full type inference. When ambiguous, you provide hints:

items << value  # Pragma: set
# => items.add(value) instead of items.push(value)

options ||= {}  # Pragma: ??
# => options ??= {} (nullish) instead of options ||= {} (logical)

Comments, not new syntax. Rare in practice. items.add(value) works equally well with Ruby and JavaScript sets.

Not full Rails. Action Mailer (browsers can't SMTP), Active Job and Action Cable and others aren't implemented yet. The current focus is offline-first SPAs and edge deployment, not replacing Rails entirely.


What's Next

The scaffold test proves transpilation works. The next steps integrate with existing Rails applications:

Rack middleware. Mount the SPA within Rails at a path like /offline. Same app serves traditional pages and offline-capable SPA.

Turbo interception. Intercept turbo:before-fetch-request. If online, fetch from server. If offline, render locally from IndexedDB.

Sync on reconnection. Queue changes in IndexedDB. Upload pending changes when connectivity returns. Download server updates since last sync.

A judge at a dance competition works on a tablet with unreliable hotel WiFi. They view heats and enter scores regardless of connectivity. When online, scores sync. No visible difference from the online experience—except it works without WiFi.


See Also


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