Ruby on Rails vs Sinatra
Ruby developers choosing a web framework face a fundamental decision between Rails' full-stack approach and Sinatra's minimalist design. While Rails provides everything needed to build complex applications out of the box, Sinatra offers simplicity and control for smaller projects or API development.
This comparison examines both frameworks' architectures, development workflows, and ideal use cases to help you select the right tool for your next Ruby web project.
What is Ruby on Rails?
Ruby on Rails (often just "Rails") is a full-stack web framework that handles everything from database interactions to front-end rendering.
The framework follows the Model-View-Controller (MVC) pattern and provides tools for routing, database abstraction through ActiveRecord, background jobs, email handling, testing, and more. This comprehensive approach means you can build production applications without integrating multiple libraries.
Rails emphasizes "convention over configuration." Rather than writing configuration files to wire components together, you follow naming conventions and Rails handles the connections automatically. This philosophy accelerates development but requires learning Rails' way of structuring applications.
What is Sinatra?
Sinatra provides a minimal foundation for building web applications in Ruby. Sinatra focuses on routing HTTP requests to Ruby code without imposing structure or including batteries.
The entire framework centers around defining routes and their responses. A simple Sinatra application fits in a single file without generators, configuration files, or directory structures. This simplicity makes Sinatra ideal for APIs, prototypes, and applications where Rails feels excessive.
Sinatra doesn't include database tools, testing frameworks, or asset pipelines. You choose and integrate the libraries you need, maintaining full control over your application's architecture. This flexibility comes at the cost of manually wiring components together.
Quick Comparison
Feature | Ruby on Rails | Sinatra |
---|---|---|
Philosophy | Convention over configuration | Minimal core, maximum flexibility |
Learning Curve | Steeper (many conventions to learn) | Gentle (basic Ruby and HTTP knowledge) |
Project Structure | Prescribed directory layout | No enforced structure |
Database Layer | ActiveRecord (built-in ORM) | Choose your own (Sequel, ActiveRecord, etc.) |
Routing | Resourceful routing with conventions | Simple route definitions |
Template Engine | ERB (default), supports others | Any template engine via gems |
Background Jobs | ActiveJob with multiple backends | Integrate Sidekiq or alternatives manually |
Testing Tools | Minitest and RSpec integration | Choose your own framework |
Asset Pipeline | Sprockets or Propshaft included | Integrate manually if needed |
File Size | ~50MB+ with dependencies | ~2MB with basic dependencies |
Getting started
I installed both frameworks to build a simple blog. Rails immediately generated 50+ files before I wrote a single line of code. Sinatra let me start with just app.rb
. This difference reveals each framework's core philosophy.
Rails requires installing the gem and using generators to create project structure:
gem install rails
rails new myapp
cd myapp
This generates dozens of files and directories:
myapp/
├── app/
│ ├── controllers/
│ ├── models/
│ ├── views/
│ └── ...
├── config/
├── db/
├── test/
└── ...
Starting the development server:
rails server
Rails creates a full application skeleton before you write any code. This structure guides where to place different components, but you need to understand the conventions to navigate effectively.
Sinatra applications start with a single Ruby file:
gem install sinatra
Create app.rb
:
require 'sinatra'
get '/' do
'Hello World'
end
Run it:
ruby app.rb
The entire application lives in one file until you decide to split it. This minimalism lets you focus on HTTP requests and responses without learning framework conventions first.
Routing approaches
After getting both apps running, I needed to handle article URLs. Rails generated seven routes from one line of code. Sinatra required writing each route explicitly.
Rails uses resourceful routing that maps HTTP verbs and URLs to controller actions:
# config/routes.rb
Rails.application.routes.draw do
resources :articles
get '/about', to: 'pages#about'
root 'articles#index'
end
The resources :articles
line generates seven routes automatically:
GET /articles
→ index actionGET /articles/new
→ new actionPOST /articles
→ create actionGET /articles/:id
→ show actionGET /articles/:id/edit
→ edit actionPATCH /articles/:id
→ update actionDELETE /articles/:id
→ destroy action
This convention reduces boilerplate but requires learning Rails' routing patterns. Custom routes work alongside resourceful routes when needed.
Sinatra defines routes directly in your application file:
get '/articles' do
# Handle GET /articles
end
post '/articles' do
# Handle POST /articles
end
get '/articles/:id' do
# Handle GET /articles/123
@article = Article.find(params[:id])
erb :show
end
delete '/articles/:id' do
# Handle DELETE /articles/123
end
Each route explicitly states the HTTP verb and path. There's no magic routing generation, just straightforward URL pattern matching. This explicitness makes understanding request flow easier but requires more code for CRUD operations.
Database interaction
Those routes needed to fetch articles from a database. Rails gave me ActiveRecord immediately with migrations and model generators. Sinatra gave me nothing, which meant choosing between ActiveRecord, Sequel, or writing raw SQL. I picked Sequel to see how manual database integration felt.
Rails includes ActiveRecord, an ORM that maps database tables to Ruby objects:
# Generate a model
rails generate model Article title:string body:text
# Run migration
rails db:migrate
# Use in controller
class ArticlesController < ApplicationController
def index
@articles = Article.all
end
def create
@article = Article.create(article_params)
redirect_to @article
end
private
def article_params
params.require(:article).permit(:title, :body)
end
end
ActiveRecord handles database connections, query building, validations, callbacks, and associations. Relationships between models use declarative syntax:
class Article < ApplicationRecord
belongs_to :author
has_many :comments
validates :title, presence: true
end
Rails migrations track database schema changes over time, making it easy to modify tables and share changes across teams.
Sinatra doesn't include database tools. You integrate the ORM or database library you prefer:
# Using Sequel ORM
require 'sinatra'
require 'sequel'
DB = Sequel.connect('sqlite://blog.db')
# Define model
class Article < Sequel::Model
end
get '/articles' do
@articles = Article.all
erb :index
end
post '/articles' do
Article.create(
title: params[:title],
body: params[:body]
)
redirect '/articles'
end
This flexibility lets you choose ActiveRecord if you want, or use lighter alternatives like Sequel, ROM, or direct SQL. You handle database connections, migrations, and schema management manually or through your chosen library.
View rendering
With articles coming from the database, I needed to display them. Rails automatically looked for templates matching my controller actions. Sinatra required me to explicitly call erb :index
for every route. That extra line of code in Sinatra felt tedious at first, but later I appreciated knowing exactly which template rendered where.
Rails includes a complete view layer with layout inheritance and partials:
<!DOCTYPE html>
<html>
<head>
<title><%= yield :title %></title>
<%= csrf_meta_tags %>
</head>
<body>
<%= render 'shared/header' %>
<%= yield %>
<%= render 'shared/footer' %>
</body>
</html>
<% @articles.each do |article| %>
<div class="article">
<h2><%= link_to article.title, article %></h2>
<p><%= article.body %></p>
</div>
<% end %>
Rails automatically renders views matching controller actions. The articles#index
action renders app/views/articles/index.html.erb
without explicit rendering code. Helper methods like link_to
generate HTML elements following Rails conventions.
Sinatra requires explicit rendering:
get '/articles' do
@articles = Article.all
erb :index
end
# views/index.erb
<% @articles.each do |article| %>
<div class="article">
<h2><a href="/articles/<%= article.id %>"><%= article.title %></a></h2>
<p><%= article.body %></p>
</div>
<% end %>
You specify which template to render and in which format. Sinatra supports ERB, Haml, Slim, and other template engines through gems. Layouts work similarly to Rails but require explicit configuration:
set :views, settings.root + '/templates'
set :erb, :layout => :application
get '/articles' do
erb :index # Renders views/index.erb inside views/application.erb
end
Form handling and validation
Displaying articles worked fine, but creating new ones exposed a major difference. Rails' form helpers generated proper HTML with CSRF tokens and validation error messages automatically. In Sinatra, I had to build all of this manually, writing HTML forms by hand and figuring out where to display validation errors.
Rails provides form helpers that generate HTML and handle CSRF protection:
<%= form_with model: @article do |form| %>
<div>
<%= form.label :title %>
<%= form.text_field :title %>
<% @article.errors.full_messages_for(:title).each do |message| %>
<div><%= message %></div>
<% end %>
</div>
<div>
<%= form.label :body %>
<%= form.text_area :body %>
</div>
<%= form.submit %>
<% end %>
The controller handles validation through model definitions:
class Article < ApplicationRecord
validates :title, presence: true, length: { minimum: 5 }
validates :body, presence: true
end
class ArticlesController < ApplicationController
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article
else
render :new, status: :unprocessable_entity
end
end
end
Rails automatically populates error messages, preserves form data on validation failures, and protects against CSRF attacks.
Sinatra requires manually building these features:
enable :sessions
get '/articles/new' do
erb :new
end
post '/articles' do
@article = Article.new(
title: params[:title],
body: params[:body]
)
if @article.valid?
@article.save
redirect "/articles/#{@article.id}"
else
erb :new
end
end
# views/new.erb
<form method="post" action="/articles">
<div>
<label>Title</label>
<input type="text" name="title" value="<%= @article&.title %>">
<% if @article&.errors&.on(:title) %>
<div><%= @article.errors.on(:title).first %></div>
<% end %>
</div>
<div>
<label>Body</label>
<textarea name="body"><%= @article&.body %></textarea>
</div>
<button type="submit">Create Article</button>
</form>
You write standard HTML forms and manually handle CSRF protection, validation display, and data persistence. This gives complete control but requires more code.
Testing approaches
Once the basic blog worked, I wanted tests. Rails had already created a test/
directory with helpers ready to go. Sinatra had nothing. I spent an hour figuring out how to set up Rack::Test and configure database cleanup between tests—work Rails handled automatically.
Rails includes testing infrastructure with fixtures and test helpers:
require "test_helper"
class ArticleTest < ActiveSupport::TestCase
test "should not save article without title" do
article = Article.new
assert_not article.save
end
end
require "test_helper"
class ArticlesControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get articles_url
assert_response :success
end
test "should create article" do
assert_difference("Article.count") do
post articles_url, params: { article: { title: "Test", body: "Content" } }
end
assert_redirected_to article_url(Article.last)
end
end
Rails generators create test files alongside models and controllers. The testing environment loads fixtures, provides assertion helpers, and handles database cleanup between tests.
Sinatra applications use whatever testing framework you choose. Using Rack::Test with Minitest:
require 'minitest/autorun'
require 'rack/test'
require_relative '../app'
class AppTest < Minitest::Test
include Rack::Test::Methods
def app
Sinatra::Application
end
def test_homepage
get '/'
assert last_response.ok?
assert_includes last_response.body, 'Hello World'
end
def test_create_article
post '/articles', title: 'Test', body: 'Content'
assert last_response.redirect?
follow_redirect!
assert_includes last_response.body, 'Test'
end
end
You configure the testing environment, handle database setup, and choose assertion libraries. This requires more initial setup but gives complete control over your testing strategy.
API development
I tried converting both blogs into JSON APIs. Rails API mode removed views but still loaded ActiveRecord, routing, and dozens of middleware components—my app still used 60MB of memory. Sinatra's API version was literally 15 lines of code and used 12MB. For a simple API returning JSON, Sinatra felt right-sized while Rails felt bloated.
Rails includes API mode that strips out view-related middleware and helpers:
rails new myapi --api
Controllers inherit from ActionController::API
instead of ActionController::Base
:
class ArticlesController < ApplicationController
def index
@articles = Article.all
render json: @articles
end
def create
@article = Article.new(article_params)
if @article.save
render json: @article, status: :created
else
render json: @article.errors, status: :unprocessable_entity
end
end
private
def article_params
params.require(:article).permit(:title, :body)
end
end
Rails API mode still includes ActiveRecord, routing, parameter parsing, and middleware for CORS, authentication, and rate limiting. The framework remains substantial even without views.
Sinatra excels at API development through its minimal footprint:
require 'sinatra'
require 'json'
before do
content_type :json
end
get '/articles' do
Article.all.to_json
end
post '/articles' do
article = Article.create(JSON.parse(request.body.read))
status 201
article.to_json
end
get '/articles/:id' do
article = Article.find(params[:id])
article ? article.to_json : halt(404)
end
The lightweight design makes Sinatra particularly suitable for microservices, webhooks, and small APIs where Rails' full stack feels excessive.
Background job processing
My blog needed email notifications when articles published. Rails gave me ActiveJob, which let me switch between Sidekiq, Resque, and other job processors without changing code. In Sinatra, I had to configure Sidekiq directly, set up Redis connections, and write worker classes myself. The Rails abstraction saved hours of setup time.
Rails includes ActiveJob, which provides a unified interface to multiple background job processors:
class ArticleNotificationJob < ApplicationJob
queue_as :default
def perform(article)
# Send notifications
NotificationMailer.new_article(article).deliver_now
end
end
# Enqueue the job
ArticleNotificationJob.perform_later(@article)
ActiveJob works with Sidekiq, Resque, Delayed Job, and other backends. You switch between them by changing configuration rather than rewriting job code.
Sinatra requires integrating background job libraries manually:
require 'sinatra'
require 'sidekiq'
class ArticleNotificationWorker
include Sidekiq::Worker
def perform(article_id)
article = Article.find(article_id)
# Send notifications
end
end
post '/articles' do
article = Article.create(params)
ArticleNotificationWorker.perform_async(article.id)
redirect "/articles/#{article.id}"
end
You configure Sidekiq, Redis connections, and worker processes yourself. This adds setup complexity but gives direct control over background processing architecture.
Authentication and authorization
Adding user logins highlighted another divide. I ran two commands in Rails (rails generate devise:install
and rails generate devise User
) and had complete user authentication with password reset emails and confirmation flows. Building the same thing in Sinatra took me two days of wiring together Warden, BCrypt, and email libraries.
Rails has multiple authentication solutions. Devise provides complete user management:
bundle add devise
rails generate devise:install
rails generate devise User
This creates user models, controllers, views, and routes for registration, login, password reset, and email confirmation. The generated code follows Rails conventions and integrates with the existing application structure.
Authorization libraries like Pundit add policy-based access control:
class ArticlePolicy
def update?
user.admin? || record.author == user
end
end
class ArticlesController < ApplicationController
def update
@article = Article.find(params[:id])
authorize @article
# Update logic
end
end
Sinatra requires building authentication from scratch or integrating libraries like Warden:
require 'sinatra'
require 'warden'
use Rack::Session::Cookie, secret: "secret_key"
use Warden::Manager do |config|
config.serialize_into_session { |user| user.id }
config.serialize_from_session { |id| User.find(id) }
config.scope_defaults :default, strategies: [:password]
config.failure_app = self
end
Warden::Strategies.add(:password) do
def valid?
params['username'] && params['password']
end
def authenticate!
user = User.authenticate(params['username'], params['password'])
user ? success!(user) : fail!("Invalid credentials")
end
end
get '/login' do
erb :login
end
post '/login' do
env['warden'].authenticate!
redirect '/'
end
Building authentication manually means writing more code but understanding exactly how user management works in your application.
Final thoughts
Rails is the stronger choice when building full-stack applications. Its conventions streamline development, offering proven solutions to common problems and reducing the burden of decision-making. This comprehensive framework fosters consistency and allows teams to move quickly with confidence.
Sinatra, on the other hand, shines in scenarios like APIs, microservices, or lightweight applications where fine-grained control is important. Its minimal design makes applications easier to grasp while avoiding unnecessary dependencies.