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::Basefor growing applications — the modular style allows splitting code into separate files and combining viaRack::URLMap.beforeandafterfilters for cross-cutting concerns — authentication, logging, and CORS headers can be written once in filters, not in every route.helpers do...endfor shared methods — methods insidehelpersare available in all routes and templates; use them forcurrent_user,require_login!, formatting helpers, etc.haltfor early exits —halt 401orhalt 404, bodystops route execution and immediately sends a response; cleaner than nestedif/elses.content_type :jsonbefore responses — always set the content type explicitly; it can be placed in abeforefilter so it applies to all API routes.Rack::Testfor testing — no real server needed;Rack::Test::Methodsprovidesget,post,put,deletethat work directly with the Rack application.- Per-exception error handlers —
error ArgumentError do...endenables granular error handling withoutrescuein every route.send_filefor 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.