Sinatra #

Sinatra is a micro web framework that lets you build web applications with very little code. Unlike Rails, which provides everything from an ORM to mailers, Sinatra only gives you a minimal foundation: HTTP routing and template rendering. There’s no forced directory structure, no generators, no mandatory conventions — you decide yourself how to organize the application. This simplicity makes it ideal for API microservices, webhook handlers, quick prototypes, or small applications that don’t need Rails’ complexity. A functional Sinatra application can be written in a single file with ten lines of code.

Sinatra vs Rails — When to Choose Which? #

Sinatra is suitable for:
  ✓ Microservices and simple APIs
  ✓ Webhook handlers (GitHub, Stripe, Twilio)
  ✓ Quick prototypes — running in 5 minutes
  ✓ Small apps that don't need complex databases
  ✓ Internal tools with a few endpoints
  ✓ When the team already has its own ORM and library choices
  ✓ Embedding inside a larger Rack application

Rails is suitable for:
  ✓ Full-featured web apps with many models
  ✓ Teams needing consistent conventions
  ✓ Apps needing ActiveRecord, ActionMailer, etc.
  ✓ When productivity matters more than flexibility
  ✓ Long-term projects with large teams

Installation and First Application #

gem install sinatra
gem install puma   # recommended web server
# Gemfile
gem 'sinatra', '~> 3.1'
gem 'puma',    '~> 6.4'
# app.rb — the simplest Sinatra application
require 'sinatra'

get '/' do
  'Hello, world!'
end

get '/hello/:name' do
  "Hello, #{params[:name]}!"
end
# Run
ruby app.rb            # runs on port 4567
ruby app.rb -p 3000    # custom port
rackup config.ru       # via Rack

Routing — HTTP Methods and Parameters #

Sinatra supports all HTTP methods with very clean syntax:

require 'sinatra'
require 'json'

# GET — fetch data
get '/products' do
  content_type :json
  Product.all.to_json
end

# POST — create new data
post '/products' do
  data = JSON.parse(request.body.read, symbolize_names: true)
  product = Product.create!(data)
  status 201
  product.to_json
end

# PUT — replace the entire resource
put '/products/:id' do
  product = Product.find(params[:id])
  product.update!(JSON.parse(request.body.read))
  product.to_json
end

# PATCH — partially update
patch '/products/:id' do
  product = Product.find(params[:id])
  product.update!(JSON.parse(request.body.read))
  product.to_json
end

# DELETE — remove
delete '/products/:id' do
  Product.find(params[:id]).destroy
  status 204
end

# HEAD and OPTIONS
head '/products' do
  # headers only, no body
end

options '/products' do
  headers['Allow'] = 'GET, POST, HEAD, OPTIONS'
  status 200
end

Route Parameters #

# Named parameters — :name
get '/users/:id' do
  "User ID: #{params[:id]}"
end

# Splat — *
get '/files/*' do
  "File: #{params[:splat].first}"
  # GET /files/documents/report.pdf → params[:splat] = ["documents/report.pdf"]
end

# Named wildcard
get '/assets/:type/*' do
  "Type: #{params[:type]}, Path: #{params[:splat].first}"
end

# Query strings — automatically available via params
# GET /search?q=laptop&page=2
get '/search' do
  q      = params[:q]
  page   = params[:page].to_i
  "Searching '#{q}' page #{page}"
end

# Regex as a route
get %r{/version/(\d+)} do |version|
  "Version: #{version}"
end

# Route conditions
get '/admin', host_name: /^admin\./ do
  "Admin Panel"
end

set(:admin_only) { |_| condition { halt 403 unless current_user&.admin? } }
get '/secret', :admin_only => true do
  "Secret content"
end

Requests and Responses #

# The request object
get '/request-info' do
  {
    method:       request.request_method,
    path:         request.path_info,
    url:          request.url,
    host:         request.host,
    port:         request.port,
    ip:           request.ip,
    user_agent:   request.user_agent,
    content_type: request.content_type,
    secure:       request.secure?,
    xhr:          request.xhr?,        # whether it's an AJAX request
    body:         request.body.read,
    referer:      request.referer
  }.to_json
end

# Request headers
get '/with-header' do
  token = request.env['HTTP_AUTHORIZATION']
  "Authorization: ***"
end

# Setting the response
get '/custom-response' do
  status 202                              # set the HTTP status
  headers 'X-Custom-Header' => 'value'   # set headers
  headers 'Cache-Control'   => 'no-cache'
  body 'Response with custom headers'   # set the body
end

# Content types
get '/json' do
  content_type :json
  { message: "ok", data: [1, 2, 3] }.to_json
end

get '/xml' do
  content_type :xml
  "<root><message>ok</message></root>"
end

# Redirects
get '/old' do
  redirect '/new'           # 302 Found
  redirect '/new', 301      # 301 Moved Permanently
end

# Stream responses (SSE / Server-Sent Events)
get '/stream' do
  content_type 'text/event-stream'
  stream(:keep_open) do |out|
    10.times do |i|
      out << "data: event-#{i}\n\n"
      sleep 1
    end
    out.close
  end
end

# File downloads
get '/download/:name' do
  send_file "public/files/#{params[:name]}",
    filename:     params[:name],
    disposition:  'attachment',
    type:         'application/octet-stream'
end

# Halt — stop execution and send a response
get '/protected' do
  halt 401, { error: 'Unauthorized' }.to_json unless authenticated?
  { data: 'secret' }.to_json
end

Templates — ERB, Haml, and JSON #

ERB Templates #

# app.rb
require 'sinatra'

get '/page' do
  @title   = "Welcome"
  @products = [{ name: "Laptop", price: 15_000_000 }]
  erb :page   # renders views/page.erb
end

get '/inline' do
  name = params[:name] || "Guest"
  erb "<h1>Hello <%= name %>!</h1>"   # inline template
end
<!-- views/layout.erb — default layout for all pages -->
<!DOCTYPE html>
<html lang="en">
<head>
  <title><%= @title || "Ruby Store" %></title>
</head>
<body>
  <nav>
    <a href="/">Home</a>
    <a href="/products">Products</a>
  </nav>

  <main>
    <%= yield %>
  </main>

  <footer>© 2024 Ruby Store</footer>
</body>
</html>
<!-- views/page.erb -->
<h1><%= @title %></h1>
<ul>
  <% @products.each do |p| %>
    <li><%= p[:name] %> — Rp <%= p[:price] %></li>
  <% end %>
</ul>

JSON Response Helpers #

require 'sinatra'
require 'json'

# Helpers for consistent JSON responses
helpers do
  def json_success(data, status_code = 200)
    content_type :json
    status status_code
    { status: "success", data: data }.to_json
  end

  def json_error(message, status_code = 400)
    content_type :json
    status status_code
    { status: "error", message: message }.to_json
  end
end

get '/api/products' do
  json_success(Product.all)
end

get '/api/products/:id' do
  product = Product.find_by(id: params[:id])
  halt 404, json_error("Product not found") unless product
  json_success(product)
end

Filters — Before and After #

require 'sinatra'

# Before filter — runs before every route
before do
  content_type :json
  @start_time = Time.now
end

# Before filter for specific paths
before '/admin/*' do
  halt 401, { error: 'Unauthorized' }.to_json unless session[:admin]
end

before '/api/*' do
  token = request.env['HTTP_AUTHORIZATION']&.delete_prefix("Bearer ")
  halt 401 unless token && valid_token?(token)
  @current_user = User.find_by_token(token)
end

# After filter — runs after every route
after do
  duration = ((Time.now - @start_time) * 1000).round(2)
  headers 'X-Response-Time' => "#{duration}ms"
  logger.info "#{request.request_method} #{request.path}#{response.status} (#{duration}ms)"
end

# After filter for specific paths
after '/api/*' do
  # add CORS headers to all API responses
  headers(
    'Access-Control-Allow-Origin'  => '*',
    'Access-Control-Allow-Methods' => 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
    'Access-Control-Allow-Headers' => 'Content-Type, Authorization'
  )
end

Helpers — Methods Available in Routes and Templates #

require 'sinatra'

helpers do
  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def logged_in?
    !current_user.nil?
  end

  def require_login!
    halt 401, { error: 'Please log in' }.to_json unless logged_in?
  end

  def require_admin!
    halt 403, { error: 'Access denied' }.to_json unless current_user&.admin?
  end

  def format_rupiah(number)
    "Rp #{number.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse}"
  end

  def paginate(collection, per_page: 20)
    page   = (params[:page] || 1).to_i
    offset = (page - 1) * per_page
    {
      data:         collection.limit(per_page).offset(offset),
      total:        collection.count,
      page:         page,
      per_page:     per_page,
      total_pages:  (collection.count.to_f / per_page).ceil
    }
  end
end

get '/profile' do
  require_login!
  { user: current_user.as_json(only: [:id, :name, :email]) }.to_json
end

get '/admin/dashboard' do
  require_login!
  require_admin!
  { statistics: fetch_statistics }.to_json
end

Sessions and Cookies #

require 'sinatra'

# Enable sessions (stored in an encrypted cookie)
enable :sessions
set :session_secret, ENV.fetch("SESSION_SECRET") { SecureRandom.hex(64) }

# Or a more detailed configuration
use Rack::Session::Cookie,
  key:          '_store_session',
  expire_after: 2592000,   # 30 days in seconds
  secret:       ENV["SESSION_SECRET"],
  httponly:     true,
  secure:       ENV["RACK_ENV"] == "production"

# Use sessions
post '/login' do
  data = JSON.parse(request.body.read, symbolize_names: true)
  user = User.authenticate(data[:email], data[:password])

  if user
    session[:user_id] = user.id
    session[:login_at] = Time.now.iso8601
    { success: true, user: { id: user.id, name: user.name } }.to_json
  else
    halt 401, { error: "Incorrect email or password" }.to_json
  end
end

delete '/logout' do
  session.clear
  { success: true }.to_json
end

# Direct cookies
get '/set-cookie' do
  response.set_cookie("preference", {
    value:    "dark",
    expires:  Time.now + (30 * 24 * 60 * 60),
    httponly: true,
    path:     "/"
  })
  "Cookie set"
end

get '/read-cookie' do
  preference = request.cookies["preference"]
  "Preference: #{preference}"
end

Modular Applications — Sinatra::Base #

For larger applications, use the modular style with Sinatra::Base:

# app/controllers/products_app.rb
require 'sinatra/base'
require 'json'

class ProductsApp < Sinatra::Base
  configure do
    enable :logging
    set :show_exceptions, false
  end

  configure :development do
    set :show_exceptions, true
  end

  # Error handling
  error 404 do
    content_type :json
    { error: "Not found", path: request.path }.to_json
  end

  error 500 do
    content_type :json
    logger.error env['sinatra.error'].message
    { error: "Server error" }.to_json
  end

  error ArgumentError do
    content_type :json
    status 400
    { error: env['sinatra.error'].message }.to_json
  end

  before do
    content_type :json
  end

  get '/' do
    { status: "ok", version: "1.0.0" }.to_json
  end

  get '/products' do
    page     = (params[:page] || 1).to_i
    per_page = (params[:per_page] || 20).to_i.clamp(1, 100)
    active   = params[:active] != "false"

    products = Product.where(active: active)
                      .order(created_at: :desc)
                      .limit(per_page)
                      .offset((page - 1) * per_page)

    {
      data:        products.as_json(only: [:id, :name, :price, :stock]),
      meta:        { page: page, per_page: per_page, total: Product.where(active: active).count }
    }.to_json
  end

  get '/products/:id' do
    product = Product.find_by(id: params[:id])
    halt 404 unless product
    product.as_json.to_json
  end

  post '/products' do
    data = JSON.parse(request.body.read, symbolize_names: true)
    product = Product.new(data.slice(:name, :price, :stock, :category_id))

    if product.save
      status 201
      product.as_json.to_json
    else
      status 422
      { errors: product.errors.full_messages }.to_json
    end
  end

  patch '/products/:id' do
    product = Product.find_by(id: params[:id])
    halt 404 unless product

    data = JSON.parse(request.body.read, symbolize_names: true)
    if product.update(data.slice(:name, :price, :stock, :active))
      product.as_json.to_json
    else
      status 422
      { errors: product.errors.full_messages }.to_json
    end
  end

  delete '/products/:id' do
    product = Product.find_by(id: params[:id])
    halt 404 unless product
    product.destroy
    status 204
  end
end

Modular Directory Structure #

store_api/
├── Gemfile
├── config.ru          ← the Rack entry point
├── app/
│   ├── controllers/
│   │   ├── products_app.rb
│   │   ├── users_app.rb
│   │   └── auth_app.rb
│   ├── models/
│   │   ├── product.rb
│   │   └── user.rb
│   └── helpers/
│       └── auth_helper.rb
├── config/
│   ├── database.yml
│   └── initializers/
├── db/
│   └── migrate/
└── public/
# config.ru — the Rack entry point
require 'bundler/setup'
Bundler.require

require_relative 'config/database'
require_relative 'app/controllers/auth_app'
require_relative 'app/controllers/products_app'
require_relative 'app/controllers/users_app'

# Combine several Sinatra apps with Rack::URLMap
run Rack::URLMap.new(
  '/api/auth'     => AuthApp,
  '/api/products' => ProductsApp,
  '/api/users'    => UsersApp
)

Rack Middleware #

Sinatra is a Rack application — it can use standard Rack middleware:

require 'sinatra/base'
require 'rack/cors'

class StoreApiApp < Sinatra::Base
  # CORS middleware
  use Rack::Cors do
    allow do
      origins  'https://frontend.example.com', /localhost:\d+/
      resource '*',
        headers: :any,
        methods: [:get, :post, :put, :patch, :delete, :options],
        credentials: false
    end
  end

  # Logging middleware
  use Rack::CommonLogger

  # Gzip compression
  use Rack::Deflater

  # Rate limiting (with the rack-attack gem)
  use Rack::Attack

  # Custom middleware
  class JsonBodyParser
    def initialize(app)
      @app = app
    end

    def call(env)
      if env['CONTENT_TYPE']&.include?('application/json')
        body = env['rack.input'].read
        env['rack.input'].rewind
        env['parsed_json_body'] = JSON.parse(body, symbolize_names: true) rescue {}
      end
      @app.call(env)
    end
  end

  use JsonBodyParser
end

ActiveRecord Integration #

# Gemfile
# gem 'sinatra',      '~> 3.1'
# gem 'activerecord', '~> 7.1'
# gem 'sinatra-activerecord'

require 'sinatra'
require 'sinatra/activerecord'

# config/database.yml is read automatically by sinatra-activerecord
set :database_file, 'config/database.yml'

class Product < ActiveRecord::Base
  validates :name,  presence: true
  validates :price, numericality: { greater_than: 0 }
end

get '/products' do
  content_type :json
  Product.where(active: true).to_json
end

post '/products' do
  content_type :json
  product = Product.new(JSON.parse(request.body.read, symbolize_names: true))
  if product.save
    status 201
    product.to_json
  else
    status 422
    { errors: product.errors }.to_json
  end
end
# Rake tasks for the database (from sinatra-activerecord)
bundle exec rake db:create
bundle exec rake db:migrate
bundle exec rake db:seed

Testing with Rack::Test #

# spec/app_spec.rb
require 'spec_helper'
require 'rack/test'
require_relative '../app'

RSpec.describe ProductsApp do
  include Rack::Test::Methods

  def app
    ProductsApp
  end

  describe "GET /products" do
    it "returns a list of products" do
      create_list(:product, 3, active: true)

      get '/products'

      expect(last_response.status).to eq(200)
      body = JSON.parse(last_response.body, symbolize_names: true)
      expect(body[:data].length).to eq(3)
    end

    it "filters by active" do
      create(:product, active: true)
      create(:product, active: false)

      get '/products', active: "true"

      body = JSON.parse(last_response.body, symbolize_names: true)
      expect(body[:data].length).to eq(1)
    end
  end

  describe "POST /products" do
    it "creates a new product" do
      post '/products',
        { name: "Laptop", price: 15_000_000, stock: 5 }.to_json,
        { 'CONTENT_TYPE' => 'application/json' }

      expect(last_response.status).to eq(201)
      expect(JSON.parse(last_response.body)['name']).to eq("Laptop")
    end

    it "fails when the name is blank" do
      post '/products',
        { price: 15_000_000 }.to_json,
        { 'CONTENT_TYPE' => 'application/json' }

      expect(last_response.status).to eq(422)
      body = JSON.parse(last_response.body, symbolize_names: true)
      expect(body[:errors]).to include("Name can't be blank")
    end
  end

  describe "GET /products/:id" do
    it "returns a product by ID" do
      product = create(:product, name: "Monitor")

      get "/products/#{product.id}"

      expect(last_response.status).to eq(200)
      expect(JSON.parse(last_response.body)['name']).to eq("Monitor")
    end

    it "404s when not found" do
      get '/products/99999'
      expect(last_response.status).to eq(404)
    end
  end
end

Deployment #

# config.ru — for production deployment
require 'bundler/setup'
Bundler.require

require_relative 'app'

# Production configuration
set :environment, :production
set :port,        ENV.fetch('PORT', 4567)
set :bind,        '0.0.0.0'

run Sinatra::Application
# Run with Puma (production)
bundle exec puma config.ru -p $PORT -e production

# With a Procfile (Heroku/Render)
# web: bundle exec puma config.ru -p $PORT -e $RACK_ENV

# Dockerfile
# FROM ruby:3.3-alpine
# WORKDIR /app
# COPY Gemfile* ./
# RUN bundle install
# COPY . .
# EXPOSE 4567
# CMD ["bundle", "exec", "puma", "config.ru", "-p", "4567", "-e", "production"]

Summary #

  • One file for small apps — Sinatra doesn’t force any structure; a simple application can be written in a single file without special directories.
  • Use Sinatra::Base for growing applications — the modular style allows splitting code into separate files and combining via Rack::URLMap.
  • before and after filters for cross-cutting concerns — authentication, logging, and CORS headers can be written once in filters, not in every route.
  • helpers do...end for shared methods — methods inside helpers are available in all routes and templates; use them for current_user, require_login!, formatting helpers, etc.
  • halt for early exitshalt 401 or halt 404, body stops route execution and immediately sends a response; cleaner than nested if/elses.
  • content_type :json before responses — always set the content type explicitly; it can be placed in a before filter so it applies to all API routes.
  • Rack::Test for testing — no real server needed; Rack::Test::Methods provides get, post, put, delete that work directly with the Rack application.
  • Per-exception error handlerserror ArgumentError do...end enables granular error handling without rescue in every route.
  • send_file for downloads — more efficient than reading files into memory; Sinatra (via Rack) streams files directly to the response.
  • Sinatra is not a Rails replacement — for apps with many models, complex database relations, and large teams, Rails is more appropriate; Sinatra shines with microservices, webhooks, and simple APIs.

← Previous: Ruby on Rails   Next: ORM Adapter →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact