Adding Meta Tags to a Rails CMS with Polymorphism
If you’ve been a web developer for a while and you’re like me, you might have built CMS functionality into a Rails app…or two.
“At least” https://t.co/v4ixlT3EIj
— Jeremy Smith (@jeremysmithco) March 7, 2022
It’s popular these days to separate your marketing site from your web application, but there are still times when it’s useful to build CMS features directly into your app. For example:
- If your team is small, it can be helpful to have fewer web properties to secure, manage, and administrate.
- If you are trying to maintain design continuity between your marketing and app experience, it can be easier to manage a single source of truth for your CSS framework, design system, or component library.
- If your marketing, design, and content teammates don’t need a lot of control over the UI/UX, it can be faster and easier to build simple, constrained tools for them directly in the app.
- If your users move back and forth between public marketing content and private app functionality, building the content management into the app can make the experience of traversing between those areas smoother.
While there are some benefits, there are also costs and complexities that may not be obvious when initially considering your options. Two such areas are Search Engine Optimization and Open Graph enhancement.
Reaching for MetaTags
Back in the fall, I was working on a client’s Rails app where I had previously built content management tools for a number content models (such as events, webinars, blog posts, courses, etc.) where each had public marketing pages. The client wanted to improve SEO for these pages and also needed to enhance each with Open Graph data, so that links shared on social media would have custom images and descriptions.
The MetaTags gem is a great fit for this use-case. It provides a nice interface to manage a lot of the metadata that your pages need, including title, meta description and keywords, Open Graph type and image, etc.
Extracting a Model
When I started, I planned to add meta description, meta keywords, and Open Graph image fields to the blog Post
model, with the intention that I would do the same for the other ActiveRecord content models. I quickly realized that I would be duplicating schema and logic over and over, for each content model.
This was clearly calling for an extraction to a separate model. If I created a separate ActiveRecord model to hold the metatag data, with a polymorphic association to all the content models in the app, I could add metadata easily to any given model, and expand or modify that metadata down the road, without touching any of those given content models.
I started with a Metatag
model with a polymorphic metataggable
association, as well as fields for an Open Graph image (using CarrierWave), a meta description, and meta keywords.
class Metatag < ApplicationRecord
belongs_to :metataggable, polymorphic: true
mount_uploader :og_image, OgImageUploader
def self.permitted_attributes
[:id, :og_image, :remove_og_image, :keywords, :description]
end
end
I then created a Metataggable
model concern.
module Metataggable
extend ActiveSupport::Concern
included do
has_one :metatag, as: :metataggable, dependent: :destroy
accepts_nested_attributes_for :metatag
end
def metatag
super || build_metatag
end
end
And that model concern could be mixed in to any model I wanted metatags for:
class Post < ApplicationRecord
include Metataggable
...
end
Nesting Attributes
By using accepts_nested_attributes_for, I could then pass the metatag attributes along with the content model in form submissions and store the data on the associated metatag
record. I added a partial to each content form in the admin area for the metatag fields:
<%= simple_form_for([:admin, @post]) do |f| %>
<%= f.error_notification %>
<%= f.input :title %>
<%= f.input :body %>
<%= render "admin/metatags/fields", f: f %>
<%= f.button :submit %>
<% end %>
Note that I am using SimpleForm in this example, but you can do the same thing without SimpleForm by using the fields_for method directly.
<%= f.simple_fields_for :metatag do |m| %>
<%= m.input :og_image, label: "Open Graph Image" %>
<%= m.input :keywords %>
<%= m.input :description %>
<% end %>
In the controller, I needed to update Strong Parameters to permit the metatag attributes to be included for ActiveRecord mass assignment. The Metatag.permitted_attributes
class method provided a single place to account for those permitted attributes.
class Admin::PostsController < Admin::AdminController
...
def create
@post = Post.new(post_params)
authorize @post
if @post.save
redirect_to edit_admin_post_path(@post.id),
notice: 'Blog post was successfully created.'
else
render action: 'new'
end
end
...
private
def post_params
params.require(:post).permit(
:title, :description, :body, :author_id, :status, :published_at,
metatag_attributes: Metatag.permitted_attributes
)
end
end
Using the Meta Tags
Now that metatag attributes were being stored for each associated content model, I could move on to using those attributes in the public marketing pages.
First, I added the display_meta_tags
method to my main layout.
<!DOCTYPE html>
<html lang="en">
<head>
<%= display_meta_tags(default_meta_tags) %>
...
I created a default_meta_tags
helper method in ApplicationHelper
to hold the hash of default metatag values that are passed into the display_meta_tags
method, and will later be overridden for any views where I use the set_meta_tags
method.
def default_meta_tags
{
separator: '-',
site: 'Acme Co.',
reverse: true,
title: 'Welcome to the Acme Co. website',
description: 'This is the website for Acme Co...',
og: {
site_name: :site,
type: 'website',
title: :title,
description: :description,
},
twitter: {
card: 'photo',
title: :title,
description: :description,
}
}
end
I didn’t want to construct a hash in every view where I would be using the set_meta_tags method, so I created a PORO presenter class to build the metatag hash needed in views, which would accept a title coming from the content model, the metatag model associated with the content model, and the view context (needed for the asset_url
method).
class MetatagPresenter
def initialize(title, metatag, view)
@title = title
@metatag = metatag
@view = view
end
def to_meta_tags
{
title: title,
description: metatag.description,
keywords: metatag.keywords,
og: {
image: (view.asset_url(metatag.og_image_url(:regular)) if metatag.og_image?)
},
twitter: {
image: (view.asset_url(metatag.og_image_url(:regular)) if metatag.og_image?)
}
}
end
private
attr_reader :title, :metatag, :view
end
In this way, I could set the metatags for each view by constructing an instance of the MetatagPresenter
class like this, passing in the appropriate values from the instance of the content model as well as the view context (self
).
<% set_meta_tags MetatagPresenter.new(@post.title, @post.metatag, self) %>
It takes time and practice to be able to look at your domain, data modeling, and feature requirements and identify the areas that will benefit from extraction and polymorphism. By extracting a separate ActiveRecord model with a polymorphic association, and adding a model concern, a shared form partial, and a presenter class, I was able to build a simple, flexible metatag solution that could be applied immediately to multiple content models, and could be easily extended with additional attributes in the future.
Need help building or maintaining a Rails app?
Book a one-hour project inquiry call or reach out via email.