Elements vs Ruby on Rails

Markdown

Ruby on Rails is an open-source web app framework written in Ruby. David Heinemeier Hansson created it and first released it in 2004, and the Rails core team maintains it. Rails is free. Its community page credits its progress to "the work of thousands of volunteers" (community), and the Rails Foundation, funded by member companies, works on its documentation, education, marketing and events (foundation). Rails is organized around convention over configuration: it decides where code goes and how the pieces connect. It ships libraries for each part of an app: Active Record for database models and migrations, Action Controller and Action View for handling requests and rendering ERB templates, Hotwire (Turbo and Stimulus) for interactive pages, Action Cable for realtime updates, Active Job for background work, Action Mailer for email, and Active Storage for file uploads. New Rails 8 apps come configured to deploy with Kamal, which runs the app in Docker containers on servers you control.

Elements is an integrated app environment for building web apps, built for people and their agents: a project server that runs while you work, a package installer, a test runner, a bundled Postgres database with migrations, and a deploy command for any Ubuntu server you reach over SSH. It ships with a default framework that just works, with routing, server rendering, typed server functions, sessions, file uploads, realtime data, background jobs and email. Its pages are written in Elements HTML: standard HTML with reactive expressions. Elements apps are written in TypeScript and run on Node.js. The project server holds the build graph and build state in memory, so it builds in milliseconds and answers agents and humans in microseconds.

Elements is ready the moment you create a project. A new Rails 8.1 app is assembled from 23 gems before you write a line (Gemfile template): Puma to serve it, Propshaft for assets, importmap for browser code, Turbo and Stimulus for interactivity, Solid Queue, Solid Cache and Solid Cable for jobs, caching and realtime, Kamal and Thruster to deploy, and Capybara and Selenium to test. Each is its own project with its own configuration, commands and docs, and every seam between them is a place where a person or an agent gets stuck. Over the parts sit conventions the agent has to remember, and nothing checks the agent's guesses until that line runs. Elements is one coherent system and stays close to the metal: HTML, CSS, TypeScript, SQL and function calls, with a framework vocabulary of app.route(), session.login(), sql(), @rpc and tests. One build reports every error together, type checking, tests, migrations and program analysis, so each mistake reaches you or your agent with a file and a line moments after you save.

Elements Ruby on Rails 8.1
Business model Sells the tooling, per machine, with nothing metered (pricing); the app framework is MIT licensed (licenses) Free; credited to volunteers, with a foundation funded by member companies for docs, education, marketing and events (foundation)
What a new app is made of One coherent system: project server, default framework, Postgres, tests and deploy 23 gems in the generated Gemfile, each its own project
Server and browser logic TypeScript for both Ruby on the server, JavaScript in the browser
Build errors One build reports type checking, tests, migrations and program analysis, as you work No type checking by default; tests and migrations are separate commands
What runs in development A project server that runs while you work, builds in milliseconds and serves the app bin/rails server starts the app
Default data access Plain SQL through sql(), safe from SQL injection by construction Active Record ORM
Migrations SQL files, applied when you save one Ruby migration files, applied with bin/rails db:migrate
Server call from the browser A typed function call (@rpc), checked at build time A url, parameters and JSON, checked when the request runs
Interactive UI One reactive html language, server rendered and live in the browser ERB with Turbo, plus Stimulus controllers in JavaScript
Live updates Changes appear at once for the user who made them and in every open browser Turbo broadcasts: after a model commits, the server renders HTML and sends it to open pages
Sessions One API for route handlers, server calls and templates; the page updates on sign-in and sign-out session in controllers; sign-in generated by bin/rails generate authentication
Background jobs (production default) Saved in your transaction, so a job commits with your data or not at all Solid Queue, in a separate database; by default a job still runs if your transaction rolls back
Tests Part of the build, and a gate on every deploy bin/rails test, bin/ci, or a CI workflow after you push
Deploy Peer to peer over SSH, changed files only, often under a second after the first deploy Kamal builds a Docker image, pushes it to a registry, pulls it on each server
Automatic TLS Let's Encrypt for every domain, on every machine Let's Encrypt on a single server

Rails Runs Your App. Elements Also Runs Your Build.

While you work on a Rails app, bin/rails server starts Puma, the Rails web server, which serves your pages and reloads changed code on the next request. When a migration is pending, Rails tells you, and you run it yourself. Nothing checks the code you saved, runs the tests it affects, or applies a migration when you save one: migrating, testing and deploying are separate commands, each started by hand. Choose one of the four bundlers rails new offers in place of import maps (app generator), and development becomes two processes, the server and a build watcher, started together by bin/dev (jsbundling-rails). The development server also holds on to memory: "ActionView leaks memory in dev" has been open since January 2019, with 56 reactions.

Elements runs a project server: a server that runs while you work. It holds the build graph, meaning every file and what depends on it, and the build state in memory. On every save, the build state is updated almost instantly, because the server redoes only what the change touched. A single-file save often builds and hot reloads in a few milliseconds: the new code is swapped into the running app without a restart (build). A build in Elements gets the app ready for release, and it reports every type error, failing test and failed migration.

Rails went the other way on builds: a new app has no JavaScript build step, and import maps load browser code without bundling or Node.js. Elements keeps the build, because a build that finishes in milliseconds is where mistakes are caught, with a file and a line.

For an Agent, the Value Is in Correction

Rails now pitches convention to AI coding agents: its home page says "Accelerate your agents with convention over configuration" and promises "token-efficient code that's easy for agents to write." For an agent, the value is not in conventions and endless framework abstractions, or in a gem for each job. It is in correction: finding out what it got wrong the moment it gets it wrong.

Rails gives an agent little of that. A convention is a rule the agent has to remember, and nothing enforces it. Ruby has no type checker by default, so a misremembered method name, instance variable or file location surfaces only when that line runs, if a test happens to reach it. Gems add to what the agent has to remember, starting with the 23 a new app is generated with. A gem is a shortcut when it does something the agent cannot write quickly from standards, and that list is short: an agent would rather write HTML and CSS directly than learn a view component gem, it does not need a dependency for what a few lines of code do, and every gem past that list brings its own configuration, its own API to look up and one more seam to get wrong.

On a line from an agent writing machine code, which nobody does, to a package that does subtraction, the right amount of framework sits at the plumbing every app needs. Elements ships that plumbing: routing, typed server calls with @rpc, sessions, sql() and the database driver. Past that plumbing comes a point of diminishing returns, where each further abstraction costs an agent more to learn and get right than it saves. So Elements stops there, keeps abstractions to a minimum and puts its engineering into correction. A page is HTML and CSS, a server call is a TypeScript function marked @rpc, and data goes through SQL in sql(). The vocabulary on top is app.route(), session.login(), sql(), @rpc and tests. elements create scaffolds pages, templates, migrations, jobs and email the way Rails generators do, and what an agent writes into them is plain TypeScript, HTML, CSS and SQL (cli).

One coherent system checks every page, function, test and migration the agent writes. An agent runs elements build -json and gets its answer in microseconds, because the project server already holds the build state (build). A failing test, a broken migration, a template reading a field that does not exist or a wrong argument type comes back as a diagnostic: an error message with a file and a line, reported before anything runs.

The same server lets several agents work at once. Your terminal, your editor and any number of agents connect to the one project server, so they see the same build state, and a single build loop orders their changes so two agents never build over each other.

One Language for Server and Browser, Checked End to End

A Rails app uses Ruby for models and controllers, ERB for views and JavaScript for browser behavior (Getting Started). Nothing checks them together by default. An instance variable the controller never set is nil in the view, so the page renders blank or raises, and only when someone visits it.

An Elements app uses TypeScript for server and browser code alike, and the project server checks every part of it in one build: type checking, tests, migrations and program analysis (build). Templates are written in Elements HTML and are part of the same build. A template declares typed attributes the way a function declares parameters, so a page that passes a template the wrong value is a build error, and so is a typo such as {comment.txet}.

Server Calls Are Typed Function Calls

Every web app has a boundary between code that runs on the server, where the database and secrets live, and code that runs in the browser. In Rails, each interaction crosses that boundary by hand: a route, a controller action, strong parameters to filter the input, and a form or JavaScript that calls a url. Path helpers catch a wrong route name when the line runs, but nothing checks at build time that the url, the parameters and the response agree. A renamed parameter is dropped without an error by strong parameters, and a changed JSON shape breaks the browser code that reads it. Both show up only at runtime.

Elements crosses the boundary with @rpc functions. An @rpc is a server function, marked with an @rpc tag in its doc comment, that the browser calls directly with typed parameters and a typed return value (rpc). You call it from an event handler like any other function. The compiler turns the browser call into a network request and keeps the function body, and everything it depends on, out of the browser bundle.

Because server and browser code can sit side by side in one file, the compiler also guards the boundary. Calling server-only code such as sql, tx, session.login or email from code that can run in the browser is a compile error at the call site, so a query or a secret cannot reach the browser by accident:

<button onclick={() => sql(`delete from users`)}>delete all</button>
<!-- build error: "Security error: cannot call server code from the
     browser without going through an rpc function." -->

<button onclick={() => deleteAllUsers()}>delete all</button>
<!-- an @rpc function, which checks the caller and runs on the server -->

File uploads go through the same typed call. Rails handles uploads with Active Storage, which attaches files to models and stores them on disk or with a cloud storage service. In Elements, files are a File[] field on the form object an @rpc receives: bind <input type="file" value={files}> (file upload). There is no separate upload endpoint to wire up, and the files arrive typed alongside the rest of the form. What you do with them next is ordinary server code: the database assets recipe keeps them in Postgres, and any npm storage client works in the same @rpc.

SQL Is the Default, and a Migration Applies When You Save It

A Rails app reaches its database through Active Record, an ORM that maps tables to Ruby classes. It is a layer to learn on top of SQL: a query interface of its own, associations, callbacks and validations that run in Ruby rather than in the database (querying, validations). The layer leaks. "accepts_nested_attributes_for breaks the uniqueness with scope validation", a uniqueness check that lets duplicates through when records are saved together, has been open since June 2015, with 67 reactions and 76 comments.

Elements makes SQL the default, because SQL is the query language every agent and every database tool already speaks. sql() takes a plain SQL template, and the build turns every ${value} in it into a separate query parameter at compile time, so an interpolated value cannot inject SQL (database/sql).

Migrations are SQL too. Rails migrations are Ruby files applied by running bin/rails db:migrate, each in its own transaction. An Elements migration is a SQL file, and saving it applies it (migrations). Edit an applied migration in development and Elements rolls it back and applies the new version. Pending migrations run together in one Postgres transaction, against the test database first, so a batch applies in full or not at all.

One Reactive HTML Language, Rendered on the Server and Live in the Browser

Both frameworks render pages on the server and then make them interactive in the browser. Hotwire cuts the custom JavaScript a Rails screen needs by sending HTML from the server: Turbo swaps in new pages and page fragments, and Turbo Streams append, replace or remove elements by id. Each interaction is still a round trip that renders HTML on the server. Anything that should happen locally, such as an input that filters a list as you type, needs a Stimulus controller written in JavaScript, so one screen is split across a Ruby controller, an ERB view and a JavaScript file, joined by data- attribute names that nothing checks. The seams show in Hotwire's own trackers. "Turbo frame history not navigating properly w/ back button after upgrading from 7 to 8" has been open since April 2024, with 25 reactions and 31 comments, and "Turbo new install - forms do not redirect" has been open since February 2021, with 76 comments.

In Elements, the same template runs on the server and in the browser. Elements HTML is standard HTML plus a few attributes, such as e:if, e:for, e:switch and group, and reactive expressions in braces (html). A page renders fully on the server, so search engines and first-time visitors get the complete page. The browser then picks up the server-rendered page and makes it interactive, and when data changes, the runtime updates only the elements that depend on it. Local interactions run locally: an input bound to a field updates the field as the user types, and the field updates the input, with no extra code. Event handlers are TypeScript in the same file as the markup, and they call @rpc functions directly.

Live Updates Paint Instantly and Stay in Sync

Live updates push changes to browsers that are already open, such as a new comment or an edited row. Rails does this with Turbo broadcasts: a model declares broadcasts_to or broadcasts_refreshes, and after each commit Rails renders HTML in a background job and sends it over Action Cable, or tells each browser to refresh the page. In production, a new Rails 8 app carries those messages through Solid Cable, which stores them in a database table and polls it for new ones. Every update, including the user's own, waits for a round trip to the server before it appears.

Elements builds live updates on Postgres notifications (LISTEN/NOTIFY): every app server listens and forwards each message to its connected browsers as it arrives (channel). On top of that sits LiveTable, a live, ordered set of typed rows that the page reads and writes directly (livetable). A LiveTable change uses optimistic UI: it paints in the browser that made it at once, while the server confirms it, and every other watching browser patches the affected row in place.

Sessions Are Live Across HTTP, WebSockets and Templates

Rails has a session in every controller, kept in an encrypted cookie, and Rails 8 generates authentication code on top of it. bin/rails generate authentication writes User and Session models, controllers, an Authentication concern, password reset mail and migrations into your app, and you maintain that code from then on. It does not generate a sign-up flow.

Elements sessions are one API across the app: route handlers, @rpc functions and templates read the signed-in user the same way, over http and WebSockets (session). A template that reads the session updates the moment a user signs in or out, with no page reload. What your app writes is the sign-in check itself, a short @rpc that verifies a password hash and calls session.login(); the authentication recipe covers sign-in and sign-up.

Email Templates Are Checked Like Pages

Rails sends mail with Action Mailer, whose views are ERB templates, so a missing variable can show up as a blank in an email that has already been sent. Elements email templates are written in Elements HTML, type checked like any page, and the compiler inlines their stylesheets into one <style> tag so they render consistently across mail clients (email). Sending is a server-only call, so it is guarded by the same compile-time check as sql.

Jobs Commit with Your Data

Background jobs run work outside a request, such as sending an email after a signup. In production, Rails 8 runs them with Solid Queue, which keeps its jobs in a separate queue database by default. By default in Rails 8.1, a job enqueued inside a transaction is written to that database at once, so it still runs if the transaction rolls back. The guide recommends enqueue_after_transaction_commit, which is off by default in Rails 8.1 and makes the job wait for the commit instead. That still leaves a gap: a crash between the commit and the enqueue loses the job.

The Elements job queue lives in the same Postgres database as the rest of your app (jobs). Schedule a job inside tx(), the Elements transaction function, and the job row commits with your other writes or not at all. There is no gap to configure around.

Tests Are Part of the Build and Gate Every Deploy

Rails tests each run in a transaction that rolls back, and they can run in parallel. They run when you start them, with bin/rails test or bin/ci, and Rails 8.1 also generates a GitHub Actions workflow that runs them on a hosted runner after you push. That is a second system, and a failing run does not stop anyone from running kamal deploy, which does not run the tests itself.

In Elements, tests are part of the build (tests). The project server knows which tests depend on a change and runs only those, on a pool of concurrent workers, each inside a Postgres transaction that rolls back. Failures arrive in the same diagnostic stream as compile errors. A release does not happen until every test passes, on your machine and on every deploy machine.

Deploys Ship Changed Files, Not Container Images

Kamal deploys a Rails app in Docker containers. On every deploy it logs in to a Docker registry, builds an image, pushes it, and pulls it onto each server, then routes traffic to the new container once it passes a health check. A new Rails app migrates its database when the container starts. Kamal issues Let's Encrypt certificates automatically only when you deploy to a single server; beyond that you load certificates yourself or run a load balancer in front of the servers.

An Elements deploy runs peer to peer over SSH: your project server talks to the one on the deploy machine, the two compare their lists of files, and only changed files transfer (deploy). An incremental deploy often completes in under a second, and the first deploy to a new machine takes a few seconds (deploy). Each machine compiles the app. The first machine migrates the test database, every machine runs the tests, and the first machine then migrates the app database. Only then does each machine switch to the new release, in one step, with no restart. An error anywhere aborts the deploy, and the previous release keeps serving.

The rest of the server setup comes with Elements. Every machine runs a built-in load balancer that issues and renews Let's Encrypt certificates for every configured domain and balances requests across the cluster (load balancer). Configuration in config.jsoc resolves every environment variable at build time, so a missing or wrongly typed value fails the build, locally or on the deploy machine, before the release goes live (config). Postgres comes with Elements, installed and started on the first deploy, which is a good fit for a prototype or a single-server app. For several machines, or when you want backups and scaling run by a provider, Elements recommends a hosted Postgres such as DigitalOcean Managed PostgreSQL or Amazon RDS, connected by setting DB_HOST (setup).

Who Pays for Rails

No company sells Rails. Its community page credits its progress to "the work of thousands of volunteers" (community). The Rails Foundation collects annual dues from its core and contributing member companies, and its stated mission is "to improve the documentation, education, marketing, and events of our framework" (foundation). Building the framework is not on that list.

The biggest new parts of Rails 8 came out of one company's own apps. 37signals sells Basecamp and HEY (Basecamp, HEY). Its engineers built Kamal 2, Kamal Proxy and Thruster, Solid Cache was created at 37signals and "has been in production at Basecamp for well over a year," and Solid Queue runs "20 million jobs per day for HEY alone" (Rails 8). Rails' roadmap follows what volunteers have time for and what 37signals' apps need.

Volunteer open source is fragile. In Tidelift's 2024 survey of more than 400 maintainers, 60% called themselves unpaid hobbyists, only 12% earned most of their income from maintenance, and 60% had quit or thought about quitting (Tidelift 2024 report). A new app's Gemfile lists 23 gems, each with maintainers who can stop. That is a risky foundation to bank a business on.

Elements is a company that sells its tooling directly, per machine, with nothing metered (pricing). What it earns from is the tooling itself, so its incentive is to make that tooling work for you. The framework is not what it sells: the app framework and runtime packages such as @elements/app are MIT licensed, and everything you build with them is yours (licenses).

Questions

Is Elements a Rails alternative?

Yes. Elements is an integrated app environment with a project server that runs while you work, builds in milliseconds and answers agents and humans in microseconds. It ships with a default framework that includes routing, server-rendered pages, database access, migrations, sessions, background jobs, email, realtime and deploys, and apps are written in TypeScript.

Does Elements have an ORM like Active Record?

Elements puts SQL first. The sql() function takes plain SQL, turns every interpolated value into a parameter at compile time, and converts camelCase columns to snake_case at the database boundary. When you want rows without writing queries, LiveTable gives you select, insert, update and delete.

How do Elements deploys compare with Kamal?

Kamal builds a Docker image, pushes it to a registry and pulls it onto each server. Elements deploys over SSH, project server to project server, and sends only changed files. It runs the tests on each machine and the migrations once, on the first machine, and then every machine switches to the new release in one step. An incremental deploy often completes in under a second.

Is Elements like Hotwire?

Both render html on the server. Hotwire adds Turbo for page and fragment updates and Stimulus controllers in JavaScript for behavior. Elements uses one reactive html language that renders on the server, becomes interactive in the browser, binds form inputs both ways, and calls typed server functions from its event handlers.

Who funds Ruby on Rails?

No company sells Rails. Its community page credits its progress to the work of thousands of volunteers (community), and the Rails Foundation, funded by member companies, works on documentation, education, marketing and events (foundation). Much of Rails 8 came from 37signals' own apps, Basecamp and HEY (Rails 8). Elements is sold directly, per machine, so its revenue comes from the tooling itself (pricing).

Can I use npm packages with Elements?

Yes. The Elements package installer runs as part of the build and installs npm packages as is, into a real node_modules tree, and the server side of an Elements app runs on Node.js. Use a package where it is a real shortcut. The useful ones are a short list: an agent would rather write HTML and CSS directly than learn one of many UI libraries, and a few lines of TypeScript often beat a dependency.