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.
end of the article

Tags: ruby hanami

This article was written by me – Paweł Świątkowski – on 29 May 2026. I'm on Fediverse (Ruby-flavoured account, Elixir-flavoured account). Let's talk.