Niza Toshpulatov

April 13, 2024

Niza on Rails

I’ve been doing web development for over 4 years now. Most of it has been using JavaScript-based technologies such as Node.js and React. Professionally, these two dominate the landscape. But my favorite JS technology at the moment is Deno. I’ve used it at work on some internal tools and even built a couple of libraries for it.

I love how easy it is to get started with Deno. It has so many things built-in: a robust HTTP server, a testing framework, a Cron scheduler, and even a key-value database! And did I mention that it supports TypeScript natively? The technology is incredible.

Always Vanilla

Today, Deno is most definitely my first pick when I set out to build a JS-based app. When it comes to building my own website however, I always went vanilla. By that, I mean no frameworks, no tooling. Just HTML, CSS, JavaScript, and good old browser.

This was an exercise and a journey of sorts that I embarked on every year or so. It was an excellent way to assess the current state of web technologies, especially on the frontend. How much can I build using only HTML and CSS? What are those cool JS APIs that I can integrate? My website, with its 0kb traffic, was a perfect testing ground.

I’ve done it recently with my niza.nz (this is a GitHub repo URL) project as well. I even highlighted a “yet-another-js-framework” I built for it that aims to improve the experience of working with web components. Building vanilla is fun, I recommend it to everyone! But it has one inherent limitation that you just can’t overcome. It’s frontend only.

Natural Choice

With some personal business goals in mind, I decided to rewrite my website in something that has a backend. Natural options crossed my mind: Next.js, Remix, Fresh, All are JS-based, but the latter is powered by Deno. I was extremely tempted to choose Fresh as I already had an experience with it and it was mostly great. But it’s been over 4 years of pure JavaScript. I was getting tired of all these frameworks...

I started to look at some other established and somewhat “outdated” technologies. PHP is the OG of web development and to this day, most of the web is PHP-based. But due to its age and hype of JS-based stuff, PHP had gained a reputation of being a legacy technology. I don’t really agree with this stance, but I do dislike its syntax.

- So, PHP, I do respect you. Thank you for building most of the internet, but you’re very ugly and I want to write my code in something pretty. Thanks.

What else is out there?

Getting on Rails

My all-time favorite email client is HEY. Once I got my HEY address and started using the software, it literally changed my life. HEY’s unique approach to filtering and sorting emails was so refreshing and liberating, I started implementing the same principles of organization in other parts of my life.

This post is not sponsored by HEY, by the way, I just really enjoy the product.

After doing some digging, I discovered that HEY is built using Ruby on Rails. I wouldn’t say that it was a shock, but it sure did surprise me. The application that is so responsive and robust was built on a “legacy” technology as JS folk would put it. Then I remembered writing a couple hundred lines of Ruby code back in my university days. So, I decided to check out RoR’s website.

To my delight, Ruby on Rails was very much alive! And much like PHP, it was getting updates regularly. They refreshed the homepage of the framework with a 37signals-style design and they started refreshing their docs as well. This wasn’t the moment when I decided to use Rails as the base for my website, but it sure did tip the scale into that direction. After weighing some pros and cons, I finally decided to give Rails a go.

- Sorry, Fresh. I will do some more side projects with you I promise! 🙏

Now, let’s get on that train!

First Contact

As with any technology, you start with documentation. A “Getting Started” guide, that will give you all of the basics and nudge you in the right direction. Rails has one, but the website and the form of presentation is not as nice as some of those fancy JS frameworks. To be fair, Rails is migrating their docs to a new design that is much prettier looking, but as far as I can tell, this is just a front lift, and doesn’t really change the way docs are presented. This is not a post to give a comprehensive feedback on Rails docs, so I will put it shortly: it’s outdated.

Despite being less beginner-friendly and less attractive, the documentation is quite thorough. It outlines every important detail and showcases best practices. It also very clearly states the rails way of doing things, which I do personally like.

After getting over the initial hurdle of re-learning the Ruby syntax, building a sample project (not on Rails), and getting my coding environment ready, I finally ran the Rails project init command.

A Monolith of Monoliths

Rails is for building monolithic applications. It has all the necessary generators and it has a clear project structure that you must follow. I really like the enforcement of convententions as it alleviates the stress and the burden of managing the structure of my code. Instead of contemplating on folder structures and variable names, I can focus on what matters - the code and the product.

Rails uses the MVC architecture and it is quite sensible. In some way, I was also using MVC with my nz library, and it seems like the most simple and scalable way of building applications. With its generators, Rails enforces and scales your MVC app with minimal friction.

Models and Controllers are written in Ruby, naturally.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  around_action :switch_locale

  def switch_locale(&action)
    locale = params[:locale] || I18n.default_locale
    I18n.with_locale(locale, &action)
  end

  def default_url_options
    { locale: I18n.locale }
  end
end

Views are .erb templating files that are similar to PHP. You can embed Ruby code directly into them.

<% direction = local_assigns.fetch(:direction, "left") %>
<% angle = case direction
  when "up" then 90
  when "right" then 180
  when "down" then 270
  else 0
end %> 

<%= render "icon/icon_base",
  class: "icon-arrow",
  transform: "rotate(#{angle} 12 12)" do %>
  <line x1="19" y1="12" x2="5" y2="12"></line>
  <polyline points="12 19 5 12 12 5"></polyline>
<% end %>

These base features allowed me to build most of my website. After all, in its current state, it’s just static content. However, I have some interactive elements: dialogs, tabs, and code blocks with syntax highlighting. All of these require JavaScript to function. Luckily, Rails does not prohibit you from using JS. You can either embed it directly in your view templates:

<script>
  const tablists = document.querySelectorAll("[role='tablist']");
    
  for (const list of tablists) {
    const tabs = list.querySelectorAll("[role='tab']");
    const tabsWithPanels = [];
    
    for (const tab of tabs) {
      const panel = document.querySelector(
        `#${tab.getAttribute("aria-controls")}`,
      );
    
      if (panel) {
        tabsWithPanels.push([tab, panel]);
      }
    }
    
    for (const [tab, tabpanel] of tabsWithPanels) {
      tab.addEventListener("click", () => {
        tabsWithPanels.forEach(([tab, panel]) => {
          tab.setAttribute("aria-selected", false);
          panel.hidden = true;
        });
    
        tab.setAttribute("aria-selected", true);
        tabpanel.hidden = false;
      });
    }
  }
</script>

Or, you can use import maps and the entry point application.js file to manage and execute your JavaScript.

Rails lets you do much more, but for my website and for my very first Rails experience, these features were enough.

Implementing Components

Since I come from the world of JS frameworks, I tried to implement some of the patterns I’m used to. One such pattern is to use components to isolate repeating blocks of JS, CSS, and HTML. In Rails, this didn’t go as smoothly from the start as I hoped it would.

Rails uses something called partials for repeating blocks of HTML. I spent quite a bit of time trying to figure out how to isolate JS and CSS into a partial. The problem was that if you just include a style and a script tag into a partial, they will get included into your page as many times as you render the partial. This obviously wasn’t something I wanted.

After doing some research, I discovered a way to include a block of HTML only once into the page when a partial is rendered. For this, I had to implement custom methods in my application_helper.rb file that are then shared with all of my controllers.

# app/helpers/application_helper.rb
module ApplicationHelper
  def content_for_scripts(id, &block)
    content_for_once(:scripts, id, &block)
  end

  def content_for_styles(id, &block)
    content_for_once(:styles, id, &block)
  end

  def content_for_once(key, id, &block)
    @included_blocks ||= {}
    @included_blocks[key] ||= []

    unless @included_blocks[key].include?(id)
      @included_blocks[key] << id
      content_for(key, &block)
    end
  end
  # …

As you can see, the content_for_once method keeps all of the passed blocks in a hashmap using the key and the id parameters. In the unless block, I make sure that we don’t render the same block twice. Using this method, I created the other two helpers: content_for_scripts, content_for_styles. They can be directly used in a partial and the code block within them will be included into the page only once. But before using them in a partial, I had to make sure that the :scripts and :styles keys are referenced in the application layout:

<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
  <head>
    <!-- … -->
    <%= content_for?(:head) ? yield(:head) : "" %>
    <%= content_for?(:styles) ? yield(:styles) : "" %>
  </head>
  <body>
    <!-- … -->
    <%= content_for?(:scripts) ? yield(:scripts) : "" %>
  </body>
</html>

Here, I’m including all of my style blocks into the head of the document, and all of my scripts are going into the end of the body.

And here is an example of a component that has HTML, CSS, and JavaScript.

<% id = local_assigns.fetch(:id) %>
<% aria_label = local_assigns.fetch(:aria_label, t("tablist.default_label")) %>
<% tabs = local_assigns.fetch(:tabs, []) %>

<div id="<%= id %>" aria-label="<%= aria_label %>" role="tablist">
  <% tabs.each do |tab| %>
    <% label = tab[:label] == "Result" ? t("tablist.result_tab") : tab[:label] %>
    <button
      role="tab"
      aria-selected="<%= tab[:selected] ? "true" : "false" %>"
      aria-controls="<%= tab[:id] %>"
      aria-label="<%= t("tablist.aria_label", tab: label) %>"
      id="<%= tab[:id] %>-tab"
    >
      <%= label %>
    </button>
  <% end %>
</div>

<% content_for_scripts "tablist" do %>
  <script type="module">
    const tablists = document.querySelectorAll("[role='tablist']");
    
    for (const list of tablists) {
      const tabs = list.querySelectorAll("[role='tab']");
      const tabsWithPanels = [];
    
      for (const tab of tabs) {
        const panel = document.querySelector(
          `#${tab.getAttribute("aria-controls")}`,
        );
    
        if (panel) {
          tabsWithPanels.push([tab, panel]);
        }
      }
    
      for (const [tab, tabpanel] of tabsWithPanels) {
        tab.addEventListener("click", () => {
          tabsWithPanels.forEach(([tab, panel]) => {
            tab.setAttribute("aria-selected", false);
            panel.hidden = true;
          });
    
          tab.setAttribute("aria-selected", true);
          tabpanel.hidden = false;
        });
      }
    }
  </script>
<% end %>
<% content_for_styles "tablist" do %>
  <style>
    [role="tablist"] {
      display: flex;
      gap: 0.5rem;
      justify-content: space-between;
      white-space: nowrap;
      overflow-x: auto;
    }

    [role="tab"] {
      flex: 1;
      border-top-left-radius: var(--br-lg);
      border-top-right-radius: var(--br-lg);
      border-bottom-left-radius: 0;
      border-bottom-right-radius: 0;
      background-color: hsl(var(--theme-light));
      color: var(--fg-color);

      &[aria-selected="true"] {
        background-color: var(--fg-color);
        color: var(--bg-color);
      }

      &:hover {
        filter: brightness(1.1);

        &[aria-selected="true"] {
          filter: brightness(0.9);
        }
      }
    }
  </style>
<% end %>

Internationalization

I didn’t want to just rewrite the nz-version of my website. I wanted to add at least some new features. One of them was a bottom pinned sticky navigation bar and the other was to implement localization. Currently, I live and work in Bratislava, Slovakia; so I decided to add Slovak locale into my website alongside the English default. Once again, Rails being the beast it is, already had a guide ready for me.

Following through the guide was quite straightforward, you’ve already seen some parts of the implementation in the snippets above. The i18n is a very basic feature that I didn’t have any issues with so I won’t go into detail about it.

Deployment

Finally, when I had the app working and polished, I needed to run it somewhere. After skimming through some conversations on Reddit, I discovered that I have couple of viable options:

I had good experience with Linode at my previous job, so I decided to give it a go. They had a template ready and after a couple of clicks I had the server running. I used the OpenLiteSpeed server template as it looked interesting, but ultimately I regretted my choice. There is nothing wrong with the server and I’m sure it is indeed lite and speedy, but it was a massive overkill for my static website. So I quickly shut down my Linode server and decided to try the alternatives.

Render has a beautiful website and similar to some of those fancy JS platforms, it has beginner friendly UI. Once again, a couple of clicks and I had my deployment running. But it failed… And I don’t know why. I’m sure if I spent an hour or so debugging the issue and perhaps contacted Render’s support, I would get my issue resolved, but I just wasn’t in the mood. Thus, the last option remained.

I installed Fly’s CLI, ran a single command and the deployment started! And then it failed… again… and again… but after fixing a couple of things, it went through! One advantage Fly has over Render, is that it provides clear logs of what is happening. If Render had such transparent logging, I’m sure that I would be able to resolve whatever the issue I was having when trying to deploy it then. Just FYI, here is what my fly.toml looks like:

app = '<redacted>'
primary_region = '<redacted>'
console_command = '/rails/bin/rails console'

[build]

[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0
  processes = ['app']

[checks]
  [checks.status]
    port = 3000
    type = 'http'
    interval = '10s'
    timeout = '2s'
    grace_period = '5s'
    method = 'GET'
    path = '/up'
    protocol = 'http'
    tls_skip_verify = false

    [checks.status.headers]
      X-Forwarded-Proto = 'https'

[[vm]]
  memory = '<redacted>'
  cpu_kind = '<redacted>'
  cpus = <redacted>

Conclusion

niza.cz is live and it is on rails! Go check it out, it will take you just a minute to skim through this very small website. I plan to work on it in my free time, but if you spot an issue, do let me know.

I hope you enjoyed this lengthy post. This was a relatively long project, I will try to get back to more simple and more vanilla stuff. Although right now, thanks to all videos by TJ, I’m tempted to start learning Rust or Go…

Anyways, the source code for the website is publicly available on my GitHub. Check it out if you're interested. Have a nice rest of your day, and see you in the next one!


Niza ✌️

About Niza Toshpulatov

Hi! I'm Niza, a web developer, productivity enthusiast, and a tech nerd. I love tinkering with software and in my free time, I like to compose music and write silly poems. Check out my website if you want to learn more about my work.