Building a Custom Page Finder for Rails: Beyond High Voltage

If you've built Rails applications, you've likely used the popular High Voltage gem to handle static pages. But what happens when your application outgrows the constraints of this gem, particularly when using modern view components like Phlex within a multi-tenant or namespaced application?

In this post, I'll share how I built a custom page finder for our Rails application that supports traditional ERB templates and Phlex views across multiple namespaces without relying on external gems.

The Problem

I used High Voltage to render static pages in my multi-tenant Rails application. Each "app" (or tenant) within our application had its own namespace and potentially its own versions of each static page.

As I migrated from ERB to Phlex views, we encountered several challenges:
  1. **Namespace Awareness**: High Voltage wasn't designed to be aware of our app-specific namespaces
  2. **Phlex Support**: We needed to find both ERB templates and Phlex view classes
  3. **Resolution Order**: We needed a clear, predictable resolution order to find the correct view
  4. **Debugging Difficulty**: It was hard to debug why certain views weren't being found

The Solution

I decided to build my own custom page finder that would:
  1. Be namespace-aware for my multi-tenant setup
  2. Support both Phlex views and ERB templates
  3. Have a clear, customizable resolution order
  4. Include debugging capabilities that could be toggled via environment variables

The Implementation

Here's the complete implementation of our custom page finder:

Pages Controller

lang-ruby
# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  skip_before_action :authenticate!
  before_action :set_locale

  layout(LAYOUT_PROC)

  def show
    debug_page_finder("======== PAGE DEBUG ========")
    debug_page_finder("Current.app: #{Current.app}")
    debug_page_finder("params[:id]: #{params[:id]}")
    debug_page_finder("Current page: #{current_page}")
    
    if current_page.is_a?(String)
      render(
        template: current_page,
        locals: { current_page: },
      )
    else
      render current_page.new
    end
  end

  def sitemap
    @articles = Article.published.select(:id, :slug, :updated_at)

    if Rails.root.join("app/views/pages/#{Current.app}/sitemap.xml.erb").exist?
      render "pages/#{Current.app}/sitemap", layout: false
    else
      render "pages/sitemap", layout: false
    end
  end

  def robots
    render "pages/robots", layout: false, content_type: "text/plain"
  end

  private

  def current_page
    @current_page ||= page_finder.find
  end

  def page_finder
    @page_finder ||= PageFinder.new(params[:id])
  end

  def debug_page_finder(message)
    Rails.logger.debug(message) if ENV["DEBUG_PAGE_FINDER"].present?
  end

  class PageFinder
    attr_reader :page_id

    def initialize(page_id)
      @page_id = page_id
    end

    def find
      # First try to find a matching Phlex view
      phlex_view = find_phlex_view
      return phlex_view if phlex_view
      
      # Then try to find a matching ERB template
      path = find_erb_template
      path || raise(ActionController::RoutingError, "No such page: #{page_id}")
    end

    private
    
    def debug_page_finder(message)
      Rails.logger.debug(message) if ENV["DEBUG_PAGE_FINDER"].present?
    end

    def find_phlex_view
      classified_app = Current.app&.classify
      classified_page_id = page_id.camelize # because classify, is silly (singularizes weirdly)
      
      debug_page_finder("Finding Phlex View:")
      debug_page_finder("App: #{Current.app}, page_id: #{page_id}")
      debug_page_finder("Classified App: #{classified_app}, Classified page_id: #{classified_page_id}")
      
      # Try in this order:
      phlex_views = []
      
      # 1. App-specific view with the current page_id: Views::Coding::Home
      phlex_views << "Views::#{classified_app}::#{classified_page_id}" if classified_app.present?
      
      # 2. App-specific view under Pages namespace: Views::Pages::Coding::Home
      phlex_views << "Views::Pages::#{classified_app}::#{classified_page_id}" if classified_app.present?
      
      # 3. Standard Pages view: Views::Pages::Home
      phlex_views << "Views::Pages::#{classified_page_id}"
      
      # 4. Direct view: Views::Home
      phlex_views << "Views::#{classified_page_id}"
      
      # Try each view class in order
      phlex_views.each do |view_class|
        debug_page_finder("Trying: #{view_class}, exists: #{Object.const_defined?(view_class)}")
        return view_class.constantize if Object.const_defined?(view_class)
      end
      
      nil
    end
    
    def find_erb_template
      content_path = Rails.root.join("app", "views", "pages")
      
      # Try in this order:
      templates = []
      
      # 1. App-specific page: app/views/pages/coding/home.html.erb
      templates << content_path.join(Current.app, page_id).to_s if Current.app.present?
      
      # 2. Regular page: app/views/pages/home.html.erb
      templates << content_path.join(page_id).to_s
      
      # Check each template path
      templates.each do |template_path|
        erb_path = "#{template_path}.html.erb"
        debug_page_finder("Checking ERB template: #{erb_path}")
        return template_path if File.exist?("#{erb_path}")
      end
      
      nil
    end
  end
end

Routes Configuration

No changes compared to HighVoltage
lang-ruby
# config/routes.rb
Rails.application.routes.draw do
  # Static pages
  get "/*id" => "pages#show", as: :page, format: false

  root to: "pages#show", id: "home"
end

How It Works

Let's break down how this custom page finder works:

1. Initialization

When a request comes in for a static page like `/about`, the `PagesController` creates a new `PageFinder` with the page ID from the params.

2. Resolution Order

The page finder attempts to find a matching view in the following order:

For Phlex Views:

1. **App-specific direct view**: `Views::{App}::{Page}`  
   Example: `Views::Coding::Home`
2. **App-specific view under Pages namespace**: `Views::Pages::{App}::{Page}`  
   Example: `Views::Pages::Coding::Home`
3. **Standard Pages view**: `Views::Pages::{Page}`  
   Example: `Views::Pages::Home`
4. **Direct view**: `Views::{Page}`  
   Example: `Views::Home`

For ERB Templates:

If no Phlex view is found, the page finder looks for ERB templates in:
1. **App-specific template**: `app/views/pages/{app}/{page}.html.erb`  
   Example: `app/views/pages/coding/home.html.erb`
2. **Standard template**: `app/views/pages/{page}.html.erb`  
   Example: `app/views/pages/home.html.erb`

3. Rendering

Once a view is found, it's handled differently based on its type:
- **Phlex Views**: Instantiated and rendered directly
- **ERB Templates**: Rendered as traditional templates

4. Debugging

The PageFinder includes comprehensive debugging support that can be enabled with an environment variable:
DEBUG_PAGE_FINDER=1 bin/rails server