SEO Tags In Phoenix
January 28, 2016
Public facing websites need to have some basic search engine optimization (SEO) tags, such as <title>
and <meta name="description">
. In Rails, you could achieve this pretty simply by putting a yield :head
tag in the appropriate layout.
<head>
<%= yield :head %>
</head>
Inside any of your views, you could then use content_for
to populate this section:
<% content_for :head do %>
<title>My Awesome Site</title>
<meta name="description" content="..." />
<% end %>
<!-- More view content here... --->
This is nice, in that it keeps the HTML all together in more or less the same place. In Phoenix, this has to be done differently.
How Phoenix is Different
In Phoenix, your layout is rendered before your page template is rendered. Take this layout, for example:
<html>
<head>
<title></title>
<meta name="description" content="" />
</head>
<body>
<%= render @view_module, @view_template, assigns %>
</body>
</html>
Because Phoenix renders templates as functions, by the time your @view_template
is rendered, the <head>
is already rendered. So, there’s nothing that your view template can do to inject content back up into <head>
. The content_for
approach won’t work.
I think there are at least four approaches to dealing with this.
1. render_existing/2
Both Chris McCord and José Valim mentioned render_existing/2
when they saw this post. I knew about it, (I believe it was one of my questions on IRC that prompted its creation, actually) and while I don’t necessarily prefer it, it really should be mentioned here.
To use render_existing
for your meta tags, you’d change your layout to look like this:
<html>
<head>
<%= render_existing(@view_module, "meta." <> @view_template, assigns) ||
render(MyApp.LayoutView, "meta.html", assigns) %>
</head>
<body>
<%= render @view_module, @view_template, assigns %>
</body>
</html>
The render_existing
function will silently render nothing if the given template doesn’t exist. The alternate render
call will take over if you don’t specify a meta file for your view/action, and will render the defaults.
Anyway, after modifying your layout, you could then create a meta.index.html.eex
file in your view’s template folder that contains whatever content you want:
<title>My Awesome App</title>
<meta name="description" content="..." />
This feels a bit more like Rails, but you end up with an extra file for each action in your controller, which feels a little odd.
meta.edit.html.eex
meta.index.html.eex
meta.new.html.eex
edit.html.eex
index.html.eex
new.html.eex
You can avoid this by defining these templates as functions in your view rather than template files:
def render("meta.index.html", _assigns) do
~E{
<title>My Awesome App</title>
<meta type="description" content="..." />
}
end
The ~E
sigil allows you to write EEX code inline in your view. This is fine, but it still can get a little messy looking with long meta descriptions, and you end up with a lot of function definitions.
I personally think this is likely to confuse the other devs on the projects I work on, so I’ve chosen not to establish it as the “standard” way at Infinite Red. It is definitely a valid option though, and seems to be the “official” way.
2. Assigns in Each Controller Action
The second approach modifies the layout to look like this:
<html>
<head>
<title><%= @title %></title>
<meta name="description" content="<%= @meta %>" />
</head>
<body>
<%= render @view_module, @view_template, assigns %>
</body>
</html>
Then, in each controller action in the app, the developer must specify title
and meta
assigns:
def index(conn, _params) do
conn
|> assign(:title, "My Awesome App")
|> assign(:meta, "...")
|> render("index.html")
end
There are a few downsides to this. First, the title and meta description can dominate the controller action, making it harder to see the business logic. Second, it can easily end up being pretty ugly if you don’t put it all on one line:
def index(conn, _params) do
conn
|> assign(:title, "My Awesome App")
|> assign(:meta, """
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam feugiat
nibh ligula. Maecenas egestas nibh cursus erat sodales, vitae congue nisi
tempus. Nam mattis et velit eu lacinia.
""")
|> render("index.html")
end
I think we can do better.
3. Delegated Functions
Another alternative I’ve seen is to delegate to functions on your view module, like this:
<html>
<head>
<title><%= @view_module.title(@view_template, assigns) %></title>
<meta name="description" content="<%= @view_module.meta(@view_template, assigns) %>" />
</head>
<body>
<%= render @view_module, @view_template, assigns %>
</body>
</html>
Then, in your view module, you can define different titles or meta descriptions for different layouts, or set a default.
defmodule MyApp.PageView do
use MyApp.Web, :view
def title("index.html", _assigns), do: "My Awesome App"
def title(_other, _assigns), do: "Default Title"
def meta("index.html", _assigns), do: "..."
def meta(_other, _assigns), do: "Default Meta"
end
This is pretty similar to the first approach outlined above.
If you didn’t want to have to define these functions on every view, you could create a mixin that would define default implementations of these title
and meta
functions.
defmodule MyApp.SEO.Defaults do
defmacro __using__(_) do
quote do
def title(_other, _assigns), do: "Default Title"
def meta(_other, _assigns), do: "Default Meta"
defoverridable [title: 2, meta: 2]
end
end
end
Then, use
this module in your web/web.ex
to make it affect all views.
def view do
quote do
# ...
use MyApp.SEO.Defaults
end
end
This approach has the advantage of cleaning up the controller, but I think it’s a bit less clear where the titles and meta descriptions are being generated, and it’s not clearly better than the render_existing
approach.
4. Use a Plug
A huge number of problems can be solved with Plug, so today, I thought I would try to use it to solve this problem. It’s pretty simple to create a custom plug for this:
defmodule MyApp.SEO.Plug do
def put_seo(%{private: %{phoenix_action: action}} = conn, settings) do
settings = settings[action] || []
conn
|> assign(:title, settings[:title])
|> assign(:meta, settings[:meta])
end
end
The plug takes a “settings” argument, which we read from to determine what the title
and meta
assigns should be. I just include it in the controller
section of web/web.ex
:
def controller do
quote do
# ...
import MyApp.SEO.Plug
end
end
And then I can use it in my controllers, like so:
defmodule MyApp.PageController do
use MyApp.Web, :controller
@meta %{
index: %{
title: "My Awesome App",
meta: """
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam feugiat
nibh ligula. Maecenas egestas nibh cursus erat sodales, vitae congue nisi
tempus. Nam mattis et velit eu lacinia.
"""
},
contact: %{
title: "Contact Us"
meta: "..."
}
}
plug :put_seo, @meta
def index(conn, _params) do
# ...
end
def contact(conn, _params) do
# ...
end
end
And my layout looks like this:
<html>
<head>
<title><%= assigns[:title] || "Default" %></title>
<meta name="description" content="<%= assigns[:meta] || "Default" %>" />
</head>
<body>
<%= render @view_module, @view_template, assigns %>
</body>
</html>
While I’m not completely happy with it, (it would be nice to get all that text out of the controller), I think this is pretty clear. The controller actions are also uncluttered, which is a plus.
Other Approaches
I’m pretty sure you could create a plug that used the Gettext API to extract all the strings out to .po files. I’m just not sure that’s worth the added complexity and explaining to new developers.
I hope you found this helpful!