ruby erb 76 lines · 4 tabs

Sinatra Content Negotiation: Serve HTML or JSON by Accept Header

Shared by codesnips Jul 2026
4 tabs
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
4 files · ruby, erb Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Sinatra Content Negotiation: Serve HTML or JSON by Accept Header — share card
Link copied