Skip to main content
Documentation Architecture

Server-Rendered Routes

Bridgetown comes with a production-ready web server based on the battle-hardened one-two punch of Rack + Puma. On top of Puma we’ve layered on Roda, a refreshingly fast & lightweight web routing toolkit created by Jeremy Evans. On a basic level, it handles serving of all statically-built site files you access when you run bin/bridgetown start.

Bridgetown lets you create your own Roda-based API routes in the server/routes folder. An example ships in each new Bridgetown project for you to examine (server/routes/hello.rb.sample). These routes provide the standard features you may be accustomed to if you’ve used Roda standalone.

However, to take full advantage of all the Bridgetown has to offer, we recommend you load up our SSR and Dynamic Routes plugins. Simply add to your configuration in config/initializers.rb:

init :ssr
init :"bridgetown-routes"

# …or you can just init the routes, which will init :ssr automatically:

init :"bridgetown-routes"

For more information on setup, read our documentation on configuring Roda and on configuring Puma.

Table of Contents #

Bridgetown SSR via Roda #

Server-Side Rendering, known as SSR, has made its peace with SSG (Static Site Generation), and we are increasingly seeing an SSG/SSR “hybrid” architecture emerge in tooling throughout the web dev industry.

Bridgetown takes advantage of this evolving paradigm by providing a streamlined path for booting a site up in-memory. This means you can write a server-side API to render content whenever it is requested. Here’s an example of what that looks like:

# ./server/routes/preview.rb

class Routes::Preview < Bridgetown::Rack::Routes
  route do |r|
    r.on "preview" do
      # Our special rendering pathway to preview a page
      # route: /preview/:collection/:path
      r.get String, String do |collection, path|
        item = Bridgetown::Model::Base.find("repo://#{collection}/#{path}")

        unless item.content.present?
          response.status = 404
          next Bridgetown::Model::Base.find("repo://pages/_pages/404.html")
        end

        item
      end
    end
  end
end

This route handles any /preview/:collection/:path URLs which are accessed just like any other statically-generated resource. It will find a content item via a repo origin ID and render that item as HTML. For example: /preview/posts/_posts%2F2022-01-10-hello-world.md would SSR the Markdown content located in src/_posts/2022-01-10-hello-world.md.

If you’re wondering “but, uh, where’s the HTML rendering part?!”, the Bridgetown Roda configuration automatically handles the rendering of any models or resources which are returned in a route block. This is based on a “callable” interface which you can also use for your own custom objects! More on that down below.

SSR is great for generating preview content on-the-fly, but you can use it for any number of instances where it’s not feasible to pre-build your content. In addition, you can use SSR to “refresh” stale content…for example, you could pre-build all your product pages statically, but then request a newer version of the page (or better yet, just a fragment of it) whenever the static page is viewed which would then contain the up-to-date pricing (perhaps coming from a PostgreSQL database or some other external data source). And if you cache that data using Redis in, say, 10-minute increments, you’ve just built yourself an extremely performant e-commerce solution. This is only a single example!

Priority Flag #

You can configure a Routes class with a specific priority flag. This flag determines what order the router is loaded in relative to other routers.

The default priority is :normal. Valid values are:

:lowest, :low, :normal, :high, and :highest. Highest priority plugins are run first, lowest priority are run last.

Examples of specifying this flag:

class Routes::InitialSetup < Bridgetown::Rack::Routes
  priority :highest

  route do |r|
    r.session[:adding_this] ||= "value"
  end
end

class Routes::LaterOn < Bridgetown::Rack::Routes
  route do |r|
    r.get "later" do
      { session_value: r.session[:adding_this] } # :session_value => "value"
    end
  end
end

Accessing the Current Site, Collections, and Resources #

You can use the site helper (also aliased bridgetown_site) in your Roda code to access the current site object. From there, you can access data, collections, and resources for aid in rendering. For example, if you knew of a particular resource by title you wanted to find, you could write:

site.collections.posts.find { _1.data.title == "My Post" }

You can return a resource at the end of any Roda block to have it render out automatically, or you could pass it along as data to some other resource, or use some resource data within a return string or hash value (which autoconverts to JSON).

Performance considerations around loaded content

By default, all available collections are read in when the Roda server boots up. This might not be a big deal in production since it’s a one-time procedure, but bear in mind that on large sites, having all that data loaded in memory could prove costly. In addition, in development, any time you make a change to a file and the site rebuilds, resources are re-read into memory.

You can configure collections, including even the built-in pages and posts collections, to be skipped when your site’s running in SSR mode. Just set skip_for_ssr to true for collection metadata in your config file. For example, to skip reading posts in config/initializers.rb:

Bridgetown.configure do
  # other configuration here

  collections do
    posts do
      skip_for_ssr true
    end
  end
end

Most of the time though, on modestly-sized sites, this shouldn’t prove to be a major issue.

File-based Dynamic Routes #

But wait, there’s more! We also provide a plugin called bridgetown-routes which gives you the ability to write file-based dynamic routes with integrated view templates right inside your source folder.

To opt-into the bridgetown-routes gem, make sure it’s enabled in your Gemfile:

gem "bridgetown-routes"

and added in config/initializers.rb:

init :"bridgetown-routes"

A file-based route is comprised of two parts:

  • A Roda block at the top, contained within special delimiters. This block is processed initially when the route URL is accessed, and before any template rendering has begun. You can include Ruby front matter here, just like you would with static resource content.
  • A view template underneath the Roda block, which is rendered in the correct format based on the file extension (ERB, etc.) and has access to front matter and local variables from the Roda block.

A Roda block can contain a single route handler via r.get, or you can add additional handling of HTTP methods (r.post, etc.) or even sub-routes—though it’s recommended to stay simple and use individual file-based routes as much as possible. Note that if even if you define multiple route types in your Roda block, you can only have a single template per-route.

Let’s take a look at how this all works. First, an example of a route saved to src/_routes/items/index.erb. It provides the /items URL which shows a list of item links:

---<%
# route: /items
r.get do
  # sample data:
  items = [
    { number: 1, slug: "123-abc" },
    { number: 2, slug: "456-def" },
    { number: 3, slug: "789-xyz" },
  ]

  render_with do
    layout :page
    title "Dynamic Items"
  end
end
%>---

<ul>
  <% items.each do |item| %>
    <li><a href="/items/<%= item[:slug] %>">Item #<%= item[:number] %></a></li>
  <% end %>
</ul>

Since all the data in the above example is created and rendered by the server in real-time, there’s no way to know ahead of time which routes should be accessible via /items/:slug. That’s why bridgetown-routes supports routing placeholders at the filesystem level! Let’s go ahead and define our item-specific route in src/_routes/items/[slug].erb:

---<%
# route: /items/:slug
r.get do
  item_id, *item_sku = r.params[:slug].split("-")
  item_sku = item_sku.join("-")

  render_with do
    layout :page
    title "Item Page"
  end
end
%>---

<p><strong>Item ID:</strong> <%= item_id %></p>

<p><strong>Item SKU:</strong> <%= item_sku %></p>

This is a contrived example of course, but you can easily imagine loading a specific item from a data source based on the incoming parameter(s) and providing that item data to the view, all within a single file.

You can even use placeholders in folder names! A route saved to src/_routes/books/[id]/chapter/[chapter_id].erb would match to something like /books/234259/chapter/5 and let you access r.params[:id] and r.params[:chapter_id]. Pretty nifty.

Route Template Delimiters, Front Matter Syntax #

Bridgetown lets you use a few different delimiters for the Roda block at the top, depending on your template format. For example, ---<% and %>--- would work well for an .erb file, but ###ruby and ### would be ideal for an .rb file.

See Ruby Front Matter for additional details.

The Roda block also excepts a couple of different styles of specifying front matter. You can use render_with do ... end as in the examples above, but you can also use a data hash instead:

hsh = { layout: :page, title: "I'm a Page!" } 

render_with(data: hsh)

Note that if you use that syntax, additional local variables will not be copied down to the view template.

Finally, Bridgetown lets you simplify your code even more if your block doesn’t need to include any special get/post etc. casing and you just want to use local variables for front matter by letting you omit render_with entirely:

---<%
referrer = params[:ref]
referrer = "=)" unless AllowedValidator.valid?(referrer) # just a demo

title = "Thank You!"
%>---

<h1><%= title %></h1>

<p>We appreciate your business, <%= referrer %></h1>

Routes in Islands Architecture #

You can add routes folders inside of one or more islands. For example, you could add a route file at src/_islands/paradise/routes/dreamy.erb, and the URL would then resolve to the island name plus the route name (/paradise/dreamy). If you name your route file index.(ext), then the route path would be just the island name (/paradise).

For more information about islands, read our Islands Architecture documentation.

Adding Additional Route Paths #

If you’d like to add any arbitrary path as a location for route files—even outside of the project root—you can do so in your config/initializers.rb:

Bridgetown.configure do |config|
  # configuration here

  init :"bridgetown-routes", additional_source_paths: File.expand_path("more_routes", "#{root_dir}/..")
end

URL Helpers #

You can use the relative_url and absolute_url helpers within your Roda code any time you need to reference a particular URL, to ensure any base path or locale prefix gets added automatically. It also will work with any object which responds to a method like relative_url or url. For example:

r.redirect relative_url("/path/to/page")

r.redirect relative_url(obj)

Callable Objects for Rendering within Blocks #

When authoring Roda blocks, you have the option of returning resources to be rendered out as HTML or other text-based formats.

But it’s also possible to return any object which includes the Bridgetown::RodaCallable mixin and defines a call method which accepts the Roda application as the argument. For example, if you wrote an object which generates an RSS feed, you could use it like so:

class MyRssFeed
  include Bridgetown::RodaCallable

  def call(app)
    app => { request:, response: } # now you have those as local variables

    feed_xml = generate_the_feed # an exercise left to the reader

    response["Content-Type"] = "application/rss+xml" # set the correct content type
    feed_xml # return XML string
  end  
end
# Use the object directly in a Roda block:
r.get "/my-feed.xml" do
  MyRssFeed.new
end

For the Roda-curious, we’ve enabled this behavior via our own custom handler for the custom_block_results Roda plugin.

Islands Architecture