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.
Why add structured data to my app?
There are two main reasons why adding structured data to our application or website is desirable:
- It can help search engines and other bots better understand what our content is about and what entities it represents and relates to.
- 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.
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.
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:
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:
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.
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:
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.
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!