Add FAQ markup schema to your articles in Ruby on Rails

Published in May 2020

What is FAQ structured data?

FAQ structured data is a way to markup your questions and answers for your articles. If your article answers specific questions, it's good to add this markup to your page, as this improves SEO and increases the probability for Google to find them and display them on the search result page.

The FAQ section within Google Result page looks like this

You need to generate JSON containing your questions and answers, they wrap them within a <script type="application/ld+json"> as per the specification provided by Google. Look at the template below to give you an idea of what we will output at the end of this article.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [{
    "@type": "Question",
    "name": "Question goes here",
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "Answer here"
    }
  }, {
    "@type": "Question",
    "name": "Question goes here",
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "Answer goes here"
    }
  }, {
    "@type": "Question",
    "name": "Question goes here",
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "Answer goes here"
    }
   }]
  }
</script>

There are several Google guidelines which you should follow.

Use this FAQ markup if you answer more than one question within your article.

Avoid adding multiple answers to the same question - Google might think that you are trying to cheat the system.

Don't add answers which have been submitted by users (for example, comments).

You shouldn't use the FAQ markup as a marketing/advertising opportunity - it should answer a specific question so avoid any product-specific content.

Step 1: Create the ArticleFaq model

This model will store our questions and answers to our articles. To add the model, create the following migration:

create_table :article_faqs do |t|
  t.integer :article_id
  t.string :question
  t.string :answer

  t.timestamps
end

Every Article can have many FAQs and the AritcleFaq belongs to the Article model.

class ArticleFaq < ApplicationRecord
  belongs_to :article
end

An article can either have zero, one or many ArticleFaqs, so we would like to make this flexible. We would add has_many association and accepts_nested_attributes_for as we will implement a nested form. Nested forms allow us to create as many FAQs as we need.

has_many :article_faqs, dependent: :destroy
accepts_nested_attributes_for :article_faqs, reject_if: :all_blank, allow_destroy: true

Step 2: Add a nested form within your article form

There are several approaches to implementing this, such as using a gem like cocoon. However, in this example, we will use stimulusJS. The stimulus controller is easy to understand and you can be extended if you need. You are welcome to use whichever approach you prefer.

Copy the nested_form stimulus controller and paste it in your app/javascript/controllers directory.

// nested_form_controller.js

import { Controller } from "stimulus"
export default class extends Controller {
  static targets = ["links", "template"]

  connect() {
  }

  add_association(event){
    event.preventDefault()

    var content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime())
    this.linksTarget.insertAdjacentHTML("beforebegin", content)
  }

  remove_association(event){
    event.preventDefault()

    let wrapper = event.target.closest(".nested-fields")
    if (wrapper.dataset.newRecord == "true"){
      wrapper.remove()
    } else{
      wrapper.querySelector("input[name*='_destroy']").value = 1
      wrapper.style.display = 'none'
    }
  }
}

If you don't have stimulus configured, you can follow this article to install it.

Now go to the view where you create/edit your articles and add the following snippet of code within your article form in order to implement the FAQ nested form.

<%# article/_form.html.erb %>


<%# ... rest of your Article form %>

<div data-controller="nested-form">
  <h3>FAQs</h3>
  <template data-target="nested-form.template">
    <%= f.fields_for :article_faqs, ArticleFaq.new, child_index: "NEW_RECORD" do |task| %>
      <%= render  "faq", form: faq %>
    <% end %>
  </template>
  <%= f.fields_for :article_faqs do |faq| %>
    <%= render  "faq", form: article_faqs %>
  <% end %>
  <div data-target="nested-form.links">
    <%= link_to  "Add a new FAQ", "#", data: {action: "click->nested-form#add_association"} %>
  </div>
</div>
<%# article/_faq.html.erb %>

<div class="nested-fields" data-new-record="<%= form.object.new_record? %>">
  <div class="form-group">
    <%= form.text_field :question, placeholder: "Your question..." %>
    <%= form.text_field :answer, placeholder: "Your answer..." %>
    <%= link_to  "Remove FAQ", "#", data: {action: "click->nested-form#remove_association"} %>
  </div>
    <%= form.hidden_field :_destroy %>
</div>

The final step is to go to your article's controller and add the nested attributes to your permitted params

# articles_controller.rb

article_faqs_attributes: [:id, :question, :answer, :_destroy]

Step 3: Create a service object for your structured data

You can extract the logic of creating the FAQ structured data into a service object. There are other types of structured data (for example, Article schema) which you might want to add in the future.

class ArticleStructuredData < Patterns::Service

  def initialize(article)
    @article = article
  end

  def generate_faq_schema
    return if @article.article_faqs.empty?

    JSON.pretty_generate(
      "@context" => "http://schema.org",
      "@type" => "FAQPage",
      "mainEntity" => @article.article_faqs.map { |faq|
        {
          "@type" => "Question",
          "name" => faq.question,
          "acceptedAnswer" => {
            "@type" => "Answer",
            "text" => faq.answer,
          }
        }
      }
    )
  end
end

In your controller, initialise the service object within your show action and assign the return value to an instance variable.

@faq_structured_data = ArticleStructuredData.new(@article).generate_faq_schema

Step 4: Conditionally render in your view

Since some articles might not have FAQs, we want to conditionally render the script tag. Wrap your script tag around a content_for helper so we can render it in the <head> tag.

<%# articles/show.html.erb %>


<%= content_for :structured_data do %>
  <% if @faq_structured_data.present? %>
    <script type="application/ld+json">
      <%= @faq_structured_data.html_safe %>
    </script>
  <% end %>
<% end %>

The final step is to check for content_for?(:structured_data) in our layout. If present, render our script tag within our head tag.

<head>
<% if content_for?(:structured_data) %>
  <%= yield(:structured_data) %>
<% end %>
</head>

Thanks for reading and happy coding! 👋 👨‍💻

Level up your web development skills

Get articles, guides and interviews right in your inbox. Join a community of fellow developers.

No spam. Unsubscribe at any time.

Full Stack Heroes logo

© 2020 Full Stack Heroes