Jeremy Smith on March 12, 2022

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.

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?

Jeremy is currently booked until mid-2023, but always happy to chat.

Email Jeremy