Overview
Spree uses the FriendlyId library to generate SEO-friendly URLs for resources like products, taxons, stores, and more. Instead of accessing resources via database IDs (e.g., /products/123), FriendlyId enables clean, readable URLs based on resource names (e.g., /products/ruby-on-rails-tshirt).
Slugs are URL-safe strings derived from resource attributes (typically the name field) that:
- Improve SEO by including relevant keywords in URLs
- Enhance user experience with readable, memorable URLs
- Maintain URL consistency across translations
- Preserve historical URLs through slug history
Slugs are primarily used in:
- Storefront: Product pages, category pages (taxons), blog posts, and other customer-facing pages
- API: Both Platform API and Storefront API accept slugs or IDs interchangeably for resource lookups
Spree integrates FriendlyId with Mobility for internationalization and Paranoia for soft-delete support, making slugs translation-aware and deletion-safe.
Core Concepts
Slug Generation
Slugs are automatically generated from resource attributes using the to_url method (provided by the Stringex gem). This method:
- Converts strings to lowercase
- Replaces spaces and special characters with hyphens
- Removes invalid URL characters
- Ensures URL safety
# Example slug generation
"Ruby on Rails T-Shirt".to_url # => "ruby-on-rails-t-shirt"
"Café & Restaurant".to_url # => "cafe-and-restaurant"
Slug Candidates
When FriendlyId generates a slug, it tries multiple “candidate” patterns to ensure uniqueness. If the first candidate is taken, it tries the next pattern, and so on.
Spree::Base provides a default slug_candidates method that all models inherit:
def slug_candidates
if defined?(deleted_at) && deleted_at.present?
[
['deleted', :name],
['deleted', :name, :uuid_for_friendly_id]
]
else
[
[:name],
[:name, :uuid_for_friendly_id]
]
end
end
For products, the slug candidates are customized to include SKU:
# Product slug candidates (from core/app/models/spree/product/slugs.rb:66-78)
[
[:name], # Try product name first
[:name, :sku], # If taken, append SKU
[:name, :uuid_for_friendly_id] # If still taken, append UUID
]
# Example progression:
# 1st attempt: "ruby-on-rails-tshirt"
# 2nd attempt: "ruby-on-rails-tshirt-TSHIRT-001" (includes SKU)
# 3rd attempt: "ruby-on-rails-tshirt-a1b2c3d4-e5f6-..." (includes UUID)
Reserved Words
FriendlyId prevents using certain reserved words that would conflict with application routes:
# From core/config/initializers/friendly_id.rb
RESERVED_WORDS = %w(
new edit index session login logout users admin
stylesheets assets javascripts images
)
Models with Slugs
Product
Products use the Spree::Product::Slugs concern which provides comprehensive slug handling with translation support.
include Spree::Product::Slugs
Configuration:
- Slug column:
slug
- Slug source:
name
- Modules:
:history, :slugged, :scoped, :mobility
- Scope:
spree_base_uniqueness_scope (typically store_id)
- Limit: 255 characters
Key features:
- Translatable slugs (different slug per locale)
- Slug history preserved when changed
- Soft-delete slug renaming with
deleted- prefix
- Automatic fallback to SKU or UUID for uniqueness
# Creating a product with a slug
product = Spree::Product.create!(
name: 'Ruby on Rails T-Shirt',
stores: [store]
)
product.slug # => "ruby-on-rails-t-shirt"
# Accessing by slug
Spree::Product.friendly.find('ruby-on-rails-t-shirt')
# Setting a custom slug
product.update!(slug: 'custom-rails-shirt')
# Translated slugs
product.set_friendly_id('t-shirt-ruby-on-rails', :fr)
product.save!
Taxon
Taxons use a custom implementation with hierarchical permalink generation. Unlike products, taxons use permalink as their slug column.
# core/app/models/spree/taxon.rb:33
friendly_id :permalink, slug_column: :permalink, use: :history
Configuration:
- Slug column:
permalink (not slug)
- Modules:
:history only
- Hierarchical: Includes parent path
Key features:
- Parent path included in permalink (e.g.,
categories/clothing/t-shirts)
- Automatically regenerates when parent changes
- Translation support via custom implementation
- Helper methods:
slug and slug= alias to permalink
# Creating a taxon
taxonomy = store.taxonomies.first
root = taxonomy.root
clothing = root.children.create!(name: 'Clothing', taxonomy: taxonomy)
tshirts = clothing.children.create!(name: 'T-Shirts', taxonomy: taxonomy)
clothing.permalink # => "clothing"
tshirts.permalink # => "clothing/t-shirts"
tshirts.slug # => "clothing/t-shirts" (aliased)
# When parent changes, children update automatically
clothing.update!(name: 'Apparel')
tshirts.reload.permalink # => "apparel/t-shirts"
Store
Stores use code as their slug column, and the slug is used for internal routing and multi-store identification.
# core/app/models/spree/store.rb:24
friendly_id :slug_candidates, use: [:slugged, :history], slug_column: :code
Configuration:
- Slug column:
code (not slug)
- Modules:
:slugged, :history
- Reserved codes: Admin, default, app, api, www, cdn, files, assets, checkout, etc.
Key features:
- Code is parameterized from store name
- Uniqueness enforced across soft-deleted stores
- History tracking for code changes
- Custom validation against reserved words
# Creating a store
store = Spree::Store.create!(
name: 'My Awesome Store',
url: 'mystore.example.com',
default_currency: 'USD',
default_country: Spree::Country.find_by(iso: 'US')
)
store.code # => "my-awesome-store"
# Code is read-only after creation (doesn't regenerate on name change)
store.update!(name: 'My Better Store')
store.code # => "my-awesome-store" (unchanged)
Other Models
Other Spree models that use FriendlyId:
- Pages (
Spree::Page): Uses slug column with history
- Posts (
Spree::Post): Uses slug column with history and translations
- Post Categories (
Spree::PostCategory): Uses slug column with history
- Policies (
Spree::Policy): Uses slug column with history
Internationalization
Spree integrates FriendlyId with Mobility for multi-language slug support using the friendly_id-mobility gem.
Translatable Models
Products, Posts, and Post Categories support translatable slugs:
# Products include TranslatableResourceSlug concern
include Spree::TranslatableResourceSlug
# Each translation has its own slug
product = Spree::Product.create!(
name: 'Red Shoes',
stores: [store]
)
product.slug # => "red-shoes" (default locale)
# Add French translation
product.translations.create!(
locale: :fr,
name: 'Chaussures Rouges',
slug: nil # Auto-generated from name
)
# Access localized slugs
I18n.with_locale(:fr) do
product.slug # => "chaussures-rouges"
end
# Or use the helper method
product.localized_slugs_for_store(store)
# => {
# "en" => "red-shoes",
# "fr" => "chaussures-rouges"
# }
Translation Slug Generation
When creating translations, slugs are automatically generated with this priority:
- Custom slug - If provided, it’s normalized (e.g.,
'Custom Slug!' → 'custom-slug')
- Translated name - Generated from the translation’s name field
- Fallback to default - Uses default locale’s name if translation name is blank
# Example from core/app/models/spree/product/slugs.rb:29-36
def generate_slug
if name.blank? && slug.blank?
translated_model.name.to_url # Use default locale name
elsif slug.blank?
name.to_url # Use translation name
else
slug.to_url # Use custom slug
end
end
Translation Uniqueness
Slugs must be unique within the same locale but can be duplicated across locales:
# Same slug in different locales - OK
product1.translations.create!(locale: :fr, slug: 'tshirt')
product2.translations.create!(locale: :es, slug: 'tshirt') # ✅ Allowed
# Duplicate slug in same locale - Auto-fixed with UUID
product1.translations.create!(locale: :fr, slug: 'tshirt')
product2.translations.create!(locale: :fr, slug: 'tshirt') # ❌ "tshirt-<uuid>"
Slug History
FriendlyId’s history feature preserves old slugs when they change, ensuring existing URLs continue to work. This is critical for:
- SEO (avoiding broken links)
- Bookmarks and external references
- Graceful URL migration
product = Spree::Product.create!(name: 'Original Name', stores: [store])
product.slug # => "original-name"
# Change the name (generates new slug)
product.update!(name: 'New Name', slug: nil)
product.slug # => "new-name"
# Old slug still works (redirects to new slug)
Spree::Product.friendly.find('original-name') # => Still finds the product
# View slug history
product.slugs.pluck(:slug)
# => ["original-name", "new-name"]
Slug History Table
FriendlyId maintains a separate friendly_id_slugs table:
# Schema
create_table :friendly_id_slugs do |t|
t.string :slug, null: false
t.integer :sluggable_id, null: false
t.string :sluggable_type, limit: 50
t.string :scope
t.string :locale # Added by Spree for multi-language
t.datetime :created_at
t.datetime :deleted_at # Added by Spree for soft-delete
end
Soft Deletes and Slugs
Spree extends FriendlyId to work with soft-deleted records (using Paranoia gem). When a resource is deleted:
- The record is marked as deleted (
deleted_at timestamp)
- The slug is prefixed with
deleted- and a UUID
- Slug history records are also soft-deleted
- Original slug becomes available for new records
product = Spree::Product.create!(name: 'Test Product', stores: [store])
product.slug # => "test-product"
# Soft delete the product
product.destroy!
# Slug is renamed with deleted prefix
product.slug # => "deleted-test-product-a1b2c3d4-..."
# Original slug is now available
new_product = Spree::Product.create!(name: 'Test Product', stores: [store])
new_product.slug # => "test-product" ✅
Restoration
When restoring a soft-deleted record, the slug is regenerated:
product.restore(recursive: true)
product.slug # => "test-product" (original slug restored if available)
Implementation
# From core/app/models/spree/product/slugs.rb:93-107
def punch_slugs
return if new_record? || frozen?
self.slug = nil
set_slug
update_column(:slug, slug)
new_slug = ->(rec) { "deleted-#{rec.slug}-#{uuid_for_friendly_id}"[..254] }
# Update both translations and slug history
translations.with_deleted.each { |rec| rec.update_columns(slug: new_slug.call(rec)) }
slugs.with_deleted.each { |rec| rec.update_column(:slug, new_slug.call(rec)) }
end
Working with Slugs
Finding by Slug
Use the friendly scope to find records by slug:
# Find by slug
product = Spree::Product.friendly.find('ruby-on-rails-tshirt')
# Find by ID still works
product = Spree::Product.friendly.find(123)
# Find by historical slug (auto-redirects)
product = Spree::Product.friendly.find('old-slug-name')
Setting Custom Slugs
You can override auto-generated slugs:
# Auto-generated slug
product = Spree::Product.create!(
name: 'Ruby on Rails T-Shirt',
stores: [store]
)
product.slug # => "ruby-on-rails-t-shirt"
# Custom slug
product.update!(slug: 'rails-tshirt')
product.slug # => "rails-tshirt"
# Reset to auto-generate (set to nil)
product.update!(slug: nil)
product.slug # => "ruby-on-rails-t-shirt" (regenerated from name)
Slug Normalization
Slugs are automatically normalized on validation:
product = Spree::Product.new(
name: 'Test',
slug: 'Hey//Joe', # Invalid format
stores: [store]
)
product.valid?
product.slug # => "hey-joe" (normalized)
Checking Slug Availability
# Check if a slug is available
Spree::Product.slug_available?('my-slug', product.id) # => true/false
# Ensure slug uniqueness manually
def ensure_unique_slug(candidate)
return candidate if Spree::Product.slug_available?(candidate, id)
"#{candidate}-#{SecureRandom.uuid}"
end
Localized Slug Retrieval
For multi-store, multi-language applications:
# Get all localized slugs for a store
product.localized_slugs_for_store(store)
# => {
# "en" => "ruby-tshirt",
# "fr" => "tshirt-ruby",
# "es" => "camiseta-ruby"
# }
Adding Slugs to Custom Models
To add FriendlyId slugs to your custom models, inherit from Spree::Base which provides the base slug functionality.
Basic Implementation
# app/models/spree/brand.rb
module Spree
class Brand < Spree::Base
extend FriendlyId
friendly_id :slug_candidates, use: [:slugged, :history]
validates :name, presence: true
validates :slug, presence: true, uniqueness: true
# slug_candidates is already defined in Spree::Base
# No need to override unless you need custom behavior
end
end
Models inheriting from Spree::Base automatically get the slug_candidates and uuid_for_friendly_id methods. You only need to override slug_candidates if you need custom slug generation logic (like Product does with SKU).
With Translation Support
# app/models/spree/brand.rb
module Spree
class Brand < Spree::Base
extend FriendlyId
include Spree::TranslatableResource
include Spree::TranslatableResourceSlug
translates :name, :slug
friendly_id :slug_candidates,
use: [:history, :slugged, :scoped, :mobility],
scope: spree_base_uniqueness_scope
validates :name, presence: true
validates :slug, presence: true,
uniqueness: { scope: spree_base_uniqueness_scope }
# Inherited slug_candidates from Spree::Base work for most cases
# Override only if needed for custom logic
end
end
With Soft Deletes
# app/models/spree/brand.rb
module Spree
class Brand < Spree::Base
extend FriendlyId
acts_as_paranoid
friendly_id :slug_candidates, use: [:slugged, :history]
validates :name, presence: true
validates :slug, presence: true, uniqueness: { conditions: -> { with_deleted } }
after_destroy :rename_slug_on_delete
after_restore :regenerate_slug_on_restore
private
def rename_slug_on_delete
return if new_record?
new_slug = "deleted-#{slug}-#{uuid_for_friendly_id}"[..254]
update_column(:slug, new_slug)
end
def regenerate_slug_on_restore
self.slug = nil
save!
end
# slug_candidates inherited from Spree::Base handles deleted records
end
end
Routing
Spree uses :id as the route parameter for all resources, but the .friendly finder allows both IDs and slugs to work interchangeably.
Default Spree Routes
# Spree's default routes use :id parameter
# GET /products/:id -> handles both numeric IDs and slugs
# Both of these URLs work:
# /products/123 (finds by ID)
# /products/ruby-tshirt (finds by slug)
Custom Resources with Slugs
# config/routes.rb
Spree::Core::Engine.routes.draw do
resources :brands, only: [:show]
end
Always use :id as the route parameter, not :slug. This allows FriendlyId’s .friendly finder to accept both numeric IDs and slugs, maintaining backward compatibility.
Controller Implementation
# app/controllers/spree/brands_controller.rb
module Spree
class BrandsController < Spree::StoreController
def show
# Use .friendly to find by both ID and slug
@brand = Spree::Brand.friendly.find(params[:id])
# Optional: Redirect if using old ID or non-canonical slug
redirect_if_legacy_path
end
private
def redirect_if_legacy_path
# If an old id or a numeric id was used to find the record,
# do a 301 redirect that uses the current friendly id.
if params[:id] != @brand.friendly_id
redirect_to brand_path(@brand), status: :moved_permanently
end
end
end
end
Storefront Examples
# From storefront/app/controllers/spree/products_controller.rb:43-45
def load_product
@product ||= find_with_fallback_default_locale do
current_store.products.friendly.find(params[:id])
end
end
# From storefront/app/controllers/spree/taxons_controller.rb:22-24
def load_taxon
@taxon ||= find_with_fallback_default_locale do
current_store.taxons.friendly.find(params[:id])
end
end
find_with_fallback_default_locale is a helper method that is used to find a resource in the current locale or the default store locale.
API Routes
Both Storefront API and Platform API accept slugs or IDs:
# From api/app/controllers/spree/api/v2/storefront/products_controller.rb:20
@resource ||= find_with_fallback_default_locale do
scope.includes(variants: variant_includes, master: variant_includes)
.friendly.find(params[:id])
end
# API Examples:
# GET /api/v2/storefront/products/123
# GET /api/v2/storefront/products/ruby-on-rails-tshirt
Best Practices
1. Always Use the friendly Scope
# ✅ Good - handles both slugs and IDs
product = Spree::Product.friendly.find(params[:id])
# ❌ Bad - only works with IDs
product = Spree::Product.find(params[:id])
2. Set Slugs to nil to Regenerate
# ✅ Good - regenerates slug from name
product.update!(slug: nil)
# ❌ Bad - keeps old slug even if name changed
product.update!(name: 'New Name') # slug unchanged
3. Validate Slug Uniqueness with Scope
# Always use the appropriate scope
validates :slug,
presence: true,
uniqueness: {
scope: spree_base_uniqueness_scope, # eg [:tenant_id]
case_sensitive: true
}
4. Eager Load Slugs When Needed
# ✅ Good - includes slug history
products = Spree::Product.includes(:slugs)
# For routing, slug history isn't needed
products = Spree::Product.all # Just uses slug column
5. Preserve Slug History
# ✅ Good - maintains URL consistency
product.update!(name: 'New Name', slug: nil) # Old slug still works
# ❌ Bad - breaks existing URLs
product.update!(slug: 'completely-different') # Old slug breaks
6. Handle Locale-Specific Slugs
# ✅ Good - provide translations
product.translations.create!(
locale: :fr,
name: 'Nom Français',
slug: nil # Auto-generates
)
# ❌ Bad - reusing English slug
product.translations.create!(
locale: :fr,
name: 'Nom Français',
slug: product.slug # Not SEO-friendly for French
)
7. Use Route Helpers with :id Parameter
# ✅ Good - uses Spree routes with :id parameter
spree.product_path(product) # Works with both slug and ID
# Generates: /products/ruby-tshirt (uses slug automatically)
# ✅ Good - explicit slug
spree.product_path(product.slug)
# Generates: /products/ruby-tshirt
# ✅ Good - explicit ID
spree.product_path(product.id)
# Generates: /products/123
# ❌ Bad - manual URL construction
"/products/#{product.slug}" # Doesn't handle edge cases
# ❌ Bad - using :slug as route parameter
resources :products, param: :slug # Don't do this - breaks ID lookup
8. Test Slug Behavior
# Example spec
RSpec.describe Spree::Brand, type: :model do
describe 'slugs' do
it 'generates slug from name' do
brand = create(:brand, name: 'Test Name')
expect(brand.slug).to eq('test-name')
end
it 'ensures slug uniqueness' do
create(:brand, name: 'Test')
duplicate = create(:brand, name: 'Test')
expect(duplicate.slug).to match(/test-.+/)
end
it 'preserves old slugs in history' do
brand = create(:brand, name: 'Original')
brand.update!(name: 'Changed', slug: nil)
expect(brand.slug).to eq('changed')
expect(brand.slugs.pluck(:slug)).to include('original')
end
end
end
Troubleshooting
Slug Not Updating When Name Changes
Problem: Changing a product’s name doesn’t update the slug.
Solution: Explicitly set slug to nil to trigger regeneration:
product.update!(name: 'New Name', slug: nil)
Duplicate Slug Errors
Problem: Getting uniqueness validation errors on slug.
Solution: Check if you’re properly scoping uniqueness:
# Ensure correct scope in validation
validates :slug,
uniqueness: {
scope: spree_base_uniqueness_scope,
case_sensitive: true
}
Slugs Not Working After Restore
Problem: Restored products have deleted- prefix in slug.
Solution: Ensure you have the after_restore callback:
after_restore :regenerate_slug
def regenerate_slug
self.slug = nil
save!
end
Translation Slugs Not Generated
Problem: Translation slugs are blank or invalid.
Solution: Ensure you’re setting slugs correctly in Translation model:
# In your Translation class
before_validation :set_slug
def set_slug
self.slug = generate_slug
end
def generate_slug
if name.blank? && slug.blank?
translated_model.name.to_url
elsif slug.blank?
name.to_url
else
slug.to_url
end
end
Reference
Key Files
core/app/models/spree/product/slugs.rb - Product slug implementation
core/app/models/spree/taxon.rb - Taxon permalink implementation (lines 33, 230-365)
core/app/models/spree/store.rb - Store code implementation (lines 24, 543-568)
core/app/models/concerns/spree/translatable_resource_slug.rb - Translation helper
core/config/initializers/friendly_id.rb - Global FriendlyId config
core/lib/friendly_id/paranoia.rb - Paranoia integration
Useful Methods
| Method | Description |
|---|
Model.friendly.find(slug) | Find by slug or ID |
resource.slug | Get current slug |
resource.slug = 'custom' | Set custom slug |
resource.slugs | Access slug history |
resource.localized_slugs_for_store(store) | Get all locale slugs |
Model.slug_available?(slug, id) | Check slug availability |
resource.friendly_id | Current friendly ID |
resource.set_friendly_id(slug, locale) | Set slug for locale |