require 'sinatra'
require 'json'
require_relative 'book'
require_relative 'helpers'
set :books, {
1 => Book.new(1, 'The Pragmatic Programmer', 'Hunt & Thomas'),
2 => Book.new(2, 'Refactoring', 'Martin Fowler')
}
get '/books/:id' do
book = settings.books[params[:id].to_i]
halt 404, not_found_body('Book not found') unless book
respond_with(book, :show)
end
# API-only endpoint: Sinatra skips this route unless JSON is acceptable.
get '/books', provides: :json do
content_type :json
settings.books.values.map(&:to_h).to_json
end
get '/books' do
erb :index, locals: { books: settings.books.values }
end
not_found do
content_type :json if request.accept?('application/json')
request.accept?('application/json') ? not_found_body('Not found') : erb(:not_found)
end
helpers do
def respond_with(resource, template)
if request.accept?('application/json')
content_type :json
resource.to_h.to_json
else
content_type :html
erb template, locals: { book: resource }
end
end
def not_found_body(message)
{ error: message }.to_json
end
def wants_json?
request.accept?('application/json') && !request.accept?('text/html')
end
end
class Book
attr_reader :id, :title, :author
def initialize(id, title, author)
@id = id
@title = title
@author = author
end
def to_h
{
id: id,
title: title,
author: author,
links: { self: "/books/#{id}" }
}
end
end
<article class="book">
<h1><%= book.title %></h1>
<p class="author">by <%= book.author %></p>
<p class="meta">Resource ID: <%= book.id %></p>
<p>
<a href="/books/<%= book.id %>">Permalink</a> ·
<a href="/books">Back to catalog</a>
</p>
</article>
This snippet shows how a small Sinatra application serves the same resource as either HTML or JSON depending on what the client asks for through the HTTP Accept header. Content negotiation is a core REST idea: one URL identifies one resource, and representation is a separate concern chosen at request time. That lets a browser hitting /books/1 get a rendered page while an API consumer sending Accept: application/json gets machine-readable data — without inventing .json suffixes or duplicate routes.
The app.rb tab holds the routes. Sinatra exposes negotiation directly through the request.accept? predicate and the provides route condition. The content_type helper flips the response Content-Type, which is why the same block can branch on format. The route for /books/:id looks up a Book and delegates rendering to a shared respond_with helper so the branching logic lives in exactly one place. A second route uses provides :json as a route condition, meaning Sinatra only matches it when the client actually accepts JSON — a clean way to expose an API-only endpoint.
The helpers.rb tab defines respond_with, the heart of the negotiation. It checks request.accept?('application/json') first because explicit API clients should win, falling back to HTML for browsers, whose Accept header typically lists text/html ahead of everything. On the JSON path it sets content_type :json and serializes via the resource's own to_h; on the HTML path it renders an ERB template. Ordering the checks matters: browsers often send */*, so testing for JSON explicitly avoids accidentally handing HTML-less clients a web page.
The Book model tab is a plain Ruby object with a to_h that defines the JSON shape, keeping serialization out of the routes. The show.erb tab is the HTML representation.
The trade-off is that header-based negotiation is invisible in the URL and harder to debug or link to than an extension; teams that value shareable .json links sometimes combine both. For a compact, correct REST surface, though, provides and request.accept? are the idiomatic Sinatra tools.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.