Ruby on Rails Guide
Published March 29, 2026 · 14 min read

Automate SEO Audits in Ruby on Rails with SEOPeek API

Rails applications evolve fast. New controllers, partial templates, Turbo frames, dynamic content from ActiveAdmin or Action Text—every change is an opportunity for SEO regressions to sneak into production. A missing meta description here, a duplicate title tag there, and suddenly organic traffic is sliding and nobody knows why. This guide shows you how to integrate the SEOPeek API into your Rails project with a reusable service object, Rake tasks, ActiveJob workers, view helpers, and RSpec tests so that every page is audited automatically and every regression is caught before it ships.

In this guide
  1. SEOPeek API response format
  2. Quick start with Net::HTTP
  3. Faraday client with retry logic
  4. Rake task for bulk audits
  5. ActiveJob background worker
  6. View helper for SEO score badges
  7. RSpec integration test
  8. FAQ

1. SEOPeek API Response

Before writing any Ruby code, you need to understand what the SEOPeek API returns. The endpoint accepts a single query parameter—url—and responds with a JSON object containing a numerical score, a letter grade, and the results of 20 individual SEO checks. Here is an example response:

{
  "url": "https://yourapp.com/pricing",
  "score": 82,
  "grade": "B+",
  "checks": {
    "title": {
      "pass": true,
      "message": "Title tag exists and is 58 characters"
    },
    "meta_description": {
      "pass": true,
      "message": "Meta description exists and is 142 characters"
    },
    "h1": {
      "pass": true,
      "message": "Single H1 tag found"
    },
    "og_tags": {
      "pass": false,
      "message": "Missing og:image tag"
    },
    "twitter_card": {
      "pass": false,
      "message": "Missing twitter:card meta tag"
    },
    "canonical": {
      "pass": true,
      "message": "Canonical URL is set"
    },
    "robots_txt": {
      "pass": true,
      "message": "robots.txt is accessible"
    },
    "structured_data": {
      "pass": false,
      "message": "No JSON-LD or microdata found"
    },
    "image_alt": {
      "pass": true,
      "message": "All images have alt attributes"
    },
    "viewport": {
      "pass": true,
      "message": "Viewport meta tag is set"
    }
  }
}

The score is an integer from 0 to 100. The grade maps to letter grades: 90–100 is A/A+, 80–89 is B/B+, 70–79 is C, 60–69 is D, and below 60 is F. Each check in the checks hash contains a boolean pass field and a human-readable message explaining the result. This structure makes it straightforward to parse in Ruby, store in a database, or render in a view.

2. Quick Start with Net::HTTP

Ruby ships with Net::HTTP in the standard library, so you can call the SEOPeek API without installing any gems. This is the fastest way to test the integration from a Rails console or a standalone script:

require "net/http"
require "json"
require "uri"

SEOPEEK_ENDPOINT = "https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi/api/v1/audit"

def audit_url(target_url)
  uri = URI(SEOPEEK_ENDPOINT)
  uri.query = URI.encode_www_form(url: target_url)

  response = Net::HTTP.get_response(uri)

  unless response.is_a?(Net::HTTPSuccess)
    raise "SEOPeek API error: #{response.code} #{response.message}"
  end

  JSON.parse(response.body)
end

# Usage
result = audit_url("https://yourapp.com")
puts "Score: #{result['score']}/100 (#{result['grade']})"

result["checks"].each do |name, check|
  status = check["pass"] ? "PASS" : "FAIL"
  puts "  [#{status}] #{name}: #{check['message']}"
end

This works, but it has no timeout handling, no retries, and no connection pooling. For production Rails applications, you want something more robust. That is where Faraday comes in.

Tip: The free tier allows 50 audits per day with no API key. Just send the GET request. For higher volumes, add your API key as a header once you upgrade to a paid plan.

3. Faraday Client with Retry Logic

The Faraday gem is the de facto HTTP client for Ruby applications. It supports middleware for retries, timeouts, logging, and connection pooling—everything you need for reliable API communication in production. Add it to your Gemfile:

# Gemfile
gem "faraday"
gem "faraday-retry"

Then create a service object that wraps the SEOPeek API:

# app/services/seopeek_client.rb

class SeopeekClient
  ENDPOINT = "https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi/api/v1/audit"

  def initialize
    @connection = Faraday.new do |f|
      f.request :retry, max: 3, interval: 0.5,
                        interval_randomness: 0.5,
                        backoff_factor: 2,
                        exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed]
      f.options.timeout = 30
      f.options.open_timeout = 10
      f.response :raise_error
      f.adapter Faraday.default_adapter
    end
  end

  # Audit a single URL and return parsed JSON
  def audit(url)
    response = @connection.get(ENDPOINT, url: url)
    JSON.parse(response.body)
  rescue Faraday::Error => e
    Rails.logger.error("[SEOPeek] Failed to audit #{url}: #{e.message}")
    raise
  end

  # Audit multiple URLs with a delay between requests
  def audit_bulk(urls, delay: 0.5)
    urls.each_with_object({}) do |url, results|
      results[url] = audit(url)
      sleep(delay) if delay > 0
    end
  end

  # Audit and return only failing checks
  def failing_checks(url)
    result = audit(url)
    failed = result["checks"].select { |_name, check| !check["pass"] }
    { url: url, score: result["score"], grade: result["grade"], failures: failed }
  end
end

Register it as a singleton in an initializer so you reuse the same connection pool across requests:

# config/initializers/seopeek.rb

Rails.application.config.seopeek = SeopeekClient.new

Now you can call it from anywhere in your Rails app:

# In a controller, job, or Rake task
client = Rails.application.config.seopeek
result = client.audit("https://yourapp.com/pricing")
puts result["score"] # => 82

4. Rake Task for Bulk Audits

Rake tasks are the bread and butter of Rails automation. This namespace gives you two tasks: seo:audit[url] for a single URL and seo:audit_all for bulk audits from a file. Both print color-coded results to the terminal.

# lib/tasks/seo.rake

namespace :seo do
  desc "Audit a single URL for SEO issues"
  task :audit, [:url] => :environment do |_t, args|
    abort "Usage: rake seo:audit[https://example.com]" unless args[:url]

    client = Rails.application.config.seopeek
    result = client.audit(args[:url])

    score = result["score"]
    grade = result["grade"]
    color = score >= 80 ? "\e[32m" : score >= 60 ? "\e[33m" : "\e[31m"
    reset = "\e[0m"

    puts "#{color}#{score}/100 (#{grade})#{reset} — #{args[:url]}"
    puts ""

    result["checks"].each do |name, check|
      icon = check["pass"] ? "\e[32m✓\e[0m" : "\e[31m✗\e[0m"
      puts "  #{icon} #{name}: #{check['message']}"
    end

    exit(1) if score < (ENV["MIN_SCORE"] || 70).to_i
  end

  desc "Audit all URLs from config/seo_urls.txt"
  task audit_all: :environment do
    url_file = Rails.root.join("config", "seo_urls.txt")
    abort "Missing #{url_file}. Create it with one URL per line." unless File.exist?(url_file)

    urls = File.readlines(url_file).map(&:strip).reject(&:empty?)
    abort "No URLs found in #{url_file}" if urls.empty?

    client = Rails.application.config.seopeek
    min_score = (ENV["MIN_SCORE"] || 70).to_i
    failures = 0

    puts "Auditing #{urls.size} URL(s) (min score: #{min_score})..."
    puts ""

    urls.each do |url|
      begin
        result = client.audit(url)
        score = result["score"]
        grade = result["grade"]
        color = score >= 80 ? "\e[32m" : score >= 60 ? "\e[33m" : "\e[31m"
        reset = "\e[0m"
        status = score >= min_score ? "\e[32mPASS\e[0m" : "\e[31mFAIL\e[0m"

        puts "  [#{status}] #{color}#{score}/100#{reset} (#{grade}) — #{url}"

        result["checks"].each do |name, check|
          unless check["pass"]
            puts "          \e[33m! #{name}: #{check['message']}\e[0m"
          end
        end

        failures += 1 if score < min_score
        sleep(0.5) # Rate limiting
      rescue StandardError => e
        puts "  \e[31m[ERROR]\e[0m #{url} — #{e.message}"
        failures += 1
      end
    end

    puts ""
    puts "Done. #{failures} failure(s) out of #{urls.size} URL(s)."
    exit(1) if failures > 0
  end
end

Create a config/seo_urls.txt file with one URL per line:

https://yourapp.com
https://yourapp.com/pricing
https://yourapp.com/features
https://yourapp.com/blog
https://yourapp.com/about

Run the tasks from your terminal:

# Audit a single URL
bundle exec rake seo:audit[https://yourapp.com]

# Audit all URLs from the file
bundle exec rake seo:audit_all

# Override the minimum score threshold
MIN_SCORE=85 bundle exec rake seo:audit_all

# Use in CI/CD — exits non-zero on failure
bundle exec rake seo:audit_all || exit 1

5. ActiveJob Background Worker

For applications that need to audit hundreds of pages without blocking a web request, use ActiveJob. This worker processes URLs asynchronously, stores results in a database table, and sends a notification when the audit is complete.

First, generate the model and migration:

# Generate the model
rails generate model SeoAudit url:string score:integer grade:string \
  checks:jsonb passed_count:integer failed_count:integer audited_at:datetime

# The migration it creates:
class CreateSeoAudits < ActiveRecord::Migration[7.1]
  def change
    create_table :seo_audits do |t|
      t.string :url, null: false
      t.integer :score
      t.string :grade
      t.jsonb :checks, default: {}
      t.integer :passed_count, default: 0
      t.integer :failed_count, default: 0
      t.datetime :audited_at

      t.timestamps
    end

    add_index :seo_audits, :url
    add_index :seo_audits, :score
    add_index :seo_audits, :audited_at
  end
end

Now the ActiveJob worker:

# app/jobs/seo_audit_job.rb

class SeoAuditJob < ApplicationJob
  queue_as :default

  retry_on Faraday::TimeoutError, wait: :polynomially_longer, attempts: 3
  retry_on Faraday::ConnectionFailed, wait: 30.seconds, attempts: 3
  discard_on Faraday::ClientError # 4xx errors are not retryable

  def perform(url)
    client = Rails.application.config.seopeek
    result = client.audit(url)

    checks = result["checks"] || {}
    passed = checks.count { |_k, v| v["pass"] }
    failed = checks.count { |_k, v| !v["pass"] }

    SeoAudit.create!(
      url: url,
      score: result["score"],
      grade: result["grade"],
      checks: checks,
      passed_count: passed,
      failed_count: failed,
      audited_at: Time.current
    )

    Rails.logger.info(
      "[SEOPeek] Audited #{url}: #{result['score']}/100 (#{result['grade']})"
    )
  end
end

Enqueue audits from a controller, a Rake task, or the Rails console:

# Audit a single URL in the background
SeoAuditJob.perform_later("https://yourapp.com/pricing")

# Audit many URLs with staggered scheduling
urls = File.readlines(Rails.root.join("config", "seo_urls.txt")).map(&:strip)
urls.each_with_index do |url, i|
  SeoAuditJob.set(wait: (i * 2).seconds).perform_later(url)
end

# Schedule recurring audits with a cron-like gem (e.g., solid_queue, sidekiq-cron)
# config/recurring.yml (solid_queue)
# seo_weekly_audit:
#   class: SeoAuditJob
#   schedule: every sunday at 6am
#   args: ["https://yourapp.com"]

Tip: Use set(wait:) to stagger bulk audit jobs by a few seconds each. This avoids hitting the rate limit and keeps your queue healthy. For Sidekiq users, perform_in(i * 2, url) achieves the same effect.

6. View Helper for SEO Score Badges

If you are building an internal dashboard or admin panel, you want to display SEO scores as colored badges. This helper method generates HTML badges that render inline in any ERB template.

# app/helpers/seo_helper.rb

module SeoHelper
  def seo_score_badge(score, grade = nil)
    color = case score
            when 90..100 then "#10B981" # emerald — excellent
            when 80..89  then "#3B82F6" # blue — good
            when 70..79  then "#F59E0B" # amber — needs work
            when 60..69  then "#F97316" # orange — poor
            else              "#EF4444" # red — critical
            end

    label = grade ? "#{score} (#{grade})" : score.to_s

    content_tag(:span, label, style: [
      "display: inline-block",
      "padding: 2px 10px",
      "border-radius: 12px",
      "font-size: 13px",
      "font-weight: 600",
      "color: white",
      "background: #{color}"
    ].join("; "))
  end

  def seo_check_icon(passed)
    if passed
      content_tag(:span, "✓".html_safe,
                  style: "color: #10B981; font-weight: bold;",
                  title: "Passed")
    else
      content_tag(:span, "✗".html_safe,
                  style: "color: #EF4444; font-weight: bold;",
                  title: "Failed")
    end
  end
end

Use the helpers in your ERB templates:

<%# app/views/admin/seo_audits/index.html.erb %>

<h1>SEO Audit Dashboard</h1>

<table>
  <thead>
    <tr>
      <th>URL</th>
      <th>Score</th>
      <th>Issues</th>
      <th>Audited</th>
    </tr>
  </thead>
  <tbody>
    <% @audits.each do |audit| %>
      <tr>
        <td><%= truncate(audit.url, length: 60) %></td>
        <td><%= seo_score_badge(audit.score, audit.grade) %></td>
        <td><%= audit.failed_count %> issue(s)</td>
        <td><%= time_ago_in_words(audit.audited_at) %> ago</td>
      </tr>
    <% end %>
  </tbody>
</table>

<%= paginate @audits %>

The seo_score_badge helper returns a colored inline badge: emerald for 90+, blue for 80+, amber for 70+, orange for 60+, and red for anything below 60. The seo_check_icon helper renders a green checkmark or red X for individual check results. Both produce safe HTML that works in any ERB layout.

7. RSpec Integration Test

The most powerful use of the SEOPeek API in a Rails SEO automation pipeline is running audits as part of your test suite. If a page drops below your minimum score, the test fails, and the deploy stops. This catches regressions before they reach production.

# spec/integration/seo_audit_spec.rb

require "rails_helper"

RSpec.describe "SEO Audit", type: :integration do
  let(:client) { SeopeekClient.new }
  let(:min_score) { Integer(ENV.fetch("SEO_MIN_SCORE", 70)) }

  # Define the URLs you care about most
  let(:critical_urls) do
    [
      "https://yourapp.com",
      "https://yourapp.com/pricing",
      "https://yourapp.com/features",
      "https://yourapp.com/blog"
    ]
  end

  critical_urls_list = %w[
    https://yourapp.com
    https://yourapp.com/pricing
    https://yourapp.com/features
    https://yourapp.com/blog
  ]

  critical_urls_list.each do |url|
    it "#{url} scores at least #{ENV.fetch('SEO_MIN_SCORE', 70)}" do
      result = client.audit(url)

      expect(result["score"]).to be >= min_score,
        "#{url} scored #{result['score']}/100 (#{result['grade']}), " \
        "expected at least #{min_score}. " \
        "Failing checks: #{result['checks']
          .select { |_k, v| !v['pass'] }
          .map { |k, v| "#{k}: #{v['message']}" }
          .join(', ')}"

      sleep(0.5) # Rate limiting between API calls
    end
  end

  it "all critical URLs have valid Open Graph tags" do
    critical_urls_list.each do |url|
      result = client.audit(url)
      og_check = result.dig("checks", "og_tags")

      expect(og_check).not_to be_nil,
        "#{url}: og_tags check not found in API response"

      expect(og_check["pass"]).to be(true),
        "#{url}: #{og_check['message']}"

      sleep(0.5)
    end
  end

  it "no critical URL scores below 50 (F grade)" do
    critical_urls_list.each do |url|
      result = client.audit(url)

      expect(result["score"]).to be > 50,
        "CRITICAL: #{url} scored #{result['score']}/100 (#{result['grade']}). " \
        "This page has severe SEO issues that need immediate attention."

      sleep(0.5)
    end
  end
end

Run the tests locally or in CI:

# Run locally
bundle exec rspec spec/integration/seo_audit_spec.rb

# Run in CI with a custom threshold
SEO_MIN_SCORE=80 bundle exec rspec spec/integration/seo_audit_spec.rb

# Run with verbose output
bundle exec rspec spec/integration/seo_audit_spec.rb --format documentation

In your CI configuration (GitHub Actions, CircleCI, GitLab CI), add the test as a separate job that runs after your staging deploy:

# .github/workflows/seo.yml

name: SEO Audit
on:
  deployment_status:
    states: [success]

jobs:
  seo-audit:
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.3
          bundler-cache: true
      - name: Run SEO audit tests
        run: bundle exec rspec spec/integration/seo_audit_spec.rb
        env:
          SEO_MIN_SCORE: 75

This workflow triggers after every successful deployment and fails the pipeline if any critical page drops below 75. Your team gets instant feedback on SEO regressions without anyone manually running Screaming Frog or checking a dashboard.

Try SEOPeek Free — 50 Audits/Day, No API Key

Start auditing your Rails application today. The free tier requires no API key, no signup, and no credit card. Just send a GET request.

Get Started with SEOPeek →

FAQ

Do I need an API key to use SEOPeek?

No. The free tier includes 50 audits per day with no API key required. Just send a GET request to https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi/api/v1/audit?url=TARGET_URL. For higher volumes, the Starter plan ($9/month) provides 1,000 audits/day and the Pro plan ($29/month) provides 10,000 audits/day.

Does the SEOPeek API work with Rails 7 and Ruby 3?

Yes. The SEOPeek API is a standard REST endpoint that returns JSON. It works with any Ruby version and any Rails version. The examples in this guide use Rails 7 conventions, but the HTTP calls work identically on Rails 5, 6, or 7. Even non-Rails Ruby scripts work fine—the Net::HTTP example in section 2 has no Rails dependency at all.

Can I run SEO audits from a Rake task?

Absolutely. Section 4 of this guide includes complete Rake tasks. Use rake seo:audit[https://example.com] for a single URL or rake seo:audit_all to audit every URL in config/seo_urls.txt. Both print color-coded results and return a non-zero exit code if any page falls below the minimum score threshold.

How do I integrate SEOPeek into my CI/CD pipeline?

The recommended approach is to write an RSpec integration test (see section 7) that calls the SEOPeek API against your production or staging URLs and asserts a minimum score. If any page fails, the test suite fails, and your CI pipeline stops the deploy. This works with GitHub Actions, CircleCI, GitLab CI, and any other CI system that runs rspec.

How does SEOPeek compare to Screaming Frog in cost?

Screaming Frog costs $259/year for a single desktop license and requires someone to manually run it. SEOPeek's free tier covers 50 audits/day at no cost. The Pro plan at $29/month ($348/year) gives you 10,000 automated audits per day—fully integrated into your code, your CI pipeline, and your background jobs. For Rails teams that value automation, SEOPeek is the clear choice.

The Peek Suite

SEOPeek is part of a family of developer-focused audit tools.