What if Hanami had templateless views?
by Paweł Świątkowski
29 May 2026
In the new glorous website of Hanakai there are two “getting started” guides for Hanami: one for a “web app” (meaning, a fullstack app) and one for an API app. Even though those two guides follow building the same application (bookshelf), they differ quite a bit in places. Sometimes it’s absolutely justified (you don’t have HTML templates in API app), sometimes it feels justified, but can also provoke a thought. This is one of these thoughts.
Let’s look about 30% in the guide at “Fetching books from database” subsection. It demonstrates how to get a books index page, paginated. This is how is looks for a fullstack app (combined into one listing for clarity):
# action
module Bookshelf
module Actions
module Home
class Index < Bookshelf::Action
def handle(request, response)
end
end
end
end
end
# view
module Bookshelf
module Views
module Books
class Index < Bookshelf::View
include Deps["repos.book_repo"]
expose :books do
book_repo.all_by_title
end
end
end
end
end
# template
<h1>Books</h1>
<ul>
<% books.each do |book| %>
<li><%= book[:title] %>, by <%= book[:author] %></li>
<% end %>
</ul>
… and this is for API:
module Bookshelf
module Actions
module Books
class Index < Bookshelf::Action
include Deps["repos.book_repo"]
def handle(request, response)
books = book_repo.all_by_title
response.format = :json
response.body = books.map(&:to_h).to_json
end
end
end
end
end
I looked at these code listing probably dozens of times, until it hit me:
Why are actions so much different here?
The fullstack app action is empty. It does nothing. Okay, this is not true, it actually does something, but it’s hidden by the framework mechanics, but it does more or less that:
def handle(request, response)
response.format = :html
response.body = render_the_view_somehow
end
It looks more similar to the API version now, but there is one jarring difference now: the API action needs to know about a repository and how to call it. Or does it actually need that? The instinctive answer is: yes. We don’t render HTML so we don’t have the view layer, so there’s no other choice, it needs to go to the controller! This is we built JSON APIs in Rails and Sinatra for decades. Now as a long-time Phoenix user and its dead-viewFor those unfamiliar, “dead views” is a term coined in opposition to LiveView - in Ruby terms it’s just a regular server-rendered view. enjoyer, I dare to challenge that.
Without further ado, I think it could look like this:
# action
module Bookshelf
module Actions
module Books
class Index < Bookshelf::Action
def handle(request, response)
response.format = :json
end
end
end
end
end
# view
module Bookshelf
module Views
module Books
class Index < Bookshelf::View
include Deps["repos.book_repo"]
expose :books do
book_repo.all_by_title
end
render do
{ books: books.map(&:to_h) }
end
end
end
end
end
This way we keep original separation of concerns from the fullstack Hanami app - the action deals with request/response stuff, while the view is responsible for the shape and content of the response. In a large app both of them will grow, but they will grow independently. When we add permissions to see books index page, only action will change, indicating that the format of the response remains unchanged. Similarly, when we need to change the structure of the JSON, the change will be local to the view class.
Implemetation
Currently Hanami relies on Tilt and file-based templates to render views. In order to achieve what I described above, we would need to define custom Renderer and Rendering classes:
class InlineRenderer < Hanami::View::Renderer
def initialize(config, render_proc)
super(config)
@render_proc = render_proc
end
def template(_name, _format, scope, &_block)
@template ||= scope.instance_exec(&@render_proc).to_json
end
end
class InlineRendering < Hanami::View::Rendering
def initialize(config:, format:, context:, render_proc:)
super(config:, format:, context:)
@renderer = InlineRenderer.new(config, render_proc)
end
end
The we use it in our application’s View class:
module Bookshelf
class View < Hanami::View
extend Dry::Core::ClassAttributes
defines :render_proc
def rendering(format: config.default_format, context: config.default_context)
InlineRendering.new(config: config, format: format, context: context, render_proc: self.class.render_proc)
end
def self.render(&block)
render_proc block
end
end
end
You can see it in action in this repo.
The doors it opens
Just to be clear, what I show here is the result of my experimentation, not a sneak peek into what Hanami will offer soon. This experiment was, however, quite important to me.
- Even though currently Hanami is kind of coupled to Tilt and its HTML templates, it’s possible to change that in about 20 lines of code.
- It opens up possibility to integrate Phlex into
hanami-view, not replacing it - Many people think JSON responses are simple compared to HTML ones. For sure they tend to be more focused, but they definitely can grow a lot. Having the whole view layer at your disposal means that API application can have this pain eased by Hanami as much as fullstack apps.