Adding Structured Data to a Rails application

By Exequiel Rozas

- April 14, 2025

When it comes to SEO, content is king.

However, content is not exclusively what can be seen or read, metadata is also part of the content, and it helps us better communicate what the content is about and what entities are part of it.

In this article, we will learn how to add structured data, a.k.a. schema markup, to a Rails application.

Let's start by understanding what structured data is, feel free to skip to the application setup if you're already familiar with the topic.

What is structured data?

Schema markup, also known as structured data, is metadata that we can add to pages to help search engines understand what our content is about.

We've learned about meta tags in Rails apps, which serve a similar purpose but in a different manner.

In contrast to meta tags, structured data is a convened upon method to categorize content using a set of entities and their properties.

This allows search engines to understand the content's context and the relationships within its entities.

Structured data can tell search engines that a page is about:

  • A product with price, availability, reviews, discounts, etc.
  • A book with an ISBN, several pages, an abstract, etc.
  • A recipe with ingredients, cooking time, calories, etc.
  • A person with a name, birthdate, email, phone number, etc.

However, structured data doesn't exclusively represent what a page is about, it can also represent entities that are present within a page as a part of the whole:

  • Breadcrumbs.
  • Frequently Asked Questions.
  • Multimedia: audio, image or video objects.
  • Comments, reviews, quotations, maps, etc.
  • A logo, an organization, among others.

Using this data and its relations, search engines can better understand what our content is about and what entities are represented within it.

Schema markup, developed by Google, Bing, and Yahoo in 2011, is an initiative to standardize the way structured data is defined on the internet. It helps give semantic meaning and context to entities in webpages using a common language. As of 2024, over 45 million web domains markup web pages using it.

Why add structured data to my app?

There are two main reasons why adding structured data to our application or website is desirable:

  1. It can help search engines and other bots better understand what our content is about and what entities it represents and relates to.
  2. It can improve our site's visibility and, with it, boost the click-through-rate we get on the SERPs, helping us rank better and get more traffic.

A more in-depth understanding of our content and its entities can help us search engines rank our content for queries we wouldn't otherwise rank for.

Structured data presents many benefits, but it's not considered a ranking factor. It can indirectly help us rank better if the improved CTR results in a good user experience, which signals to search engines that our content fulfills what users were expecting. This means that we can add structured data to a webpage without any noticeable improvements in rankings or traffic. Consider it an indirect means to an end.

What will we build?

For this article, we will be building upon Buk, a dummy book directory application we already used for articles like adding a sitemap to a Rails app.

Buk application home page

We will be adding structured data to this application to enrich the information we give to search engines.

Even though we could add the structured data using partials or even add it directly to the Book or Article models, we will be using the schema_dot_org gem which helps us with data validation and typing which can be crucial, especially if our site has many pages and entities.

Concretely, we will add the following entities to the appropriate pages:

  • WebSite with the SearchAction to represent the ability to search for books from the homepage.
  • Organization
  • Breadcrumb
  • Book
  • Review

If you like to see how everything is implemented, you can check the application repository.

Setup

We can use many formats to represent structured data for our Rails application.

But, to make things simple and follow search engine's recommendations, we will stick to JSON-LD which stands for “JavaScript Object Notation for Linked Data”.

This means that we declare structured data as JSON and include it within the head of the document we're trying to add structured data to.

There are many ways to achieve this with Rails. We will focus on how to add SD with and without a gem:

Adding structured data without a gem

In Rails, there are a couple of ways we can add SD without using a gem.

The first way is to declare the data as a hash in a given model:

class User
  def to_json_ld
    {
      "@context": "https://schema.org",
      "@type": "Person",
      "name": username,
      "url": "https://avohq.io/accounts/#{id}"
    }
  end
end

Then, we can add this Person information to another type schema: as an author of an Article for example or add it directly to the <head> of a page:

<%= content_for(:head) do %>
  <script type="application/ld+json">
    <%= @user.to_json_ld %>
  </script>
<% end %>

Another way is to use namespaced POROs to represent the entities we want to include as structured data.

Every object of this type would have to be defined within the Schema namespace to avoid collisions, and it would have to receive an object on the constructor and respond to the to_json_ld method:

module Schema
  class ImageObject
    include Rails.application.routes.url_helpers

    def initialize(image)
      @image = image
    end

    def to_json_ld
      {
        "@context": "https://schema.org",
        "@type": "ImageObject",
        "url": rails_blob_url(@image),
        "height": @image.metadata[:height],
        "width": @image.metadata[:width]
      }
    end

    private

    def default_url_options
      Rails.application.config.action_mailer.default_url_options || { host: 'localhost:3000' }
    end
  end
end
module Schema
  class Article
    def initialize(article)
      @article = article
    end

    def to_json_ld
      {
        "@context": "https://schema.org",
        "@type": "Article",
        "author": @article.user.to_json_ld,
        "name": @article.title,
        "url": "https://avohq.io/blog/#{@article.slug}",
        "image": Schema::ImageObject.new(@article.cover).to_json_ld,
        "description": @article.excerpt,
        "articleBody": @article.content,
        "publisher": {
          "@type": "Organization",
          "name": "AvoHQ",
          "url": "https://avohq.io"
        },
        "datePublished": @article.published_at,
        "dateModified": @article.updated_at,
      }
    end
  end
end

This will produce a result that looks like the following:

{:@context=>"https://schema.org",
 :@type=>"Article",
 :author=>{:@context=>"https://schema.org", :@type=>"Person", :name=>"erozas", :url=>"https://avohq.io/accounts/1"},
 :name=>"Microservices: When to Adopt and When to Avoid",
 :url=>"https://avohq.io/blog/microservices-when-to-adopt-and-when-to-avoid",
 :image=>
  {:@context=>"https://schema.org",
   :@type=>"ImageObject",
   :url=>"http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MjEsInB1ciI6ImJsb2JfaWQifX0=--0c8a98de44ad2cc4a753561d6e8b54a53bf7f5bc/3.webp",
   :height=>630,
   :width=>1200},
 :description=>"A pragmatic guide to evaluating whether microservices architecture is right for your organization and project needs.",
 :articleBody=>
  "Microservices architecture has become something of a buzzword in software development circles, often presented as the inevitable evolution beyond monolithic applications. However, the reality is more nuanced—microservices offer significant benefits in certain contexts while introducing unnecessary complexity in others.",
 :publisher=>{:@type=>"Organization", :name=>"AvoHQ", :url=>"https://avohq.io"},
 :datePublished=> "2025-03-21 01:46:12.979479000 UTC +00:00",
 :dateModified=> "2025-03-22 01:54:11.865287000 UTC +00:00"}

Notice that we're using a mix of techniques: a call to .to_json_ld on the User model for the Person schema and the Schema::Image PORO to represent the ImageObject schema.

We would then have to define a class for every possible schema type.

Solving the problem like this is perfectly fine but, unless we're cautious, we can create invalid schemas or provide the wrong types of values.

That's why we will use a gem that helps us with validation and typing:

We could also define schemas directly within partials and call it a day. However, if we foresee our application needing to define more schemas in the future, using POROs or a gem is probably a better decision to keep the code organized and more maintainable.

Adding structured data using a gem

We will use the schema_dot_org gem, which allows us to define and validate the structured data to make sure we're rendering the right things.

Let's start by adding and installing the gem:

$ bundle add schema_dot_org && bundle install

At the date of writing this, the gem produces invalid results because it adds a contextForValidation attribute to the generated JSON.

There's an open PR that resolves the issue, but it hasn't been merged yet, so we will be using a fork of the gem:

gem "schema_dot_org", git: "https://github.com/erozas/schema-dot-org"

Besides addressing the issue with the contextForValidation, this fork also will help us later when we start defining the schemas.

But let's not get ahead of ourselves, let's start by seeing how the gem works:

The gem comes with some predefined entities that we can find under the lib/schema_dot_org directory of the gem.

Some of these entities are: Person, Place, Product, WebSite, Organization, SearchAction, ItemList, among others.

But, unlike with the previous PORO approach, the classes that define the entities are not expecting model instances or other Ruby objects, but the individual attributes like name or url.

To define a Person we would do something like this:

person = SchemaDotOrg::Person.new(name: "Exequiel Rozas", url: "https://erozas.com", same_as: ["https://github.com/erozas"])
person.to_json_ld

Which produces the following output:

"<script type=\"application/ld+json\">\n{\"@context\":\"https://schema.org\",\"@type\":\"Person\",\"name\":\"Exequiel Rozas\",\"url\":\"https://erozas.com\",\"sameAs\":[\"https://github.com/erozas\"]}\n</script>"

We can then include that directly in the document's <head> or use the content_for helper:

<%= content_for(:head) do %>
  <%= @person %>
<% end %>

Like we said before, the gem only has a limited set of schema types, but we can extend them by defining new classes within the SchemaDotOrg namespace that inherit from SchemaType.

Let's start by adding an Article schema that also receives an ImageObject:

# app/models/schema_dot_org/image_object.rb
module SchemaDotOrg
  class ImageObject < SchemaType
    validated_attr :url, type: String
    validated_attr :height, type: Integer
    validated_attr :width, type: Integer
  end
end

We can add many more attributes for the ImageObject, but we will stick to these because the process is basically the same for every type of attribute we want to add.

Now, we define the Article schema:

# app/models/schema_dot_org/article.rb
module SchemaDotOrg
  class Article < SchemaType
    validated_attr :author, type: SchemaDotOrg::Person, allow_nil: true
    validated_attr :name, type: String
    validated_attr :url, type: String, allow_nil: true
    validated_attr :image, type: SchemaDotOrg::ImageObject, allow_nil: true
    validated_attr :article_body, type: String, allow_nil: true
    validated_attr :description, type: String, allow_nil: true
    validated_attr :publisher, type: SchemaDotOrg::Organization, allow_nil: true
    validated_attr :date_published, type: String, allow_nil: true
    validated_attr :date_modified, type: String, allow_nil: true
  end
end

Besides primitives like String it can also receive a Person, an Organization or an ImageObject.

We can define as many schemas as we like, as long as we follow the conventions.

Now that we know how to use the schema_dot_org gem, let's start by adding the site-related structured data:

Besides the JSON-LD format, we can add structured data to a page using the Microdata and RDFa formats, which use attributes to represent the desired entities and their properties. However, both of these formats have their limitations and are more error-prone. Search engines recommend JSON-LD for structured data.

Graph array to group entities

Occasionally, we need to define various entities that might be grouped together.

For example, a page representing an article might include the WebSite, WebPage, Article and BreadcrumbList entities.

To achieve this out of the box, we would need to define many <script> tags, which is redundant and clutters the page with unnecessary code.

Luckily, we can use the @graph property to declare a set of entities that are grouped together via the @id property.

Out of the box, the schema_dot_org gem doesn't have support for @graph or @id properties, but the fork we installed does.

The way to declare a graph is by first declaring schemas for individual properties and passing them to the SchemaDotOrg.create_graph class method:

person = SchemaDotOrg::Person.new(name: "Exequiel Rozas", id: "https://avohq.io/#person")
site = SchemaDotOrg::WebSite.new(name: "Avo HQ", url: "https://avohq.io", id: "https://avohq.io/#website" )
graph = SchemaDotOrg.create_graph(person, site)
graph.to_json_ld

Which produces the following output:

<script type=\"application/ld+json\">\n{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"Person\",\"@id\":\"https://avohq.io/#person\",\"name\":\"Exequiel Rozas\"},{\"@type\":\"WebSite\",\"@id\":\"https://avohq.io/#website\",\"name\":\"Avo HQ\",\"url\":\"https://avohq.io\"}]}\n</script>

Now, we can define multiple related schemas within a single object and without the need to duplicate tags.

The @id property is used to uniquely identify an item within a webpage. It allows our properties to reference each other. For example, if we had an Article entity with an author property, we could give the author an @id that references the Person entity without the need to duplicate anything, thus communicating to search engines that the author of the article is actually the person defined below.

Schema types

There are currently around 800 different schema types that we can use to define entities in our applications.

Naturally, not all of them are supported by search engines, but adding the appropriate schema to our web pages can make a difference.

However, some schemas are a safe bet for almost every website.

We will start by covering those and then adding appropriate schemas for the example application shown above.

Website and organization schemas

The first structured data we might want to add is the one that's related to the site in general.

For this, we will use four schemas: Organization, WebSite, WebPage and ImageObject.

The organization contains information about the actual company behind the website, the website represents the actual site or collection of web pages, the web page represents the current page which is the home page or any other static page or our website and the ImageObject represents the OpenGraph image.

The gem doesn't have schemas for the WebPage and the ImageObject but we've already defined the latter, so let's create the WebPage schema:

# app/models/schema_dot_org/web_page.rb
module SchemaDotOrg
  class WebPage < SchemaType
    validated_attr :name, type: String
    validated_attr :url, type: String
    validated_attr :description, type: String, allow_nil: true
    validated_attr :inLanguage, type: String, allow_nil: true
  end
end

Now, we will define the schema in a controller action. For our example, the controller is the StaticController and we will use a filter to assign the schema to an instance variable:

class StaticController < ApplicationController
  before_action :set_schema, only: [:home]

  def home
    @books = Book.all
  end

  private

  def set_schema
    organization = SchemaDotOrg::Organization.new(
      name: "Buk",
      url: "https://buk.com",
      email: "hello@buk.com",
      logo: "https://buk.com/logo.png",
      telephone: "+1234567890"
    )

    web_site = SchemaDotOrg::WebSite.new(
      url: "https://buk.com",
      name: "Buk",
      about: "Buk is a book sharing platform where users can gather and share their favorite books with each other.",
      publisher: organization
    )

    web_page = SchemaDotOrg::WebPage.new(
      url: "https://buk.com",
      name: "Buk",
      description: "Rediscover the joy of reading using Buk to explore a  world of possibilities with our carefully curated collection of books.",
      in_language: "en-US",
    )

    @schema = SchemaDotOrg::GraphContainer.new([organization, web_site, web_page])
  end
end

Next, we create a partial that we will use to display the schema script on the document's head whenever rendered on a view:

<%# app/views/shared/_schema.html.erb %>
<%= content_for :head do %>
  <%= schema %>
<% end %>

Now, we can render the partial in the home page and have the schema show in the document's head:

<%# app/views/static/home.html.erb %>
<%= render "shared/schema", schema: @schema %>

We can avoid using the schema partial and just declaring the content_for :head on every page we need the schema to show up. That's ultimately up to taste.

As a side note, we can add this schema to other pages of our site by changing the WebPage schema and using specific descriptions for those pages.

Articles

After adding the schemas for the home page, let's add a schema for articles.

We could use the Article or the BlogPosting schema type. In our case, we will use the first.

Because the gem doesn't define any of those, we will add an Article type:

module SchemaDotOrg
  class Article < SchemaType
    validated_attr :id, type: String, allow_nil: true
    validated_attr :author, type: SchemaDotOrg::Person, allow_nil: true
    validated_attr :name, type: String
    validated_attr :url, type: String, allow_nil: true
    validated_attr :image, type: SchemaDotOrg::ImageObject, allow_nil: true
    validated_attr :article_body, type: String, allow_nil: true
    validated_attr :description, type: String, allow_nil: true
    validated_attr :publisher, type: SchemaDotOrg::Organization, allow_nil: true
    validated_attr :date_published, type: String, allow_nil: true
    validated_attr :date_modified, type: String, allow_nil: true
  end
end

After defining it, we can add the schema in the controller like we did before:

class ArticlesController < ApplicationController
  before_action :set_schema, only: [:show]

  # Rest of the code

  def set_schema
    organization = SchemaDotOrg::Organization.new(
      name: "Buk",
      url: "https://buk.com",
      email: "hello@buk.com",
      logo: "https://buk.com/logo.png",
      telephone: "+1234567890"
    )

    author = SchemaDotOrg::Person.new(
      name: @article.user.username,
      url: user_url(@article.user),
    )

    image = SchemaDotOrg::ImageObject.new(
      url: url_for(@article.cover),
      width: @article.cover.metadata["width"],
      height: @article.cover.metadata["height"]
    )

    article = SchemaDotOrg::Article.new(
      name: @article.title,
      url: article_url(@article),
      description: @article.excerpt,
      article_body: @article.content,
      date_published: @article.published_at.to_time.iso8601,
      publisher: organization,
      image: image,
      author: author
    )

    @schema = SchemaDotOrg::GraphContainer.new([organization, article])
  end
end

Now, we add it to the show partial just like we did before, and we should have a nice script that outputs what we require.

But we're missing the structured data for breadcrumbs, and we have those in our example application blog:

Structured data for breadcrumbs

Let's see how we could add them:

Breadcrumbs

We can define structured data for breadcrumbs, using the BreadcrumbList markup to hold the list of breadcrumbs and the ListItem element to define the actual breadcrumbs and the Thing markup to represent the item property of the list item.

The schema_dot_org fork already defines those, so let's create the breadcrumbs.

Our breadcrumb structure is simple: Home that points to the root page, the Blog item that points to the blog index and the Article that points to the current article.

As we will be including the schema in the ArticlesController everything from the Article schema is the same except we will also pass a BreadcrumbList object to the @graph:

  def set_schema
    organization = SchemaDotOrg::Organization.new(
      name: "Buk",
      url: "https://buk.com",
      email: "hello@buk.com",
      logo: "https://buk.com/logo.png",
      telephone: "+1234567890"
    )

    author = SchemaDotOrg::Person.new(
      name: @article.user.username,
      url: user_url(@article.user),
    )

    image = SchemaDotOrg::ImageObject.new(
      url: url_for(@article.cover),
      width: @article.cover.metadata["width"],
      height: @article.cover.metadata["height"]
    )

    article = SchemaDotOrg::Article.new(
      name: @article.title,
      url: article_url(@article),
      description: @article.excerpt,
      article_body: @article.content,
      date_published: @article.published_at.to_time.iso8601,
      publisher: organization,
      image: image,
      author: author
    )

    home_breadcrumb = SchemaDotOrg::ListItem.new(
      position: 1,
      name: "Home",
      item: root_url
    )

    blog_breadcrumb = SchemaDotOrg::ListItem.new(
      position: 2,
      name: "Blog",
      item: blog_url,
    )

    article_breadcrumb = SchemaDotOrg::ListItem.new(
      position: 3,
      name: @article.title,
      item: article_url(@article)
    )

    breadcrumbs = SchemaDotOrg::BreadcrumbList.new(
      item_list_element: [home_breadcrumb, blog_breadcrumb, article_breadcrumb]
    )

    @schema = SchemaDotOrg::GraphContainer.new([organization, article, breadcrumbs])
  end

Now, we can also generate structured data for any types of breadcrumbs on our site.

Books

Given that our example application is about book sharing, let's add markup for a Book.

Let's start by creating the class to generate the desired markup:

module SchemaDotOrg
  class Book < SchemaType
    validated_attr :name, type: String
    validated_attr :author, type: SchemaDotOrg::Person, allow_nil: true
    validated_attr :description, type: String, allow_nil: true
    validated_attr :publisher, type: SchemaDotOrg::Organization, allow_nil: true
    validated_attr :isbn, type: String, allow_nil: true
    validated_attr :date_published, type: String, allow_nil: true
    validated_attr :genre, type: Array, allow_nil: true
    validated_attr :about, type: String, allow_nil: true
    validated_attr :in_language, type: String, allow_nil: true
    validated_attr :thumbnail, type: SchemaDotOrg::ImageObject, allow_nil: true
  end
end

Then, in the BooksController we define the schema just like we did before:

# app/controllers/books_controller.rb
class BooksController < ApplicationController
  before_action :set_book, only: [:show]
  before_action :set_schema, only: [:show]

  private
  def set_schema
    author = SchemaDotOrg::Person.new(
      name: @book.author_name,
      url: author_url(@book.author),
    )

    publisher = SchemaDotOrg::Organization.new(
      name: @book.publisher.name,
      url: @book.publisher.url,
      logo: url_for(@book.publisher.logo),
    )

    thumbnail = SchemaDotOrg::ImageObject.new(
      url: url_for(@book.cover),
    )

    @schema = SchemaDotOrg::Book.new(
      name: @book.title,
      author: author,
      publisher: publisher,
      isbn: @book.isbn,
      date_published: @book.published_at.to_date.iso8601,
      genre: @book.genres.map(&:name),
      description: @book.description,
      thumbnail: thumbnail,
    )
  end
end

Products

There are really no products in the example application, but we could add the appropriate SD with the Product schema type.

The markup can have many properties, but the gem includes name, description, image, url and a special offers property that accepts an AggregateOffer to communicate about offers or price reductions.

So, we will define the set_schema method to show how it can be done:

# app/controllers/products_controller.rb

def set_schema
  images = []

  @product.images.each do |image|
    images << SchemaDotOrg::ImageObject.new(
      url: url_for(image),
      width: image.metadata["width"],
      height: image.metadata["height"]
    )
  end

  offer = SchemaDotOrg::AggregateOffer.new(
    lowPrice: @product.discounted_price,
    highPrice: @product.price,
    priceCurrency: "USD",
    offerCount: @product.count_on_hand
  )

  @schema = SchemaDotOrg::Product.new(
    name: @product.name,
    description: @product.description,
    image: images,
    url: product_url(@product)
  )
end

Please note that the AggregateOffer uses camel case for the keys.

Now we have structured data for our products too!

FAQ Page

To add the Frequently Asked Questions markup, we will define a FAQPage schema type and then use the Question and Answer schemas to handle the actual questions.

In the example application, we defined the actual questions in a YAML file stored in the config directory that looks like this:

faqs:
  - question: What is Buk?
    answer: Buk is a revolutionary book sharing platform that connects readers, allows book exchanges, and helps you discover new literary adventures.

  - question: How does book sharing work?
    answer: Users can list books they're willing to share, request books from others, and arrange local or digital exchanges. Our platform ensures a safe and seamless sharing experience.

Once we have that, we set the schema in the controller:

# app/controllers/static_controller.rb
class StaticController < ApplicationController
  # Rest of the code

  def faq
    faqs_path = Rails.root.join("config", "faqs.yml")
    @faqs = YAML.load_file(faqs_path)
    @schema = set_faq_schema
  end

  private
  def set_faq_schema
    questions = []
    @faqs.each do |question|
      answer = SchemaDotOrg::Answer.new(
        text: question['answer']
      )
      question = SchemaDotOrg::Question.new(
        name: question['question'],
        accepted_answer: answer
      )
      questions << question
    end
    SchemaDotOrg::FAQPage.new(
      name: "Frequently Asked Questions",
      main_entity: questions
    )
  end
end

After passing the schema to the schema partial, we get an appropriate schema markup for our FAQ section

Going further

Showcasing every possible type of schema in an article like this is not really practical.

Your application might have many other types of schemas or implement the same we showed here a bit differently.

As long as you follow the convention of defining your new schemas within the SchemaDotOrg namespace and have the classes inherit from SchemaType you should be able to define as many as you want.

The best place to see how the schemas should look like is the official Schema.org website, don't hesitate to explore it and search for your desired markup there.

Note: not every schema defined in the schema.org specification is supported by search engines or produces enriched search results. As a starting guide, check the structured data schemas supported by Google guide.

Schema validation

Before publishing changes to the structured data of our sites, we need to make sure that it's properly formatted and valid.

To do so, we can use the schema.org validator, where we can test by URL or by pasting the generated code fragment from the json+ld script.

If we've added our site to the Google Search Console like we taught in the sitemaps in Rails article, we can check the Enhancements section to see how our structured data is indexed and if there are any errors with it.

In my experience, even if we add the appropriate markup to pages that are indexed they won't necessarily show up in this section, but errors tend to appear so keeping an eye on it is advisable.

Summary

Adding structured data to our website can help us with our SEO efforts by making it easier for search engines to understand what our content is about and the entities it represents.

There are around 800 different schema markups to represent entities that might be present in our websites or applications, but not all of them are picked by search engines to produce enriched search results.

To add structured data to a Rails application, we used a fork of the schema_dot_org gem, which adds the ability to support the @graph property and some extra schema types we needed.

Because structured data is essentially JSON, we can achieve the same results by using POROs or even partials, but the gem has some nice features like validations.

Before adding structured data to our application, make sure that it's valid by using a validator service so we can avoid wasting crawl budget unnecessarily.

Lastly, there are many browser extensions that allow us to inspect the JSON+LD markup for websites. Use them to learn how others are solving the issue, or even find out if your competitors are using structured data and how.

I hope you enjoyed the article and that you can implement structured data in your Rails applications.

Happy coding!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.