Serving static files and dynamic views in Ruby

Let's Build a Webapp in Ruby without Rails: Serve Static Files and Render Dynamic Views

This is the second article in the series where we build a simple, yet complete web app in plain Ruby, without using Rails to better understand how Rails works. In this article, we'll learn how to serve static files and use the ERB gem to render dynamic views. All in plain Ruby, without using Rails.

10 min read

In the previous article, we created a simple-yet-complete web application in Ruby without using Rails. All it did was return a "Hello World" response back to the browser. In this post, we will improve our code and make it more Rails-like with the following enhancements:

  1. serve static files like stylesheets and images using the Rack::Static middleware
  2. separate the views from the application logic, and
  3. generate response HTML dynamically using ERB

By the end, you'll have a pretty good understanding of how Rails dynamically generates HTML views.

Read the first article here:

Let’s Build a Web Application in Ruby without Rails
Rails is great for building web apps. But it can be quite overwhelming if you don’t know how web applications work. In this series of articles, we’ll build a simple but complete app in plain Ruby without Rails, to get a deeper understanding and appreciation of everything Rails does for us.

Separation of Concerns

As things stand now, our application mixes the logic and the views together in a single file. Although there's no custom 'application logic' here, you can see the response HTML with h1 tags mixed in the Ruby script.

class App
  def call(env)
    headers = { 'Content-Type' => 'text/html' }
    
    # This is view-specific code
    response = ['<h1>Hello World!</h1>']
    
    [200, headers, response]
  end
end

This is 'generally' not considered good practice in software development, as it tightly couples the logic and view together (however, some folks (see: React) may have differing opinions). You can't change one of them without understanding or affecting the other.

Hence, the first thing we'll do is separate the application logic and the views. The benefit is that you can change the view without worrying about the logic, and vice versa.

💡
You might have heard of the separation of concerns principle. This is what it means in the simplest form.

Separate Views from Application

We will separate the view from the application logic by moving the response HTML out of the app.rb to a file named index.html under the views directory, just like Rails.

<!-- views/index.html -->

<h1>Hello World!</h1>

Now update the app.rb to read the contents of this file to build the response. We will use the File#read method to read the file.

class App
  def call(env)
    headers = { 'Content-Type' => 'text/html' }
    
    response_html = File.read 'views/index.html'
    
    [200, headers, [response_html]]
  end
end

Since File.read returns the HTML contents, we need to wrap the response_html into square brackets, as rack response expects an Array.

There are three benefits to separating the view from the application.

  1. The view gets its own .html or html.erb file with the benefits that come with it, like IntelliSense and code organization.
  2. Anytime you change the view, the application code picks it up automatically (even without the Rack::Reloader middleware), and they can vary on their own pace.
  3. The biggest benefit is that the programmer and the designer can work on the Ruby and HTML code separately, without stepping over each others' toes. I think this was the major benefit Rails introduced in 2004, which was quite a big deal back then.

Refresh the browser to verify that everything is still working.

Let's Make It Pretty with CSS!

Have you noticed that our application is very plain-looking? Let's add some style.

First, we will standardize the index.html by adding a proper HTML structure.

<html>
  <head>
    <title>Application</title>
    <link rel="stylesheet" href="/public/style.css">
    <meta charset="utf-8">
  </head>

  <body>
    <main>
        <h1>Hello World!</h1>
    </main>
  </body>
</html> 

Reloading the browser shouldn't show any difference, except the nice title for the tab. Also, notice that we added the link to a public/style.css file under the <head> tag.

Let's create a new public directory with the following style.css file in it.

/* weby/public/style.css */

main {
  width: 600px;
  margin: 1em auto;
  font-family: sans-serif;
}

Now reload the page.

😣
It's not working!

Our page looks the same, and none of the styles are getting applied.

Before proceeding, can you guess why it's not working?

Let's inspect the response in the DevTools window. You can see that even though it's requesting the style.css file, the response shows contents from the index.html file, instead of the CSS we want.

style.css

What's going on?

The reason is that we are still serving the contents of the index.html file when the request for style.css arrives, as we don't have any logic to differentiate the requests.

class App
  def call(env)
    headers = { 'Content-Type' => 'text/html' }
    
    response_html = File.read('views/index.html')
    
    # same response, i.e. contents of index.html
    # is sent no matter the request
    [200, headers, [response_html]]
  end
end

For any request our application receives, it's sending the same response. Hence it ignores the actual contents of the style.css file.

Let's fix it.

Serving Static Files

When a request for style.css arrives, we want to serve the contents of the style.css file. Additionally, we want to serve style.css as it is, without inserting any dynamic content to it.

The simplest solution would be to check the request path and return the appropriate view.

class App
  def call(env)
    headers = { 'Content-Type' => 'text/html' }

    if env['REQUEST_PATH'].end_with?('.css')
      response = File.read('public/style.css')
    else
      response = File.read('views/index.html')
    end
    
    [200, headers, [response]]
  end
end

However, you can see it can get quite cumbersome as we add more stylesheets and images. It would be nice if there was a declarative way to specify all the static files we'd like to serve from a common folder. The good news is that there's an existing solution to solve this exact problem.

Let's see how we can use the Rack::Static middleware to accomplish this.

According to the documentation,

The Rack::Static middleware intercepts requests for static files (javascript files, images, stylesheets, etc) based on the url prefixes or route mappings passed in the options, and serves them using a Rack::Files object. This allows a Rack stack to serve both static and dynamic content.

Let's update the config.ru file to include the Rack::Static middleware.

require 'rack'
require_relative './app'

# Reload source after change
use Rack::Reloader, 0

# Serve all requests beginning with /public 
# from the "public" folder 
use Rack::Static, urls: ['/public', "/favicon.ico"]

run App.new

This tells the static middleware to serve all requests beginning with /public from the "public" directory and to serve the favicon.ico image from the current weby directory. You can find a sample favicon.ico file on GitHub.

That's it. Nothing needs to change in the app.rb file, and it can remain as it is.

class App
  def call(env)
    headers = { 'Content-Type' => 'text/html' }
    
    response_html = File.read('views/index.html')
    
    [200, headers, [response_html]]
  end
end

Now you might be wondering how this middleware works.

In short, it intercepts the incoming HTTP request before it even hits our application. Then it checks the request path to see if it matches the pre-configured pattern, i.e. /public and serves the required file from this directory. Our application remains blissfully unaware that a request for a static file was even made. Pretty cool!

To learn more about Rack and the concept of middleware, check out the following articles:

The Definitive Guide to Rack for Rails Developers
The word Rack actually refers to two things: a protocol and a gem. This article explains pretty much everything you need to know about Rack as a Rails developer. We will start by understanding the problem Rack solves and move to more advanced concepts like middleware and the Rack DSL.
Middleware in Rails: What It Is, How It Works, and Examples
In this post, We’ll learn about Rails middleware: what it is, why we need it, how it works, and why it’s so important. If you have a fuzzy understanding of middleware, this post will make it concrete. I’ll also show you how to create and test custom middleware for your Rails app.

Now restart the Puma server and reload the page. You can verify in the DevTools that our application sends the correct CSS this time, and our styles are getting applied. The favicon image is also getting loaded as expected. Woohoo!

loads favicon + css
loads favicon + css

Dynamically Rendering Views

As of now, our view is a static HTML. That means each time the page loads, our application renders the same HTML, no matter what.

We want a dynamic view, i.e., a view that can embed variables in it, to render a different page each time you reload the browser. For this, we'll need a view template.

What's a view template?

It's simply a file with predefined slots which will be filled dynamically in the future. Let's convert our index.html to a view template.

Here's how we'll convert the static view into a view template.

  1. Change the name of the index.html file to index.html.erb
  2. Update the <h1> tag to use a title variable instead of a hard-coded string.
<!-- views/index.html.erb -->

<html>
  <head>
    <title>Application</title>
    <link rel="stylesheet" href="/public/style.css">
    <meta charset="utf-8">
  </head>

  <body>
    <main>
      <h1>
        <%= title %>  <!-- Note the % -->
      </h1>
    </main>
  </body>
</html> 

The above view template will insert the value of the title variable to generate the final HTML view that will be shown to the user.

Within an ERB template, Ruby code can be included using both <% %> and <%= %> tags. The <% %> tags are used to execute Ruby code that does not return anything, such as conditions, loops, or blocks, and the <%= %> tags are used when you want output.

For learning more about the erb syntax, refer Rails guides.

The next question is, how to pass this title variable to the view from the app.rb file?

For this, we will use the ERB gem. It allows you to add any Ruby code to a plain text document for dynamically generating a new document. I recommend reading its documentation to learn more.

Here's a simple example that shows how it works:

require 'erb'

name = 'Akshay'
age = 30

erb = ERB.new 'My name is <%= name %> and I am <%= age %> years old'
puts erb.result(binding)

# Output:
#
# "My name is Akshay and I am 30 years old"

There're three things going on in the above example:

  1. After requiring the ERB gem and creating a few variables, we create an instance of the ERB class with a template string.
  2. We create a Binding object by calling the Kernel#binding method. Think of this binding object as a wrapper that includes the current programming environment with variables like name and age, methods, and even the self object. The basic idea is to store the current context in an object for later use.  
  3. The result method on the erb object uses this binding object and the variables defined in that binding to replace the slots in the template string, generating the final string printed above.

Let's use the above structure to render the template from the app.rb file:

require 'erb'

class App
  def call(env)
    headers = { 'Content-Type' => 'text/html' }
    
    title = 'Ruby on Rails'
    erb = ERB.new(html_template)
    response_html = erb.result(binding)

    [200, headers, [response_html]]
  end

  def html_template
    File.read 'views/index.html.erb'
  end
end

Now reload the browser. If everything worked, you should see our (new) title Ruby on Rails on the page.

Our application is generating views on the fly 🔥

Not So Dynamic, Yet!

Now you might have questions about the dynamic nature of the above code, as we're still using the hard-coded string Ruby with Rails. You're correct, but the important part is that we are fetching the value from the title variable. This value can come from anywhere, which is the part that makes it dynamic.

To illustrate this, let's tweak our code to read the title from the query string instead of a hard-coded string.

We will add a new method named get_title which will parse the env hash to fetch the query string, and return its value.

require 'erb'

class App
  def call(env)
    headers = { 'Content-Type' => 'text/html' }
    
    title = get_title(env) # new code here
    template = ERB.new(template_html)
    response_html = template.result(binding)

    [200, headers, [response_html]]
  end

  def get_title(env)
    query = env['QUERY_STRING'] # "title=ruby"
    values = query.split('=')   # ["title", "ruby"]
    values[1]                   # ruby
  end

  def template_html
    File.read 'views/index.html.erb'
  end
end

Now append ?title=whatever to the existing URL and refresh the browser. You should see the browser display whatever text you typed in the URL.

Congratulations, you now have a fully dynamic web application. We could improve it further, but I'll stop here as this post has already crossed 2000 words and I need to go to bed.

In future articles in this series, we'll explore the following topics together.

  • Building a custom router to handle requests based on URL patterns
  • Improving the project structure and organization
  • Introducing the concepts of controllers and models
  • Handle errors and logging
  • Process form inputs along with query strings into a params object
  • Connect to the database to store and fetch data
  • Adding middleware to handle specific tasks like authentication
  • and much more...

If those sound interesting to you, consider subscribing to the blog.

Trust me, it's going to be a lot of fun, so stay tuned!!


That's a wrap. I hope you liked this article and you learned something new.

As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I look forward to hearing from you.

If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.