SEO in Rails apps remains one of the most overlooked aspects of production deployments, yet a single poorly configured title tag or missing structured data can sink your organic traffic. We've seen e-commerce platforms lose half their monthly sessions because product pages lacked proper meta descriptions, and content-heavy SaaS sites plateau in search rankings simply because their server-rendered pages weren't discoverable. This article walks through production-tested SEO patterns we use across our Rails applications to ensure search engines crawl, index, and rank our pages effectively.
Unique titles and meta descriptions
The foundation of any SEO strategy in Rails apps starts with unique, descriptive title tags and meta descriptions. Search engines rely heavily on these HTML elements to understand page content, and they're also what users see in search results. A generic title like "Products" across hundreds of pages signals to Google that your site lacks specificity; worse, users skip your results because the snippet doesn't explain what they'll find.
In a typical Rails application, you'd set these in your layout or view using the content_for helper. Let's say we're building an e-commerce platform:
<!-- app/views/layouts/application.html.erb -->
<head>
<title><%= content_for?(:title) ? yield(:title) : "Default Site Title" %></title>
<meta name="description" content="<%= content_for?(:meta_description) ? yield(:meta_description) : "Default meta description" %>">
<meta name="robots" content="<%= content_for?(:robots) ? yield(:robots) : "index, follow" %>">
</head>
<body>
<%= yield %>
</body>
Now in your product view, you'd populate these dynamically:
<!-- app/views/products/show.html.erb -->
<% content_for :title, "#{@product.name} | Buy Online | YourStore" %>
<% content_for :meta_description, "Shop #{@product.name} at YourStore. #{@product.short_description} Free shipping on orders over $50." %>
<div class="product">
<h1><%= @product.name %></h1>
<p><%= @product.description %></p>
</div>
Keep titles between 50–60 characters and meta descriptions between 150–160 characters. Google truncates longer snippets, and you want every character to count. A good pattern is: Primary Keyword + Brand | Brief Benefit. For meta descriptions, include a call-to-action and a unique value proposition so users click through from search results rather than competitors.
Friendly slugs with friendly_id
URLs matter more than many developers realize. Search engines treat URLs as a ranking signal, and descriptive, human-readable slugs improve click-through rates from search results. A URL like /products/42 tells search engines nothing about page content; /products/organic-cotton-t-shirt-blue is immediately indexable and trustworthy.
We use the friendly_id gem in production Rails apps to generate and manage these slugs automatically. It's battle-tested and handles slug history gracefully so old links don't break.
First, add it to your Gemfile and run the installer:
gem 'friendly_id', '~> 5.4.0'
Then generate the database migration for slug tracking:
rails generate friendly_id
rails db:migrate
Configure your model to use friendly_id:
# app/models/product.rb
class Product < ApplicationRecord
extend FriendlyId
friendly_id :slug_candidates, use: :slugged
def slug_candidates
[
:name,
[:name, :sku],
[:name, :sku, :id]
]
end
def should_generate_new_friendly_id?
name_changed? || super
end
end
This configuration tells friendly_id to first try the product name as a slug, then fall back to name + SKU, and finally name + SKU + ID if duplicates exist. The should_generate_new_friendly_id? method ensures slugs regenerate only when the name changes, preserving old URLs.
Update your routes to use the friendly_id parameter:
# config/routes.rb
resources :products, param: :friendly_id
Now your product URLs look like /products/organic-cotton-t-shirt-blue, and friendly_id automatically redirects old numeric IDs to the new slug with a 301 redirect, preserving SEO value. Controllers work unchanged—Rails still finds the record via friendly_id rather than the numeric ID.
Sitemaps with sitemap_generator
A sitemap is a machine-readable file that tells search engines exactly which pages on your site exist and how frequently they change. Without one, Google's crawler might miss pages, especially newly published content or deep product listings. The sitemap_generator gem automates sitemap creation and keeps it updated as your content grows.
Add the gem to your Gemfile:
gem 'sitemap_generator'
Run the generator to create the configuration file:
rails generate sitemap_generator:install
This creates config/sitemap.rb. Configure it to include all your important routes:
# config/sitemap.rb
SitemapGenerator::Sitemap.default_host = "https://yourstore.com"
SitemapGenerator::Sitemap.public_path = 'tmp/'
SitemapGenerator::Sitemap.sitemaps_host = "https://s3.amazonaws.com/your-bucket/"
SitemapGenerator::Sitemap.adapter = SitemapGenerator::AwsSdkAdapter.new
SitemapGenerator::Sitemap.create do
# Static pages
add root_path, priority: 1.0, changefreq: 'daily'
add '/about', priority: 0.8, changefreq: 'monthly'
add '/blog', priority: 0.9, changefreq: 'daily'
# Products
Product.find_each do |product|
add product_path(product), lastmod: product.updated_at, priority: 0.9, changefreq: 'weekly'
end
# Blog posts
BlogPost.published.find_each do |post|
add blog_post_path(post), lastmod: post.published_at, priority: 0.8, changefreq: 'monthly'
end
# Categories
Category.find_each do |category|
add category_path(category), priority: 0.7, changefreq: 'weekly'
end
end
SitemapGenerator::Sitemap.ping_search_engines # Notify Google, Bing, etc.
Run rake sitemap:create locally or in production to generate the sitemap. For large sites with thousands of products, sitemap_generator automatically splits into multiple files (sitemap1.xml, sitemap2.xml, etc.) and creates a sitemap index, which is critical for Google to crawl everything.
Add it to your public routes so search engines can fetch it:
# config/routes.rb
get 'sitemap.xml', to: 'pages#sitemap'
Schedule the rake task to run daily or whenever your content updates significantly:
# lib/tasks/sitemap.rake
task sitemap: :environment do
SitemapGenerator::Sitemap.create
end
If you're using Heroku or a similar platform without persistent storage, configure S3 as shown above to store sitemaps remotely and update robots.txt to point to the S3 URL.
Structured data in views
Structured data (JSON-LD) tells search engines the semantic meaning of your content. A product page with price, rating, availability, and image markup appears in rich results—Google might show your product price directly in search results, dramatically improving click-through rate. For articles, structured data helps Google understand publication date, author, and headline, sometimes earning a featured snippet.
We use JSON-LD format because it's clean, doesn't clutter your HTML, and search engines prioritize it. Here's a product example:
<!-- app/views/products/show.html.erb -->
<script type="application/ld+json">
<%= raw json_ld_product(@product).to_json %>
</script>
Define the helper in a concern or service:
# app/helpers/structured_data_helper.rb
module StructuredDataHelper
def json_ld_product(product)
{
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.image_url,
brand: {
"@type": "Brand",
name: "YourStore"
},
offers: {
"@type": "Offer",
url: product_url(product),
priceCurrency: "USD",
price: product.price.to_s,
availability: product.in_stock? ? "InStock" : "OutOfStock",
seller: {
"@type": "Organization",
name: "YourStore"
}
},
aggregateRating: product.average_rating.present? ? {
"@type": "AggregateRating",
ratingValue: product.average_rating.round(1),
reviewCount: product.reviews_count
} : nil
}.compact
end
end
For a blog post, add Article markup:
# app/helpers/structured_data_helper.rb
def json_ld_article(post)
{
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
image: post.featured_image_url,
datePublished: post.published_at.iso8601,
dateModified: post.updated_at.iso8601,
author: {
"@type": "Organization",
name: "Your Team"
},
publisher: {
"@type": "Organization",
name: "YourStore",
logo: {
"@type": "ImageObject",
url: "https://yourstore.com/logo.png"
}
}
}
end
Use Google's Rich Results Test (search.google.com/test/rich-results) to validate your markup before pushing to production. Malformed JSON-LD is often silently ignored, so testing prevents wasted effort.
Common mistakes
- Duplicate title tags and descriptions across pages: Copy-pasting templates without variable substitution wastes SEO potential. Every public page should have a unique, descriptive title and meta description.
- Ignoring canonical tags: If you have URL parameters for filtering (e.g.,
/products?color=blueand/products?color=red), search engines might crawl both as separate pages. Use<link rel="canonical" href="...">to tell them they're variants of the same page, preventing duplicate-content penalties. - Forgetting to update robots.txt: If your sitemap lives at a non-standard location, update your
public/robots.txtto point search engines to it:Sitemap: https://yourstore.com/sitemap.xml - Breaking old URLs without redirects: Removing friendly_id or changing route structures without 301 redirects loses all ranking value from old links. Always redirect old URLs to new ones.
- Not testing structured data: Invalid JSON-LD is ignored silently. Always validate with Google's Rich Results Test before deploying.
Key takeaways
- Unique, descriptive titles (50–60 chars) and meta descriptions (150–160 chars) are non-negotiable for search visibility and click-through rates.
- Friendly_id eliminates numeric URLs and handles historical slug changes with 301 redirects, preserving SEO equity.
- Sitemap_generator automatically crawls your database and notifies search engines of new or updated content, accelerating indexing.
- JSON-LD structured data—especially for products, articles, and organizations—enables rich results and improves click-through from search.
- Always validate markup and test redirects locally before production deployment.






Comments
Loading comments…