Back to Scaling Ruby Applications guides

Ruby on Rails vs Sinatra

Stanley Ulili
Updated on October 10, 2025

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?

Screenshot of Ruby on Rails Github page

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?

Screenshot of 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 action
  • GET /articles/new → new action
  • POST /articles → create action
  • GET /articles/:id → show action
  • GET /articles/:id/edit → edit action
  • PATCH /articles/:id → update action
  • DELETE /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:

app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title><%= yield :title %></title>
    <%= csrf_meta_tags %>
  </head>
  <body>
    <%= render 'shared/header' %>
    <%= yield %>
    <%= render 'shared/footer' %>
  </body>
</html>
app/views/articles/index.html.erb
<% @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:

test/models/article_test.rb
require "test_helper"

class ArticleTest < ActiveSupport::TestCase
  test "should not save article without title" do
    article = Article.new
    assert_not article.save
  end
end
test/controllers/articles_controller_test.rb
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:

test/app_test.rb
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:

app/jobs/article_notification_job.rb
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.

Got an article suggestion? Let us know
Next article
Ruby on Rails vs Hanami
Learn how Rails speeds Ruby web development with conventions and tools, while Hanami offers structure and clarity for long-term projects.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.