View Source Localized Routes with Routex

A core feature of Routex is to enable Localized Routes in Phoenix with translated URLs, enhancing user engagement and content relevance. In this tutorial we will explain how multiple extensions are combined to have a product page with Ural's in multiple languages and how you can display links to the localized pages. Without changing a single route in your templates!

                     /products/:id/edit                    @loc.locale = "en_US"
/products/:id/edit   /eu/nederland/producten/:id/bewerken  @loc.locale = "nl_NL"
                     /eu/espana/producto/:id/editar        @loc.locale = "es_ES"
                     /gb/products/:id/edit                 @loc.locale = "en_GB"

This tutorial assumes you have followed the usage guide to setup Routex.

If you encounter any issues with Routex or this tutorial, feel free to open a topic at Elixir Forums or create an issue at GitHub.

What we start with

Currently we have multiple routes to the product page. Your route.ex file contains something like the example below.

  preprocess_using ExampleWeb.RoutexBackend do
    scope "/", ExampleWeb do
      pipe_through :browser

      live "/products", ProductLive.Index, :index
      live "/products/new", ProductLive.Index, :new
      live "/products/:id/edit", ProductLive.Index, :edit
      live "/products/:id", ProductLive.Show, :show
      live "/products/:id/show/edit", ProductLive.Show, :edit
    end
  end

When you run mix phx.routes you will see those routes as:

product_show_path  GET     /products/:id/show/edit                ExampleWeb.ProductLive.Show :edit
product_show_path  GET     /products/:id                          ExampleWeb.ProductLive.Show :show
product_index_path  GET    /products/:id/edit                     ExampleWeb.ProductLive.Index :edit
product_index_path  GET    /products/new                          ExampleWeb.ProductLive.Index :new
product_index_path  GET    /products                              ExampleWeb.ProductLive.Index :index

You want these pages to be accessible from multiple (translated) URLs. So our first step is to generate alternative routes.

Step 1: Generate alternative URLs

The Routex.Extension.Alternatives does exactly this. Add it to the list of extensions and provide a minimal configuration.

use Routex.Backend,
extensions: [
+ Routex.Extension.Alternatives,
],
+ alternatives: %{
+      "/" => %{
+        branches: %{
+          "/europe" => %{
+              branches: %{
+                "/nl" => %{},
+                "/be" => %{}
+            }
+          },
+          "/gb" => %{}
+        }
+      }
+    }

You can confirm it works by running mix phx.routes. It now shows a lot more routes as alternatives are generated for each route within the preprocess_using block. For example the route to /products/:id/show/edit has multiple alternatives.

product_show_path           GET     /products/:id/show/edit               ExampleWeb.ProductLive.Show :edit
product_show_europe_path    GET     /europe/products/:id/show/edit        ExampleWeb.ProductLive.Show :edit
product_show_europe_be_path GET     /europe/be/products/:id/show/edit     ExampleWeb.ProductLive.Show :edit
product_show_europe_nl_path GET     /europe/nl/products/:id/show/edit     ExampleWeb.ProductLive.Show :edit
product_show_gb_path        GET     /gb/products/:id/show/edit            ExampleWeb.ProductLive.Show :edit

As you can see the routes are still in the English language; we need another extension to translate them

Step 2: Translate the alternative routes

The Routex.Extension.Translation makes routes translatable by splitting the route into segments (e.g. ["products", "show", "edit"]) and extracting these segments to a routes.po file for translation. You might recognize the .po extension from your Phoenix project; it's the extension used by Gettext. Gettext is a standard for i18n in different communities, meaning there is a great set of tooling for developers and translators. This also means your routes segments can be translated with the same tooling as used for all other translations in Phoenix!

Add the extension and it's minimal configuration.

use Routex.Backend,
extensions: [
  Routex.Extension.Alternatives,
+ Routex.Extension.Translations,
],
  alternatives: %{...},
+ translations_backend: ExampleWeb.Gettext,

If this is the first time you add translations in your project, you need to generate the folder structure which Gettext can use to detect languages to translate to. We need two languages in this tutorial: 'en' and 'nl'. As 'en' is the default for routes we only need to create translations for 'nl'.

mix gettext.extract
mix gettext.merge priv/gettext --locale nl

You should see a message that Gettext has generated new translation files which can be found in the priv/gettext/nl folder

priv/
 gettext/
   nl/
     LC_MESSAGES/
       default.po # phoenix translations
       routes.po  # routex translations

Now you can translate the segments by opening the routes.po file with your favorite .po editor. Here are a few suggestions:

  • GNU Emacs (with po-mode): Linux, MacOSX, and Windows.
  • Lokalize: runs on KDE desktop for Linux (replacement for KBabel; formerly known as KAider)
  • Poedit: Linux, MacOSX, and Windows
  • OmegaT: Linux, MacOSX, and Windows
  • Vim: Linux, MacOSX, and Windows with PO ftplugin for easier editing of GNU gettext PO files.
  • gted plugin for Eclipse (if you are already using Eclipse)
  • gtranslator: Linux/Gnome
  • Virtaal: Windows, Mac (Beta version)

One you have translated the segments, list all routes using mix phx.routes. The compilation will fail with the message:

** (RuntimeError) Routex backend `Elixir.ExampleWeb.RoutexBackend` lists extension `Elixir.Routex.Extension.Translations` but
 neither :language nor :locale was found in private.routex of route %Phoenix.Router.Route{[...]}

This makes sense. After all, we have multiple routes and a translation of segments but Routex does not know which translation to use for what route. Apparently we need to set an attribute :locale or :language per alternatives.

Luckily this is covered by Extension.Alternatives as it supports setting the :attrs key per branch. Let's replace the alternatives configuration with one that also sets the :locale attribute. While we are add it, we also give the branches a :display_name attribute.

 alternatives: %{
      "/" => %{
+        attrs: %{locale: "en-150", display_name: "Global"},
         branches: %{
           "/europe" => %{
+              attrs: %{locale: "en-150", display_name: "Europe"},
               branches: %{
+                "/nl" => %{attrs: %{locale: "nl_NL", display_name: "The Netherlands"}},
+                "/be" => %{attrs: %{locale: "nl_BE", display_name: "Belgium"}}
             }
           },
+          "/gb" => %{attrs: %{locale: "en-150", display_name: "Great Britain"}}
         }
       }
     }

Note As locale: "en-150" seems to be a default, consider using a struct with a default value instead of a map.

Now when you list all routes using mix phx.routes, you will see some routes have been translated. We are getting there!

product_show_path            GET     /products/:id/show/edit
product_show_europe_path     GET     /europe/products/:id/show/edit
product_show_europe_be_path  GET     /europe/be/producten/:id/toon/bewerken
product_show_europe_nl_path  GET     /europe/nl/producten/:id/toon/bewerken

Now we have the routes it would be nice if users stay within their locale while browsing pages.

When you start your app with mix phx.server and you visit a 'localized' page such as /europe/nl/producten you will notice that every link on the page will bring you back to a default route. In the code the path of the link is written like ~p"/products". It would be nice if instead of always rendering a link to /products, Phoenix would instead render a localized link. Enter Routex.Extension.VerifiedRoutes.

Note In older Phoenix applications you might find something like ExampleAppWeb.Router.Helpers.product_path(conn_or_endpoint, :show, "hello"). These are Phoenix Router Helpers and those are deprecated in favor of the Verified Routes using ~p"/my_path". When you can't migrate, you can use Routex.Extension.RouteHelpers instead of Routex.Extension.VerifiedRoutes.

You might already have guessed it: we are gonna add the extension and some configuration to the backend.

use Routex.Backend,
extensions: [
  Routex.Extension.Alternatives,
  Routex.Extension.Translations,
+ Routex.Extension.VerifiedRoutes,
],
  alternatives: %{...},
  translations_backend: ExampleWeb.Gettext,
+ verified_sigil_routex: "~p",
+ verified_sigil_phoenix: "~o",
+ verified_url_routex: :url,
+ verified_path_routex: :path

By default the extension uses non-standard macro names. As we want to have dynamic routes throughout our application, we 'take over' the names used by Phoenix in your application and rename the originals. This way you do not need to modify all your templates. Convenient.

To not have duplicated imports, add this to your routex_helpers in example_web.ex

  def routex_helpers do
    quote do
+      import Phoenix.VerifiedRoutes,
+        except: [sigil_p: 2, url: 1, url: 2, url: 3, path: 2, path: 3]

      import unquote(__MODULE__).Router.RoutexHelpers, only: :macros
      alias unquote(__MODULE__).Router.RoutexHelpers, as: Routes
    end
  end

Now when you start your app with mix phx.server you will notice an explanation is printed about the usage of Routex Verified Routes. This informs other developers of the 'take overs'.

Due to the configuration in module `ExampleWeb.RoutexBackend` one or multiple
Routex variants use the default name of their native Phoenix equivalents. The native
macro's, sigils or functions have been renamed.

 Native              | Routex
 -----------------------------------------
 ~o                  | ~p
 url_phx             | url
 path_phx            | path

 Documentation: https://hexdocs.pm/routex/extensions/verified_routes.html

When you visit a 'localized' page such as /europe/nl/producten you will notice that every link on the page will keep you within the localized 'branch' /europe/nl/. Keeping users in a localized environment is great, but giving them an option to switch to another locale would be nice.

Let's empower our visitors!

Step 4: Show alternative pages to the user

The Routex.Extension.AlternativeGetters generates function at compile time to dynamically fetch alternative routes for the current url without overhead. Let's once again add the extension.

use Routex.Backend,
extensions: [
  Routex.Extension.Alternatives,
  Routex.Extension.Translations,
  Routex.Extension.VerifiedRoutes,
+ Routex.Extension.AlternativeGetters,
],
  alternatives: %{...},
  translations_backend: ExampleWeb.Gettext,
  verified_sigil_routex: "~p",
  verified_sigil_phoenix: "~o",
  verified_url_routex: :url,
  verified_path_routex: :path

All created functions pattern match on a given URL and return the alternatives with a slug, the match? attribute which is true if the route pattern matches the provided path, and attributes of the route.

iex> ExampleWeb.Router.RoutexHelpers.alternatives("https://example.com/products?search=bar#top")
[
  %Routex.Extension.AlternativeGetters{
    slug: "/products?search=bar#top",
    attrs: %{
      name: "Worldwide",
      locale: "en-US",
      [...]
    },
    match?: true
  },
  %Routex.Extension.AlternativeGetters{
    slug: "/europe/products?search=bar#top",
    attrs: %{
      name: "Europe",
      locale: "en-150",
      [...]
    },
    match?: false
  },
  %Routex.Extension.AlternativeGetters{
    slug: "/europe/be/producten?search=bar#top",
    attrs: %{
      name: "Belgium",
      locale: "nl-BE",
       [...]
    },
    match?: false
  },
  [...]

As Routex automatically assigns the current url to @url we have all ingredients instantly available in our templates! It becomes a matter of looping over the results to generate links.

 <!-- alternatives/1 is located in ExampleWeb.Router.RoutexHelpers which is aliased as Routes -->
 <.link
   :for={alternative <- Routes.alternatives(@url)}
   class="button"
   rel="alternate"
   hreflang={alternative.attrs.locale}
   patch={alternative.slug}
 >
   <.button class={(alternative.match? && "bg-[#FD4F00]") || ""}>
     <%= alternative.attrs.display_name %>
   </.button>
 </.link>

Conclusion

In this tutorial you have learned how to create localized routes for your Phoenix application using multiple extensions and how to add custom attributes (such as :locale and :display_name) to these routes. There are a few more extension you can add to the mix for extra flexibility and convenience, such as:

  • Routex.Extension.Interpolation - Use any attribute to customize routes (e.g. "/#{locale}/products/#{display_name}/:id/edit")
  • Routex.Extension.Assigns - Use any attribute in your templates using @ notation.
  • Routex.Extension.AttrGetters - Lazy load attributes

If you encounter any issues with Routex or this tutorial, feel free to open a topic at Elixir Forums or create an issue at GitHub.

Have a nice day!