Skip to main content
This guide assumes you’ve completed the Storefront tutorial. You should have working brand pages.
This guide covers SEO best practices for your custom Spree features, including friendly URLs, meta tags, and Open Graph data.

SEO-Friendly URLs

Rather than using database IDs in URLs:
https://example.com/brands/1
Create human-readable URLs that help with SEO:
https://example.com/brands/nike-sportswear

Step 1: Add SEO Columns

Add columns for slug and meta fields:
bin/rails g migration AddSeoFieldsToSpreeBrands slug:string:uniq meta_title:string meta_description:text
bin/rails db:migrate
This adds:
  • slug - SEO-friendly URL with unique index
  • meta_title - Custom page title for search engines
  • meta_description - Description shown in search results

Step 2: Add FriendlyId to the Model

Add FriendlyId to generate SEO-friendly URLs automatically:
app/models/spree/brand.rb
module Spree
  class Brand < Spree::Base
    extend FriendlyId
    friendly_id :slug_candidates, use: %i[slugged]

    # ... other code ...

    validates :name, presence: true
    validates :slug, presence: true, uniqueness: { scope: spree_base_uniqueness_scope }
  end
end
The slug_candidates method is already defined in Spree::Base:
def slug_candidates
  [
    :name,
    [:name, :uuid_for_friendly_id]
  ]
end
This generates the slug from the name. If the name is taken, it appends a UUID.

Step 3: Add SEO Fields to Admin

Spree provides a reusable SEO partial that handles slug, meta title, and meta description with a live search preview. Update your admin form:
app/views/spree/admin/brands/_form.html.erb
<div class="row" data-controller="slug-form seo-form">
  <div class="col-lg-8">
    <div class="card mb-4">
      <div class="card-body">
        <%= f.spree_text_field :name,
            required: true,
            data: {
              seo_form_target: 'sourceTitleInput',
              slug_form_target: 'name',
              action: 'slug-form#updateUrlFromName'
            } %>
        <%= f.spree_rich_text_area :description,
            data: { seo_form_target: 'sourceDescriptionInput' } %>
      </div>
    </div>
  </div>

  <div class="col-lg-4">
    <div class="card mb-4">
      <div class="card-header">
        <h5 class="card-title"><%= Spree.t(:logo) %></h5>
      </div>
      <div class="card-body">
        <%= f.spree_file_field :logo, width: 300, height: 300, crop: true %>
      </div>
    </div>

    <%= render 'spree/admin/shared/seo',
          f: f,
          title: @brand.name,
          meta_title: @brand.meta_title,
          description: @brand.description,
          slug: @brand.slug,
          slug_path: 'brands',
          placeholder: 'Add a name and description to see how this brand might appear in a search engine listing' %>
  </div>
</div>
The SEO partial provides:
  • Live preview of how the page will appear in search results
  • Meta title field with character count
  • Meta description field
  • Slug field with auto-generation from name
The seo-form Stimulus controller syncs the preview with your form inputs in real-time. Don’t forget to permit the SEO attributes in your controller:
app/controllers/spree/admin/brands_controller.rb
def permitted_resource_params
  params.require(:brand).permit(
    :name,
    :description,
    :logo,
    :slug,
    :meta_title,
    :meta_description)
end

Step 4: Use Slugs in Storefront

Update your controller to use friendly.find:
app/controllers/spree/brands_controller.rb
def load_brand
  @brand = Spree::Brand.friendly.find(params[:id])
end
The friendly.find method:
  • Finds by slug first (e.g., /brands/nike-sportswear)
  • Falls back to ID if no slug matches
  • Raises ActiveRecord::RecordNotFound if neither is found

URL Helpers

Always pass the model object to path helpers:
<%= link_to brand.name, spree.brand_path(brand) %>
<%# => /brands/nike-sportswear %>
FriendlyId overrides to_param to return the slug automatically.

Handling Slug Changes

To redirect old URLs when slugs change, enable slug history:
app/models/spree/brand.rb
extend FriendlyId
friendly_id :slug_candidates, use: %i[slugged history]
Old slugs will automatically resolve to the current slug.

Meta Tags & Open Graph

Spree automatically generates meta tags and Open Graph data. The system uses a helper called object that finds the main instance variable based on your controller name. For BrandsController, Spree looks for @brand and generates:
  • Meta description
  • Open Graph tags (og:title, og:description, og:image)
  • Twitter Card tags

Page Title

Override accurate_title in your controller to set the page <title>:
app/controllers/spree/brands_controller.rb
private

def accurate_title
  if @brand
    @brand.meta_title.presence || @brand.name
  else
    Spree.t(:brands)
  end
end

Meta Description

Spree checks these sources in order:
  1. @page_description instance variable (if set)
  2. @brand.meta_description (if the model has this attribute)
  3. current_store.meta_description (fallback)

Using the SEO Partial

If you followed Step 1 and Step 3, your model already has meta_description and the admin form uses the SEO partial. Spree will automatically use @brand.meta_description for the page description.

Manual Override

For custom logic, set @page_description directly:
def show
  @page_description = "Shop #{@brand.name} - #{@brand.products.count} products available"
end

Social Sharing Image

For Open Graph images, Spree checks:
  1. @page_image instance variable (if set)
  2. @brand.image (if the model responds to image)
  3. current_store.social_image (fallback)
If your Brand model has logo but not image, add an alias:
app/models/spree/brand.rb
def image
  logo
end
Or set it manually:
def show
  @page_image = @brand.logo if @brand.logo.attached?
end

Complete SEO-Optimized Model

Here’s a complete Brand model with full SEO support:
app/models/spree/brand.rb
module Spree
  class Brand < Spree::Base
    extend FriendlyId
    friendly_id :slug_candidates, use: %i[slugged]

    has_many :products, class_name: 'Spree::Product', dependent: :nullify

    has_one_attached :logo
    has_rich_text :description

    # Database columns: slug, meta_title, meta_description

    validates :name, presence: true
    validates :slug, presence: true, uniqueness: { scope: spree_base_uniqueness_scope }

    # SEO: Use logo as Open Graph image
    def image
      logo
    end
  end
end

Complete SEO-Optimized Controller

app/controllers/spree/brands_controller.rb
module Spree
  class BrandsController < StoreController
    before_action :load_brand, only: [:show]

    def index
      @brands = Spree::Brand.order(:name)
    end

    def show
      @products = @brand.products
                        .active(current_currency)
                        .includes(storefront_products_includes)
                        .page(params[:page])
                        .per(12)

      # Optional: Custom page description
      # @page_description = "Shop #{@brand.name} products"

      # Optional: Custom social image
      # @page_image = @brand.logo if @brand.logo.attached?
    end

    private

    def load_brand
      @brand = Spree::Brand.friendly.find(params[:id])
    end

    def accurate_title
      if @brand
        @brand.meta_title.presence || @brand.name
      else
        Spree.t(:brands)
      end
    end
  end
end

SEO Checklist

Friendly URLs - Use slugs instead of IDs (/brands/nike not /brands/1)
Page Titles - Override accurate_title for descriptive titles
Meta Descriptions - Add meta_description field or set @page_description
Social Images - Provide image method or set @page_image for Open Graph
Slug History - Enable FriendlyId history to handle URL changes gracefully