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,:dependentbelongs_to—:class_name,:foreign_key,:optionalhas_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
@ivarassignments → local variablesparams[:id]→ function parameterrender→ view function callredirect_to→ redirect objectnewaction →$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_migrationstable, ensuring each migration runs only once - Dexie support — The
tableSchemasproperty 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}] %>
Link Helper
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/onsubmithandlers with JavaScript navigation - Server databases (better_sqlite3, pg, mysql2, d1): Generate standard
href/actionattributes
# 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 (.each → for...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.