Skip to main content
Spree uses Rails Active Storage for all file uploads and image handling. This guide covers how images are stored, processed, and displayed in Spree.

How Spree Stores Images

Spree provides two storage service configurations:
ServicePurposeExample Models
public_storage_service_namePublic assets (images, logos)Product images, Store logo, Taxon images
private_storage_service_namePrivate assets (exports, imports)CSV exports, Digital downloads

Configuration

Configure storage services in your Spree initializer:
config/initializers/spree.rb
Spree.public_storage_service_name = :amazon_public   # S3 bucket for public assets
Spree.private_storage_service_name = :amazon_private # S3 bucket for private assets
If not configured, Spree uses the default Active Storage service from config/storage.yml.
For production deployments, we recommend using cloud storage (S3, GCS, Azure) instead of local disk storage. See Active Storage Configuration for details.

Adding Images to Your Models

For most models, use Rails Active Storage directly with Spree’s storage service:
app/models/spree/brand.rb
module Spree
  class Brand < Spree::Base
    # Public image (product photos, logos, etc.)
    has_one_attached :logo, service: Spree.public_storage_service_name

    # Multiple public images
    has_many_attached :gallery_images, service: Spree.public_storage_service_name

    # Private file (invoices, reports, etc.)
    has_one_attached :contract, service: Spree.private_storage_service_name
  end
end

Models Using Active Storage

Most Spree models use Active Storage directly:
ModelAttachmentType
Spree::Storelogo, favicon_image, social_image, mailer_logoPublic
Spree::Taxonimage, square_imagePublic
Spree::PostimagePublic
Spree::UseravatarPublic
Spree::PageSectionassetPublic
Spree::PageBlockassetPublic
Spree::DigitalattachmentPrivate
Spree::ExportattachmentPrivate
Spree::ImportattachmentPrivate

Product & Variant Images

Product images are a special case that use the Spree::Image model (a subclass of Spree::Asset). This provides additional features needed for e-commerce:
  • Multiple images per variant with ordering
  • Alt text for accessibility and SEO
  • Position management for image galleries
  • Polymorphic association to variants

How It Works

# Images belong to variants, not products directly
variant.images # => [Spree::Image, Spree::Image, ...]

# Products access images through their variants
product.images        # => Images from master variant
product.variant_images # => Images from all variants including master

# Each image wraps an Active Storage attachment
image = variant.images.first
image.attachment      # => ActiveStorage::Attached::One
image.alt             # => "Red T-Shirt Front View"
image.position        # => 1

Creating Product Images

# Add image to a variant
variant.images.create!(
  attachment: File.open('path/to/image.jpg'),
  alt: 'Product front view'
)

# Or using Active Storage's attach method
image = variant.images.new(alt: 'Product side view')
image.attachment.attach(
  io: File.open('path/to/image.jpg'),
  filename: 'side-view.jpg',
  content_type: 'image/jpeg'
)
image.save!

Spree::Asset Class

Spree::Asset is the base class for product images:
# Key attributes and methods
asset.attachment      # ActiveStorage attachment
asset.attached?       # Check if file is attached
asset.blob            # ActiveStorage blob
asset.filename        # Original filename
asset.key             # Storage key
asset.viewable        # Parent record (e.g., Spree::Variant)
asset.viewable_type   # "Spree::Variant"
asset.position        # Order in gallery
asset.alt             # Alt text (Spree::Image only)
Spree::Asset and Spree::Image are only used for product/variant images. For all other models, use Active Storage directly with has_one_attached or has_many_attached.

Image Processing

Spree uses libvips for image processing. Images are automatically:
  • Converted to WebP format for optimal file size
  • Preprocessed on upload using named variants for common sizes for Products
  • Processed on demand for other models
  • Cached for subsequent requests

Installing libvips

# macOS
brew install vips

# Ubuntu/Debian
apt-get install libvips

# Alpine Linux
apk add vips

Preprocessed Named Variants

Preprocessed named variants are only available for product images (Spree::Image). For other models, images are processed on demand.
When an image is uploaded, Spree automatically generates optimized versions in the background, so they’re ready to serve immediately without on-demand processing. You can still generate on-the-fly variants if you wish, but we recommend using the preprocessed named variants for optimal performance.

Available Variant Sizes

NameDimensionsUse Case
:mini128×128Thumbnails, cart items
:small256×256Product listings, galleries
:medium400×400Product cards, category pages
:large720×720Product detail pages
:xlarge2000×2000Zoom, high-resolution displays
All variants are:
  • Cropped to fill the exact dimensions (resize_to_fill)
  • Converted to WebP format for smaller file sizes
  • Optimized with quality settings balanced for size and clarity

Using Named Variants

# In views - recommended approach
<%= spree_image_tag product.images.first, variant: :medium %>

# Get URL only
<%= spree_image_url product.images.first, variant: :large %>

# Command line (rails console)
image.attachment.variant(:small)
Always use named variants (:mini, :small, :medium, :large, :xlarge) instead of custom dimensions for product images. Named variants are preprocessed on upload, resulting in faster page loads.

Configuring Variant Sizes

Customize variant dimensions in your Spree initializer:
config/initializers/spree.rb
Spree.configure do |config|
  config.product_image_variant_sizes = {
    mini: [128, 128],
    small: [256, 256],
    medium: [400, 400],
    large: [720, 720],
    xlarge: [2000, 2000]
  }
end
Changing variant sizes only affects newly uploaded images. Existing images will retain their original variant dimensions until reprocessed.

Reprocessing Existing Images

After changing variant sizes or upgrading Spree, reprocess existing images in background jobs:
Spree::Image.find_each do |image|
  next unless image.attachment.attached?
  
  blob = image.attachment.blob
  
  %i[mini small medium large xlarge og_image].each do |variant_name|
    transformations = Spree::Image.attachment_reflections['attachment']
                                  .named_variants[variant_name]
                                  .transformations
    
    ActiveStorage::TransformJob.perform_later(blob, transformations)
  end
end
Or process synchronously (slower but immediate)
Spree::Image.find_each do |image|
  next unless image.attached?
  [:mini, :small, :medium, :large, :xlarge].each do |variant_name|
    image.attachment.variant(variant_name).processed
  end
end

Dynamic Variants

For non-product images or custom dimensions, you can still generate variants on-the-fly:
# Resize to fit within dimensions (maintains aspect ratio)
spree_image_tag(image, width: 400, height: 400)

# Resize and crop to exact dimensions
spree_image_tag(image, width: 200, height: 200, crop: true)

# Resize by width only
spree_image_tag(image, width: 300, height: nil)

# Resize by height only
spree_image_tag(image, width: nil, height: 300)
Dynamic variants are generated on first request and cached.

Helpers

Spree provides several helper methods for working with images. These are defined in Spree::ImagesHelper and are available in Admin, Storefront, and API.

spree_image_tag

Generates an optimized image tag with automatic WebP conversion and retina support.
<%# Using preprocessed named variant (recommended for product images) %>
<%= spree_image_tag(
  product.images.first,
  variant: :medium,
  loading: :lazy,
  class: 'product-image',
  alt: product.name
) %>

<%# Using custom dimensions (generates variant on-the-fly) %>
<%= spree_image_tag(
  product.images.first,
  width: 400,
  height: 400,
  loading: :lazy,
  class: 'product-image',
  alt: product.name
) %>
Output:
<img
  loading="lazy"
  class="product-image"
  alt="Red T-Shirt"
  src="https://example.com/rails/active_storage/representations/...">
Features:
  • Automatically scales dimensions by 2x for retina displays (when using width/height)
  • Converts to WebP format
  • Works with both Spree::Asset objects and Active Storage attachments
  • Supports preprocessed named variants for optimal performance
Parameters:
image
Asset | Attachment
required
Spree::Asset or ActiveStorage attachment
variant
Symbol
Named variant (:mini, :small, :medium, :large, :xlarge). When set, width, height, and format are ignored.
width
Integer
Display width in pixels. Ignored if variant is set.
height
Integer
Display height in pixels. Ignored if variant is set.
format
Symbol
Output format, e.g., :png. Ignored if variant is set.
loading
Symbol
Loading strategy: :lazy or :eager
class
String
CSS classes for the image tag
alt
String
Alt text for accessibility
Use the variant parameter with named variants for product images. These are preprocessed on upload and load faster than dynamic width/height variants.

spree_image_url

Generates a URL for an image variant.
<%# Using preprocessed named variant (recommended) %>
<%= spree_image_url(product.images.first, variant: :large) %>

<%# Using custom dimensions %>
<%= spree_image_url(product.images.first, width: 800, height: 600) %>
Output:
https://example.com/rails/active_storage/representations/proxy/...
Parameters:
image
Asset | Attachment
required
Spree::Asset or ActiveStorage attachment
variant
Symbol
Named variant (:mini, :small, :medium, :large, :xlarge). When set, width, height, and format are ignored.
width
Integer
Width in pixels. Ignored if variant is set.
height
Integer
Height in pixels. Ignored if variant is set.
format
Symbol
Output format, e.g., :png. Ignored if variant is set.
URLs generated by this helper are static and can be cached by CDNs. Don’t use this for private assets!

spree_asset_aspect_ratio

Returns the aspect ratio of an image for CSS layouts.
<div style="aspect-ratio: <%= spree_asset_aspect_ratio(product.images.first) %>">
  <%= spree_image_tag(product.images.first, width: 400) %>
</div>
Returns: Float (e.g., 1.5, 0.75, 1.0)

Displaying Images

Product Images in Views

<%# Display primary product image using named variant %>
<% if product.images.any? %>
  <%= spree_image_tag product.images.first,
      variant: :large,
      alt: product.name,
      class: 'product-main-image' %>
<% end %>

<%# Display image gallery with thumbnails %>
<div class="product-gallery">
  <% product.images.each do |image| %>
    <%= spree_image_tag image,
        variant: :small,
        alt: image.alt.presence || product.name,
        class: 'gallery-thumbnail' %>
  <% end %>
</div>

<%# High-resolution image for zoom %>
<%= spree_image_tag product.images.first,
    variant: :xlarge,
    alt: product.name,
    class: 'product-zoom-image' %>

Active Storage Attachments

<%# Store logo %>
<% if current_store.logo.attached? %>
  <%= spree_image_tag current_store.logo,
      width: 200,
      height: 60,
      alt: current_store.name %>
<% end %>

<%# Taxon image %>
<% if taxon.image.attached? %>
  <%= spree_image_tag taxon.image,
      width: 400,
      height: 300,
      alt: taxon.name %>
<% end %>

<%# User avatar %>
<% if user.avatar.attached? %>
  <%= spree_image_tag user.avatar,
      width: 48,
      height: 48,
      class: 'rounded-full' %>
<% end %>

Checking for Attached Images

<%# For Spree::Asset/Image %>
<% if product.images.any? %>
  <%= spree_image_tag product.images.first, width: 400 %>
<% else %>
  <%= image_tag 'placeholder.png', width: 400 %>
<% end %>

<%# For Active Storage attachments %>
<% if @brand.logo.attached? %>
  <%= spree_image_tag @brand.logo, width: 200 %>
<% else %>
  <div class="placeholder">No logo</div>
<% end %>

Admin Dashboard

In the admin dashboard, use the spree_file_field form helper for image uploads:
<%= form_with model: @brand do |f| %>
  <%= f.spree_file_field :logo,
      width: 300,
      height: 300,
      crop: true,
      help: "Recommended size: 300x300px" %>
<% end %>
See the Form Builder and Components documentation for more details.

API Serialization

Product images are automatically serialized in API responses:
{
  "data": {
    "id": "1",
    "type": "product",
    "attributes": {
      "name": "Red T-Shirt"
    },
    "relationships": {
      "images": {
        "data": [
          { "id": "1", "type": "image" }
        ]
      }
    }
  },
  "included": [
    {
      "id": "1",
      "type": "image",
      "attributes": {
        "position": 1,
        "alt": "Front view",
        "original_url": "https://...",
        "styles": [
          {
            "url": "https://...",
            "width": 650,
            "height": 870,
            "size": "650x870>"
          }
        ]
      }
    }
  ]
}

Best Practices

Use appropriate storage services - Public assets use public_storage_service_name, private files use private_storage_service_name
Always provide alt text - For accessibility and SEO, always include meaningful alt text
Use lazy loading - Add loading: :lazy for images below the fold
Specify dimensions - Always provide width and height to prevent layout shifts
Use CDN in production - Configure Active Storage to use a CDN for faster delivery
Validate uploads - Add file size and type validations to prevent abuse
# Example validation
class Brand < Spree::Base
  has_one_attached :logo, service: Spree.public_storage_service_name

  validate :acceptable_logo

  private

  def acceptable_logo
    return unless logo.attached?

    if logo.blob.byte_size > 5.megabytes
      errors.add(:logo, 'must be less than 5MB')
    end

    acceptable = ['image/jpeg', 'image/png', 'image/webp']
    unless acceptable.include?(logo.blob.content_type)
      errors.add(:logo, 'must be JPEG, PNG, or WebP')
    end
  end
end