Rails

The Rails filter transforms idiomatic Rails code into JavaScript, enabling Rails applications to run in browsers and JavaScript runtimes. It handles models, controllers, routes, migrations, seeds, and logging.

Overview

The Rails filter enables a powerful workflow: write standard Rails code that transpiles to browser-ready JavaScript. The same Ruby source can run on the server (with PostgreSQL) and in the browser (with IndexedDB).

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/migrate/*.rb           →  dist/db/migrate/*.js

See the Ruby2JS on Rails blog post for a complete walkthrough.

Models

Transforms ActiveRecord model classes with associations, validations, callbacks, and scopes.

Associations

class Article < ApplicationRecord
  has_many :comments, dependent: :destroy
  belongs_to :author, optional: true
end
import ApplicationRecord from "./application_record.js";
import Comment from "./comment.js";
import Author from "./author.js";

export class Article extends ApplicationRecord {
  static table_name = "articles";

  get comments() {
    let _id = this.id;
    return Comment.where({article_id: _id})
  }

  get author() {
    return this._attributes["author_id"]
      ? Author.find(this._attributes["author_id"])
      : null
  }

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

Supported association options:

  • has_many:class_name, :foreign_key, :dependent
  • belongs_to:class_name, :foreign_key, :optional
  • has_one:class_name, :foreign_key

Validations

class Article < ApplicationRecord
  validates :title, presence: true
  validates :body, length: { minimum: 10 }
  validates :status, inclusion: { in: %w[draft published] }
end
export class Article extends ApplicationRecord {
  static _validations = {
    title: {presence: true},
    body: {length: {minimum: 10}},
    status: {inclusion: {in: ["draft", "published"]}}
  };
}

Supported validations: presence, length, format, inclusion, exclusion, numericality, uniqueness

Callbacks

class Article < ApplicationRecord
  before_save :normalize_title
  after_create :notify_subscribers

  private

  def normalize_title
    self.title = title.strip.titleize
  end
end
export class Article extends ApplicationRecord {
  before_save() {
    this.normalize_title()
  }

  after_create() {
    this.notify_subscribers()
  }

  normalize_title() {
    this.title = title.trim().titleize()
  }
}

Supported callbacks: before_validation, after_validation, before_save, after_save, before_create, after_create, before_update, after_update, before_destroy, after_destroy, after_commit, after_create_commit, after_update_commit, after_destroy_commit, after_save_commit

Turbo Streams Broadcasting

The broadcasts_to macro provides a declarative way to broadcast model changes to subscribed clients. It automatically generates after_create_commit, after_update_commit, and after_destroy_commit callbacks.

class Message < ApplicationRecord
  broadcasts_to -> { "chat_room" }
end
import { ApplicationRecord } from "./application_record.js";
import { BroadcastChannel } from "../../lib/rails.js";

export class Message extends ApplicationRecord {
};
Message.table_name = "messages";

Message.after_create_commit($record => (
  BroadcastChannel.broadcast("chat_room",
    `<turbo-stream action="append" target="${"messages"}">
      <template>${$record.toHTML()}</template>
    </turbo-stream>`)
));

Message.after_update_commit($record => (
  BroadcastChannel.broadcast("chat_room",
    `<turbo-stream action="replace" target="${`message_${$record.id}`}">
      <template>${$record.toHTML()}</template>
    </turbo-stream>`)
));

Message.after_destroy_commit($record => (
  BroadcastChannel.broadcast("chat_room",
    `<turbo-stream action="remove" target="${`message_${$record.id}`}">
    </turbo-stream>`)
));

Options:

Option Description Default
inserts_by: Insert position for new records: :append or :prepend :append
target: DOM element ID for append/prepend operations Pluralized model name

Examples:

# Prepend new messages (newest first)
broadcasts_to -> { "chat_room" }, inserts_by: :prepend

# Custom target element
broadcasts_to -> { "chat_room" }, target: "chat_messages"

# Dynamic stream name using record attributes
broadcasts_to -> { "article_#{article_id}_comments" }

Generated callbacks:

Callback Action Target
after_create_commit append or prepend (based on inserts_by:) Custom target or pluralized model name
after_update_commit replace dom_id of record (e.g., message_123)
after_destroy_commit remove dom_id of record

Scopes

class Article < ApplicationRecord
  scope :published, -> { where(status: 'published') }
  scope :recent, -> { order(created_at: :desc).limit(10) }
end
export class Article extends ApplicationRecord {
  static published() {
    return this.where({status: "published"})
  }

  static recent() {
    return this.order({created_at: "desc"}).limit(10)
  }
}

Controllers

Transforms Rails controllers to JavaScript modules with async action functions.

class ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :edit, :update, :destroy]

  def index
    @articles = Article.all
    render 'articles/index', articles: @articles
  end

  def show
    render 'articles/show', article: @article
  end

  def create
    @article = Article.new(article_params)
    if @article.save
      redirect_to @article
    else
      render 'articles/new', article: @article
    end
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end

  def article_params
    params.require(:article).permit(:title, :body)
  end
end
import Article from "../models/article.js";
import * as views from "../views/articles/index.js";

export const ArticlesController = {
  before_action: {
    set_article: ["show", "edit", "update", "destroy"]
  },

  async index() {
    let articles = await Article.all();
    return views.index({articles})
  },

  async show(id) {
    let article = await this.set_article(id);
    return views.show({article})
  },

  async create(params) {
    let article = new Article(params.article);
    if (await article.save()) {
      return {redirect_to: `/articles/${article.id}`}
    } else {
      return views.$new({article})
    }
  },

  async set_article(id) {
    return await Article.find(id)
  }
};

Key transformations:

  • Controller class → exported module object
  • Instance methods → async functions
  • @ivar assignments → local variables
  • params[:id] → function parameter
  • render → view function call
  • redirect_to → redirect object
  • new action → $new (reserved word)

Format Negotiation (respond_to)

Controllers can respond to multiple formats using respond_to:

class ArticlesController < ApplicationController
  def index
    @articles = Article.all

    respond_to do |format|
      format.html
      format.json { render json: @articles }
    end
  end
end
export const ArticlesController = {
  async index(context) {
    let articles = await Article.all();

    if (context.request.headers.accept?.includes("application/json")) {
      return {json: articles}
    } else {
      return ArticleViews.index({$context: context, articles})
    }
  }
};

Supported formats:

Format Accept Header Response
format.html text/html View render (default)
format.json application/json {json: data} wrapper
format.turbo_stream text/vnd.turbo-stream.html Turbo Stream actions

JSON responses are wrapped in {json: ...} for the runtime to handle serialization. For JSON-only endpoints:

respond_to do |format|
  format.json { render json: @articles }
end

This generates an Accept header check and returns the JSON wrapper directly.

Routes

Transforms config/routes.rb to a JavaScript router configuration.

Rails.application.routes.draw do
  root 'articles#index'

  resources :articles do
    resources :comments, only: [:create, :destroy]
  end
end
import { Router } from "./router.js";
import * as ArticlesController from "./controllers/articles_controller.js";
import * as CommentsController from "./controllers/comments_controller.js";

export const Routes = {
  routes: [
    {method: "GET", path: "/", action: ArticlesController.index},
    {method: "GET", path: "/articles", action: ArticlesController.index},
    {method: "GET", path: "/articles/new", action: ArticlesController.$new},
    {method: "POST", path: "/articles", action: ArticlesController.create},
    {method: "GET", path: "/articles/:id", action: ArticlesController.show},
    {method: "GET", path: "/articles/:id/edit", action: ArticlesController.edit},
    {method: "PATCH", path: "/articles/:id", action: ArticlesController.update},
    {method: "DELETE", path: "/articles/:id", action: ArticlesController.destroy},
    {method: "POST", path: "/articles/:article_id/comments", action: CommentsController.create},
    {method: "DELETE", path: "/articles/:article_id/comments/:id", action: CommentsController.destroy}
  ]
};

Supported route methods: root, resources, resource, get, post, patch, put, delete, namespace, scope

Supported options: :only, :except, :path, :as

Path Helpers with HTTP Methods

Path helpers are generated in a separate config/paths.js file. They return callable objects with HTTP methods:

// config/paths.js
import { createPathHelper } from 'ruby2js-rails/path_helper.mjs';

export function articles_path() {
  return createPathHelper('/articles');
}

export function article_path(article) {
  return createPathHelper(`/articles/${extract_id(article)}`);
}

Usage in views:

# GET request - params become query string
articles_path.get()                      # GET /articles.json
articles_path.get(page: 2)               # GET /articles.json?page=2

# POST request - params become JSON body
articles_path.post(article: { title: 'New' })  # POST /articles.json

# PATCH/DELETE requests
article_path(1).patch(article: { title: 'Updated' })  # PATCH /articles/1.json
article_path(1).delete                   # DELETE /articles/1.json

All methods return Response objects. Default format is JSON; use format: 'html' for HTML responses. CSRF tokens are included automatically for mutating requests.

See Path Helpers for complete documentation.

Migration

Transforms Rails migration files (db/migrate/*.rb) to JavaScript modules. Each migration becomes a module with an async up() function that creates or modifies database tables.

# db/migrate/20241231120000_create_articles.rb
class CreateArticles < ActiveRecord::Migration[7.1]
  def change
    create_table :articles do |t|
      t.string :title, null: false
      t.text :body
      t.string :status, default: "draft"
      t.timestamps
    end

    add_index :articles, :status
  end
end
import { createTable, addIndex } from "../../lib/active_record.mjs";

export const migration = {
  up: async () => {
    await createTable("articles", [
      {name: "id", type: "integer", primaryKey: true, autoIncrement: true},
      {name: "title", type: "string", null: false},
      {name: "body", type: "text"},
      {name: "status", type: "string", default: "draft"},
      {name: "created_at", type: "datetime"},
      {name: "updated_at", type: "datetime"}
    ]);
    await addIndex("articles", ["status"])
  },

  tableSchemas: {
    articles: "++id, title, body, status, created_at, updated_at"
  }
};

Migration Features:

  • Version tracking — Migrations are tracked in a schema_migrations table, ensuring each migration runs only once
  • Dexie support — The tableSchemas property provides IndexedDB schema strings for Dexie adapter
  • DDL functions — Uses abstract DDL functions (createTable, addIndex, addColumn, removeColumn, dropTable) that are implemented by each database adapter

Supported migration methods:

Ruby Method JavaScript Function
create_table :name createTable("name", columns)
add_index :table, :column addIndex("table", ["column"])
add_column :table, :col, :type addColumn("table", "col", type)
remove_column :table, :col removeColumn("table", "col")
drop_table :name dropTable("name")

Supported column types: string, text, integer, bigint, float, decimal, boolean, date, datetime, time, timestamp, binary, json, jsonb, references

Seeds

Transforms db/seeds.rb to a JavaScript module.

Article.create!(title: "Welcome", body: "Hello, world!")
Article.create!(title: "Getting Started", body: "Let's begin...")
import Article from "./models/article.js";

export async function run() {
  await Article.create({title: "Welcome", body: "Hello, world!"});
  await Article.create({title: "Getting Started", body: "Let's begin..."});
}

Helpers

Transforms Rails view helpers into HTML-generating JavaScript. Use with the ERB filter.

Form Helpers

# With Ruby2JS::Erubi for proper block handling
template = '<%= form_for @user do |f| %><%= f.text_field :name %><% end %>'
src = Ruby2JS::Erubi.new(template).src
Ruby2JS.convert(src, filters: [:"rails/helpers", :erb])
function render({ user }) {
  let _buf = "";
  _buf += "<form data-model=\"user\">";
  _buf += "<input type=\"text\" name=\"user[name]\" id=\"user_name\">";
  _buf += "</form>";
  return _buf
}

Form with URL:

form_with can specify an explicit URL instead of a model:

<%= form_with url: "/photos", method: :post, class: "my-form" do |f| %>
  <%= f.text_field :caption %>
  <%= f.submit "Save" %>
<% end %>
_buf += "<form class=\"my-form\" action=\"/photos\" method=\"post\">";

You can also use path helpers:

<%= form_with url: photos_path, method: :post do |f| %>
  <%= f.text_field :caption %>
<% end %>

HTTP method overrides (:patch, :delete) add a hidden _method field:

<%= form_with url: "/photos/1", method: :delete do |f| %>
  <%= f.submit "Delete" %>
<% end %>
_buf += "<form action=\"/photos/1\" method=\"post\">";
_buf += "<input type=\"hidden\" name=\"_method\" value=\"delete\">";

Form with class and data attributes:

Both form_for and form_with accept class: and data: options:

<%= form_with model: @article, class: "contents space-y-4", data: { turbo_frame: "modal" } do |form| %>
  <%= form.text_field :title, class: "input w-full" %>
  <%= form.submit "Save", class: "btn btn-primary" %>
<% end %>

Supported form builder methods:

Ruby Method HTML Output
f.text_field :name <input type="text" name="model[name]" id="model_name" value="${model.name ?? ''}">
f.email_field :email <input type="email" ... value="${model.email ?? ''}">
f.password_field :pass <input type="password" ... value="${model.pass ?? ''}">
f.hidden_field :id <input type="hidden" ... value="${model.id ?? ''}">
f.text_area :body <textarea name="model[body]" ...>${model.body ?? ''}</textarea>
f.check_box :active <input type="checkbox" value="1" ...>
f.radio_button :role, :admin <input type="radio" value="admin" ...>
f.label :name <label for="model_name">Name</label>
f.select :category <select name="model[category]" ...></select>
f.submit "Save" <input type="submit" value="Save">
f.button "Click" <button type="submit">Click</button>

Additional input types: number_field, tel_field, url_field, search_field, date_field, time_field, datetime_local_field, month_field, week_field, color_field, range_field.

HTML attributes on form fields:

Form builder methods accept standard HTML attributes:

<%= f.text_field :title, class: "input-lg", id: "article-title", placeholder: "Enter title" %>
<%= f.text_area :body, rows: 4, class: "w-full", required: true %>
<%= f.submit "Save", class: "btn btn-primary" %>
<%= f.label :title, class: "font-bold" %>
Attribute Example Description
class: class: "form-control" CSS classes
id: id: "custom-id" Custom element ID
style: style: "width: 100%" Inline styles
placeholder: placeholder: "Enter value" Placeholder text
required: required: true Required field
disabled: disabled: true Disabled field
readonly: readonly: true Read-only field
autofocus: autofocus: true Auto-focus on load
rows: rows: 4 Textarea rows
cols: cols: 40 Textarea columns
min:/max: min: 0, max: 100 Number field range
step: step: 0.01 Number field step

Conditional classes (Tailwind patterns):

For Tailwind CSS and similar frameworks, you can use array syntax with conditional hashes:

<%= f.text_field :title, class: ["input", "w-full", {"border-red-500": @article.errors[:title].any?}] %>

This generates a runtime expression that evaluates the condition:

`<input class="${"input w-full" + (article.errors.title.any() ? " border-red-500" : "")}" ...>`

Multiple conditions are supported:

<%= f.text_field :email, class: ["input", {"border-red-500": has_error, "opacity-50": disabled}] %>
erb_src = '_buf = ::String.new; _buf << link_to("Articles", "/articles").to_s; _buf.to_s'
Ruby2JS.convert(erb_src, filters: [:"rails/helpers", :erb])
function render() {
  let _buf = "";
  _buf += "<a href=\"/articles\" onclick=\"return navigate(event, '/articles')\">Articles</a>";
  return _buf
}

Link to model objects:

link_to accepts model objects and generates the appropriate path:

<%= link_to "Show", @article %>
<%= link_to "Show", article %>  <%# local variable from loop %>

Both generate: <a href="/articles/${article.id}" onclick="...">Show</a>

Link with class attribute:

<%= link_to "Show", @article, class: "btn btn-primary" %>
<%= link_to "Edit", edit_article_path(@article), class: "text-blue-500 hover:underline" %>

Conditional classes work the same as form fields:

<%= link_to "Edit", edit_path, class: ["btn", {"opacity-50": disabled}] %>

Button Helper

button_to generates delete buttons with confirmation dialogs:

<%= button_to "Delete", @article, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
// Browser target
_buf += `<form style="display:inline"><button type="button" onclick="if(confirm('Are you sure?')) { routes.article.delete(${article.id}) }">Delete</button></form>`;

// Server target
_buf += `<form method="post" action="/articles/${article.id}"><input type="hidden" name="_method" value="delete"><button type="submit" data-confirm="Are you sure?">Delete</button></form>`;

Button with class attributes:

<%= button_to "Delete", @article, method: :delete, class: "btn-danger text-white", form_class: "inline-block" %>
Option Description
class: CSS classes for the button element
form_class: CSS classes for the wrapping form element
data: { turbo_confirm: "..." } Confirmation dialog message

When form_class: is provided, the default style="display:inline" is omitted, allowing full control over form styling.

Truncate Helper

erb_src = '_buf = ::String.new; _buf << truncate(@body, length: 100).to_s; _buf.to_s'
Ruby2JS.convert(erb_src, filters: [:"rails/helpers", :erb])
function render({ body }) {
  let _buf = "";
  _buf += truncate(body, {length: 100});
  return _buf
}

Browser vs Server Target

The helpers filter detects the target environment based on the database option:

  • Browser databases (dexie, indexeddb, sqljs, pglite): Generate onclick/onsubmit handlers with JavaScript navigation
  • Server databases (better_sqlite3, pg, mysql2, d1): Generate standard href/action attributes
# Browser target (default)
Ruby2JS.convert(src, filters: [:"rails/helpers", :erb], database: 'dexie')
# => onclick="return navigate(event, '/articles')"

# Server target (Node.js)
Ruby2JS.convert(src, filters: [:"rails/helpers", :erb], database: 'better_sqlite3')
# => href="/articles"

# Server target (Cloudflare Workers)
Ruby2JS.convert(src, filters: [:"rails/helpers", :erb], database: 'd1')
# => href="/articles"

Validation Error Display

When a controller action fails validation and calls render :new or render :edit, the model with its validation errors is automatically passed to the view. Standard Rails error display patterns work:

<% if @article.errors && @article.errors.length > 0 %>
  <div class="errors">
    <ul>
      <% @article.errors.each do |error| %>
        <li><%= error %></li>
      <% end %>
    </ul>
  </div>
<% end %>

<%= form_for @article do |f| %>
  <%= f.text_field :title %>
  <%= f.text_area :body %>
  <%= f.submit %>
<% end %>

The controller remains idiomatic Rails:

def create
  @article = Article.new(article_params)
  if @article.save
    redirect_to @article
  else
    render :new  # Re-renders with @article.errors populated
  end
end

The transpiled controller returns the rendered view directly when validation fails, ensuring the model’s error state is preserved.

Logger

Maps Rails logger calls to console methods.

Rails.logger.debug "Processing request"
Rails.logger.info "User logged in"
Rails.logger.warn "Rate limit approaching"
Rails.logger.error "Failed to save"
console.debug("Processing request");
console.info("User logged in");
console.warn("Rate limit approaching");
console.error("Failed to save");

Runtime Requirements

The transpiled JavaScript requires runtime implementations of:

  • ApplicationRecord — Base model class with find, where, create, save, destroy, etc.
  • ApplicationController — Base controller with routing integration
  • Router — URL matching and History API integration

These are provided by the Ruby2JS on Rails demo runtime, which uses:

Component Browser Server (Node/Bun/Deno) Edge (Cloudflare)
Database Dexie, sql.js, PGLite better-sqlite3, pg, mysql2 D1
Router History API HTTP server Fetch handler
Renderer DOM manipulation HTML string HTML string

Usage with Other Filters

The Rails filter works best with these companion filters:

Ruby2JS.convert(source, filters: [:rails, :esm, :functions, :active_support])
Filter Purpose
esm ES module imports/exports
functions Ruby → JS method mappings (.eachfor...of, etc.)
active_support blank?, present?, try, etc.
erb ERB templates → render functions
camelCase Convert snake_case identifiers

Limitations

The goal is enabling offline-first applications and static deployment, not replacing Rails entirely.