Getting started
Hello World
require 'sinatra'
get '/' do
'Hello world!'
end
Save as app.rb and run:
ruby app.rb
# View at http://localhost:4567
Installation
gem install sinatra
# Gemfile
gem 'sinatra'
gem 'puma' # Web server
Application Types
Classic Style (single file)
require 'sinatra'
get '/' do
'Hello'
end
Modular Style (class-based)
require 'sinatra/base'
class MyApp < Sinatra::Base
get '/' do
'Hello'
end
run! if app_file == $0
end
Running the App
ruby app.rb # Run directly
ruby app.rb -p 4000 # Custom port
ruby app.rb -e production # Production mode
rackup config.ru # With Rack
Routes
HTTP Methods
get '/' do
# Handle GET request
end
post '/create' do
# Handle POST request
end
put '/update/:id' do
# Handle PUT request
end
patch '/update/:id' do
# Handle PATCH request
end
delete '/destroy/:id' do
# Handle DELETE request
end
options '/options' do
# Handle OPTIONS request
end
link '/link' do
# Handle LINK request
end
unlink '/unlink' do
# Handle UNLINK request
end
Route Patterns
get '/hello/:name' do
"Hello #{params['name']}!"
end
get '/posts/:id' do
Post.find(params['id'])
end
# Wildcard (splat)
get '/download/*.*' do
# params['splat'] => ['path/to/file', 'xml']
end
# Named splats
get '/download/:path/*.*' do |path, ext|
# path => 'path/to/file'
# ext => 'xml'
end
# Optional parameters
get '/posts/:id/?:format?' do
# Matches /posts/1 and /posts/1/json
end
Regular Expressions
get %r{/hello/([\w]+)} do
"Hello #{params['captures'].first}!"
end
get %r{/posts/(\d+)} do |id|
Post.find(id)
end
# Named captures
get %r{/posts/(?<id>\d+)} do
Post.find(params['id'])
end
Conditions
get '/user-agent', agent: /Chrome/ do
"You're using Chrome"
end
get '/admin', host: 'admin.example.com' do
"Admin area"
end
get '/api', provides: 'json' do
{ status: 'ok' }.to_json
end
# Custom conditions
set(:probability) { |value|
condition { rand <= value }
}
get '/win', probability: 0.5 do
"You won!"
end
Request & Response
Request Object
get '/foo' do
request.body # Request body
request.scheme # "http"
request.script_name # "/example"
request.path_info # "/foo"
request.port # 4567
request.request_method # "GET"
request.query_string # "foo=bar"
request.content_length # Length of body
request.media_type # Media type
request.host # "example.com"
request.get? # true
request.form_data? # false
request.secure? # false
request.forwarded? # true
request.env # Raw env hash
request.xhr? # AJAX request?
request.referrer # Referrer
request.user_agent # User agent
request.ip # Client IP
request.url # Full URL
end
Parameters
# Query parameters
get '/search' do
params['q'] # Access query param
end
# Named parameters
get '/posts/:id' do
params['id'] # Route parameter
params[:id] # Symbol access
end
# Form parameters (POST)
post '/users' do
params['name']
params['email']
end
# Nested parameters
post '/users' do
params['user']['name']
params['user']['email']
end
Response
get '/download' do
status 200 # Set status code
headers 'X-Custom' => 'Value'
content_type 'text/xml'
body '<xml></xml>' # Set body
end
# Return values
get '/' do
'String body' # String
[200, {}, ['Body']] # Rack array
redirect '/other' # Redirect
halt 401 # Stop with status
pass # Pass to next route
end
# Status shortcuts
not_found do
'Not found'
end
error 500 do
'Server error'
end
Headers & Content Type
get '/download' do
content_type 'application/pdf'
# or
content_type :pdf
attachment 'file.pdf'
# Send file
end
# Set multiple headers
get '/' do
headers 'X-Custom' => 'Value',
'X-Other' => 'Another'
'OK'
end
# Content negotiation
get '/data' do
case request.accept.first
when 'application/json'
content_type :json
{ data: 'value' }.to_json
when 'application/xml'
content_type :xml
'<data>value</data>'
end
end
Cookies
get '/set' do
response.set_cookie 'name', 'value'
# or
cookies[:name] = 'value' # Rack::Protection
end
get '/get' do
request.cookies['name']
# or
cookies[:name]
end
# Cookie options
get '/secure' do
response.set_cookie 'token',
value: 'secret',
max_age: 3600,
path: '/',
secure: true,
httponly: true,
same_site: :strict
end
Templates
ERB Templates
get '/' do
@title = 'Home'
@items = ['a', 'b', 'c']
erb :index
end
views/index.erb:
<h1><%= @title %></h1>
<ul>
<% @items.each do |item| %>
<li><%= item %></li>
<% end %>
</ul>
Inline Templates
get '/' do
erb :index
end
__END__
@@layout
<html>
<body><%= yield %></body>
</html>
@@index
<h1>Hello</h1>
Template Engines
# ERB (default)
get '/' do
erb :index
end
# Haml
get '/haml' do
haml :index
end
# Slim
get '/slim' do
slim :index
end
# Builder (XML)
get '/feed' do
builder :feed
end
# Liquid
get '/liquid' do
liquid :index
end
# Markdown
get '/markdown' do
markdown :index
end
Template Options
get '/' do
erb :index,
layout: :post, # Custom layout
locals: { name: 'Bob' }, # Local variables
layout_engine: :erb # Layout engine
end
# Disable layout
get '/partial' do
erb :widget, layout: false
end
# Inline template
get '/' do
erb '<h1>Hello <%= name %></h1>',
locals: { name: 'World' }
end
Layouts
views/layout.erb:
<html>
<head>
<title><%= @title || 'App' %></title>
</head>
<body>
<%= yield %>
</body>
</html>
# Set default layout
set :erb, layout: :custom_layout
# Per-route layout
get '/' do
@title = 'Home'
erb :index, layout: :special
end
Filters & Helpers
Before Filters
before do
@note = 'Hi!'
request.path_info = '/foo/bar/baz'
end
# Pattern matching
before '/protected/*' do
authenticate!
end
# Conditions
before agent: /Chrome/ do
# Only for Chrome
end
After Filters
after do
puts response.status
end
after '/api/*' do
response.headers['X-API'] = 'v1'
end
# Modify response
after do
response.body = response.body.upcase
end
Around Filters
around do |block|
puts 'before'
block.call
puts 'after'
end
around '/api/*' do |block|
time = Time.now
block.call
puts "Request took #{Time.now - time}s"
end
Helpers
helpers do
def format_date(date)
date.strftime('%Y-%m-%d')
end
def current_user
@current_user ||= User.find(session[:user_id])
end
def admin?
current_user && current_user.admin?
end
end
get '/posts/:id' do
@post = Post.find(params[:id])
@date = format_date(@post.created_at)
erb :post
end
Helper Modules
module AuthHelper
def authenticated?
!session[:user_id].nil?
end
def require_login
redirect '/login' unless authenticated?
end
end
helpers AuthHelper
before '/admin/*' do
require_login
end
Sessions & State
Session Configuration
enable :sessions
# Custom settings
set :sessions,
expire_after: 2592000, # 30 days
secret: 'your_secret_key'
# Rack session
use Rack::Session::Cookie,
key: 'rack.session',
path: '/',
secret: 'your_secret'
Using Sessions
get '/login' do
session[:user_id] = 123
redirect '/'
end
get '/dashboard' do
user_id = session[:user_id]
halt 401 unless user_id
"Welcome, user #{user_id}"
end
get '/logout' do
session.clear
redirect '/'
end
# Check if key exists
get '/check' do
session.key?(:user_id)
end
Flash Messages
# Using sinatra-flash gem
require 'sinatra/flash'
post '/login' do
if valid_credentials?
flash[:success] = 'Logged in!'
redirect '/'
else
flash[:error] = 'Invalid credentials'
redirect '/login'
end
end
views/layout.erb:
<% if flash[:success] %>
<div class="success"><%= flash[:success] %></div>
<% end %>
<% if flash[:error] %>
<div class="error"><%= flash[:error] %></div>
<% end %>
Cookies vs Sessions
# Cookies (client-side)
get '/set-cookie' do
response.set_cookie 'theme', 'dark'
end
# Sessions (server-side)
get '/set-session' do
session[:theme] = 'dark'
end
# ⚠️ Sessions require enable :sessions
# ⚠️ Cookies visible to client
# ⚠️ Sessions more secure for sensitive data
Error Handling
Error Routes
not_found do
'Page not found'
end
error do
'Server error: ' + env['sinatra.error'].message
end
# Specific error codes
error 403 do
'Access forbidden'
end
error 500..599 do
'Server error'
end
Custom Errors
error MyCustomError do
'Custom error occurred'
end
get '/trigger' do
raise MyCustomError
end
Halt & Pass
get '/admin' do
halt 401, 'Not authorized' unless admin?
'Admin panel'
end
get '/items/:id' do
item = Item.find(params[:id])
pass if item.nil? # Try next route
item.to_json
end
# Alternative route
get '/items/:id' do
'Item not found'
end
Error Handling in Production
configure :production do
set :show_exceptions, false
set :dump_errors, false
set :raise_errors, false
error do
'Application error'
end
not_found do
erb :not_found
end
end
Configuration
Settings
set :port, 4000
set :bind, '0.0.0.0'
set :environment, :production
set :server, 'puma'
# Disable features
disable :logging
disable :protection
# Enable features
enable :sessions
enable :inline_templates
# Check setting
settings.port # 4000
settings.environment # :production
production? # true/false
development? # true/false
Environment Configuration
configure :development do
set :show_exceptions, true
enable :logging
end
configure :production do
set :show_exceptions, false
disable :logging
end
configure :test, :production do
disable :sessions
end
Custom Settings
set :api_key, ENV['API_KEY']
set :max_upload_size, 10 * 1024 * 1024
get '/api' do
api_key = settings.api_key
# Use api_key
end
Environment Variables
# Load from .env (sinatra/config_file)
require 'sinatra/config_file'
configure do
config_file 'config.yml'
end
# Access settings
get '/' do
settings.database_url
end
config.yml:
development:
database_url: "sqlite://dev.db"
production:
database_url: "postgres://prod.db"
Middleware & Rack
Using Middleware
require 'rack/protection'
use Rack::Protection
use Rack::Auth::Basic do |username, password|
username == 'admin' && password == 'secret'
end
use Rack::Static,
urls: ['/css', '/js'],
root: 'public'
Built-in Protection
# Rack::Protection enabled by default
set :protection,
except: [:json_csrf] # Disable specific
# Disable all
set :protection, false
# Custom protection
set :protection,
origin_whitelist: ['https://example.com']
Static Files
set :public_folder, 'public'
set :static, true
# Serve from public/
# public/css/style.css -> /css/style.css
# public/js/app.js -> /js/app.js
# Send file
get '/download' do
send_file 'files/document.pdf',
filename: 'report.pdf',
type: 'application/pdf'
end
Rack Config
config.ru:
require './app'
use Rack::CommonLogger
use Rack::ShowExceptions
run Sinatra::Application
# or
run MyApp.new
rackup config.ru
rackup -p 4000
rackup -E production
Testing
Test Setup (RSpec)
# spec/spec_helper.rb
require 'rack/test'
require 'rspec'
require File.expand_path '../../app.rb', __FILE__
ENV['RACK_ENV'] = 'test'
RSpec.configure do |config|
config.include Rack::Test::Methods
end
def app
Sinatra::Application
end
Request Tests
# spec/app_spec.rb
describe 'My App' do
it 'returns homepage' do
get '/'
expect(last_response).to be_ok
expect(last_response.body).to include('Hello')
end
it 'creates user' do
post '/users', name: 'Bob', email: 'bob@example.com'
expect(last_response.status).to eq(201)
end
it 'returns 404' do
get '/unknown'
expect(last_response.status).to eq(404)
end
end
Session & Headers
it 'sets session' do
get '/login'
expect(last_request.session[:user_id]).to eq(1)
end
it 'sets custom header' do
get '/api'
expect(last_response.headers['X-API-Version']).to eq('1.0')
end
it 'requires authentication' do
header 'Authorization', 'Bearer token'
get '/protected'
expect(last_response).to be_ok
end
Testing Helpers
describe 'Helpers' do
include MyApp.helpers
it 'formats date' do
expect(format_date(Date.today)).to match(/\d{4}-\d{2}-\d{2}/)
end
end
Advanced Features
Streaming
get '/stream' do
stream do |out|
out << "Line 1\n"
sleep 1
out << "Line 2\n"
sleep 1
out << "Line 3\n"
end
end
# Keep-alive
get '/events' do
stream(:keep_open) do |out|
EventMachine.add_periodic_timer(1) do
out << "data: #{Time.now}\n\n"
end
end
end
WebSockets
require 'sinatra-websocket'
get '/ws' do
if request.websocket?
request.websocket do |ws|
ws.onopen do
ws.send('Connected!')
end
ws.onmessage do |msg|
ws.send("Echo: #{msg}")
end
ws.onclose do
puts 'Connection closed'
end
end
else
'WebSocket endpoint'
end
end
Attachments
get '/download' do
attachment 'report.pdf'
send_file 'files/report.pdf'
end
# Inline disposition
get '/view' do
attachment 'report.pdf', :inline
send_file 'files/report.pdf'
end
Cache Control
get '/cached' do
cache_control :public, max_age: 3600
'This is cached for 1 hour'
end
get '/no-cache' do
cache_control :no_cache
'Fresh every time'
end
# Expires header
get '/expires' do
expires 3600, :public
'Expires in 1 hour'
end
# ETag
get '/etag' do
etag 'unique-identifier'
'Content'
end
Redirects
get '/old' do
redirect '/new'
end
# With status code
get '/moved' do
redirect '/new', 301
end
# Back
get '/back' do
redirect back
end
# External redirect
get '/external' do
redirect 'https://example.com'
end
Time & Date Helpers
get '/time' do
time_for(:now) # Current time
time_for(:yesterday) # Yesterday
time_for(:tomorrow) # Tomorrow
end
# Last modified
get '/page' do
last_modified File.mtime('page.html')
send_file 'page.html'
end
Gotchas
Route Order Matters
# ⚠️ Wrong - :id captures "new"
get '/posts/:id' do
Post.find(params[:id])
end
get '/posts/new' do
'New post form'
end
# ✅ Correct - specific routes first
get '/posts/new' do
'New post form'
end
get '/posts/:id' do
Post.find(params[:id])
end
Params Are Strings
get '/posts/:id' do
id = params[:id] # ⚠️ String "123"
Post.find(id.to_i) # ✅ Convert to integer
end
get '/search' do
page = params[:page].to_i || 1
per_page = params[:per_page].to_i || 10
# Search with pagination
end
Block Return Values
get '/' do
@title = 'Home' # ⚠️ Returns 'Home', not rendered
erb :index
end
get '/' do
@title = 'Home'
erb :index # ✅ Last expression is returned
end
get '/' do
process_data # ⚠️ Returns process_data result
erb :index # This is not executed
end
Template Caching
# Development: Templates reloaded
configure :development do
set :reload_templates, true
end
# Production: Templates cached
configure :production do
set :reload_templates, false # Default in production
end
# ⚠️ Changes to templates in production
# require app restart
Sessions Must Be Enabled
get '/set' do
session[:user_id] = 123 # ⚠️ Error if sessions disabled
end
# ✅ Enable sessions
enable :sessions
get '/set' do
session[:user_id] = 123 # Now works
end
Modular vs Classic
# Classic - methods at top level
require 'sinatra'
get '/' do
'Hello'
end
# Modular - must inherit Sinatra::Base
require 'sinatra/base'
class MyApp < Sinatra::Base
get '/' do
'Hello'
end
# ⚠️ Must call run! or use config.ru
run! if app_file == $0
end
Thread Safety
# ⚠️ Instance variables not thread-safe
@counter = 0
get '/increment' do
@counter += 1 # Race condition!
end
# ✅ Use thread-safe storage
configure do
set :counter, Concurrent::Atom.new(0)
end
get '/increment' do
settings.counter.swap { |n| n + 1 }
end
Halt Stops Execution
get '/admin' do
halt 401 unless admin?
'Admin panel' # Not reached if halt is called
end
before do
halt 403 unless authorized?
# Code after halt not executed
end
Deployment
Production Server
# Gemfile
gem 'puma'
# config/puma.rb
workers 2
threads 1, 6
port ENV.fetch('PORT', 4567)
environment ENV.fetch('RACK_ENV', 'production')
bundle exec puma -C config/puma.rb
Heroku Deployment
Procfile:
web: bundle exec puma -C config/puma.rb
heroku create
git push heroku main
heroku config:set RACK_ENV=production
heroku open
Docker
Dockerfile:
FROM ruby:3.2
WORKDIR /app
COPY Gemfile* ./
RUN bundle install
COPY . .
EXPOSE 4567
CMD ["bundle", "exec", "ruby", "app.rb", "-o", "0.0.0.0"]
docker build -t sinatra-app .
docker run -p 4567:4567 sinatra-app
Nginx Reverse Proxy
nginx.conf:
upstream app {
server 127.0.0.1:4567;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Examples
RESTful API
require 'sinatra'
require 'json'
# List all
get '/api/posts' do
content_type :json
Post.all.to_json
end
# Get one
get '/api/posts/:id' do
content_type :json
post = Post.find(params[:id])
halt 404, { error: 'Not found' }.to_json unless post
post.to_json
end
# Create
post '/api/posts' do
content_type :json
post = Post.create(
title: params[:title],
body: params[:body]
)
status 201
post.to_json
end
# Update
put '/api/posts/:id' do
content_type :json
post = Post.find(params[:id])
halt 404 unless post
post.update(
title: params[:title],
body: params[:body]
)
post.to_json
end
# Delete
delete '/api/posts/:id' do
content_type :json
post = Post.find(params[:id])
halt 404 unless post
post.destroy
status 204
end
File Upload
post '/upload' do
unless params[:file]
halt 400, 'No file provided'
end
file = params[:file]
# Validate
halt 400, 'File too large' if file[:tempfile].size > 5_000_000
# Save
filename = file[:filename]
path = File.join('uploads', filename)
File.open(path, 'wb') do |f|
f.write(file[:tempfile].read)
end
redirect "/uploads/#{filename}"
end
Authentication
require 'bcrypt'
helpers do
def authenticated?
session[:user_id]
end
def current_user
@current_user ||= User.find(session[:user_id]) if authenticated?
end
def require_login
redirect '/login' unless authenticated?
end
end
get '/login' do
erb :login
end
post '/login' do
user = User.find_by(email: params[:email])
if user && BCrypt::Password.new(user.password) == params[:password]
session[:user_id] = user.id
redirect '/'
else
flash[:error] = 'Invalid credentials'
redirect '/login'
end
end
get '/logout' do
session.clear
redirect '/'
end
before '/dashboard*' do
require_login
end
Rate Limiting
require 'rack/attack'
class MyApp < Sinatra::Base
use Rack::Attack
Rack::Attack.throttle('req/ip', limit: 100, period: 60) do |req|
req.ip
end
Rack::Attack.throttle('login/ip', limit: 5, period: 60) do |req|
req.ip if req.path == '/login' && req.post?
end
end
Also see
- Sinatra Official Documentation (sinatrarb.com)
- Sinatra README (github.com)
- Sinatra Recipes (recipes.sinatrarb.com)
- Rack Documentation (github.com)
- Sinatra Extensions (sinatrarb.com)
- Padrino Framework - Full-stack framework built on Sinatra (padrinorb.com)