Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 073 / 100

SEO in Rails apps: Ruby on Rails lessons from production

Rank server-rendered pages.

Expona Admin7 min read

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:

ruby
<!-- 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:

ruby
<!-- 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:

ruby
gem 'friendly_id', '~> 5.4.0'

Then generate the database migration for slug tracking:

ruby
rails generate friendly_id
rails db:migrate

Configure your model to use friendly_id:

ruby
# 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:

ruby
# 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:

ruby
gem 'sitemap_generator'

Run the generator to create the configuration file:

ruby
rails generate sitemap_generator:install

This creates config/sitemap.rb. Configure it to include all your important routes:

ruby
# 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:

ruby
# config/routes.rb
get 'sitemap.xml', to: 'pages#sitemap'

Schedule the rake task to run daily or whenever your content updates significantly:

ruby
# 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:

ruby
<!-- 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:

ruby
# 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:

ruby
# 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=blue and /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.txt to 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles