Sometimes it's useful to be able to store ERB templates in your database. Say you want to want to have email templates that are editable by admins, or you want to create blog posts that are editable (like this site has) and want access to route helpers or just the full power of ruby in those HTML templates. There doesn't seem to a proper "Rails way" of doing this, but it's a pretty common need. So, here's what I do:

First, I install another erb engine that's more customizable [1]. Don't worry, Rails will still use its default ERB engine for rendering template files from disk. This gem will just be used to solve our problem.

Gemfile:

gem "erubis" # I tested it with 2.7.0 but it's a stable gem so I think most versions will work

Then we need a custom "context" class for erubis. The context object is where we pass variables we want to access from the template.

lib/my_erb_context.rb

class MyErbContext < Erubis::Context
  include Rails.application.routes.url_helpers
  # allow `url_for` in the erb template.
  include ActionView::RoutingUrlFor

  def initialize(vars = {})
    super()
    vars.each do |var, val|
      self[var] = val # allow it to be accessed in the template as an instance variable
      self.singleton_class.attr_reader(var) # allow it to be accessed as a reader method
    end
  end

  # The rest of these methods are needed to be able to use named routes

  def routes
    Rails.application.routes
  end

  def controller
  end

  def default_url_options
    { only_path: false }
  end
end

Now, to put it all together:

template_source = <<SRC
<p>It's <%= @time_now.strftime("%H:%M:%S") %></p><!-- instance var -->
<p>In 5 hours it will be <%= (time_now + 5.hours).strftime("%H:%M:%S") %></p><!-- reader method -->
<a href="<%= new_session_path %>">Login here!</a><!-- routes -->
SRC
template = Erubis::Eruby.new(template_source)
context = MyErbContext.new(time_now: Time.zone.now) # pass in whatever you want
output = template.evaluate(context)

We could create a helper method for formatting time in this example and either add that method in the context class, pass a lambda into the input vars of the initializer or extend the context object with a method after initialization. Let's say we want to access the link_to helper method to generate an a tag. For that, we just need to include the proper module into the context class, in this case it's ActionView::Helpers::UrlHelper. Want all the helper methods available to regular Rails templates? Include ActionView::Helpers itself.

Bonus Round

Okay, so we can access variables and routes from the templates. But what about partials? Well, here it starts to get a bit messy. I'm sure there are cleaner solutions, but here's mine after playing around with it for 30 minutes and reading some ActionView source code. So remember, we're trying to get render(partial: 'some_partial', locals: { }) working inside the template. We add the following to the initialize method:

params = vars.delete(:params) || {}
# vars.each { ... }
lookup_context = ActionView::LookupContext.new(
  Dir.glob(Rails.root.join('app', 'views', '*'))
)
# this object is solely responsible for `render` calls
@base = MyViewClass.new(lookup_context, vars.dup, controller=nil, self, params)

Here's that new class:

# I namespace this inside MyErbContext
class MyViewClass < ActionView::Base
  def initialize(lookup_context, assigns, controller, erb_context, params = {})
    super(lookup_context, assigns, controller)
    @_my_erb_context = erb_context
    @_my_params = params
  end

  # implementation detail, needed
  def compiled_method_container
    singleton_class
  end

  # in case we access params inside the partial
  def controller
    fake = Object.new
    params = @_my_params
    fake.define_singleton_method(:params) do
      params
    end
    fake
  end

  # all the route methods and other helpers we include in the Context class
  # get delegated back to the context object here
  def method_missing(name, *args, &block)
    if @_my_erb_context.respond_to?(name, true)
      @_my_erb_context.send(name, *args, &block)
    else
      super
    end
  end
end

And to hook it up:

class MyErbContext
  delegate :render, to: :@base
end

I tested this on Rails 7.2.2, but it will work with older versions of Rails, it just might need a bit of tweaking. Finally, remember that regular users should never be able to execute arbitrary code on your web server, so only save ERB templates to the database that a trusted user (an admin) has created.

[1] As I'm writing this the default Rails ERB engine is erubi, which I didn't really try to get working, admittedly. It may be just as customizable, but I'm used to erubis and erubi lacks documentation at the moment.