Fast Images in React on Rails
Next.js markets next/image as a single component that solves responsive
images, lazy loading, modern formats, layout-shift (CLS) prevention, and LCP
prioritization. Rails already ships the primitives for every one of those
concerns — no hosted image service, no Vercel lock-in. This recipe shows how to
combine them in a React on Rails app, both in ERB views and in React components
rendered with react_component.
A first-party
<Image>component is under consideration for React on Rails (see issue #3874). Everything on this page works today with stock Rails — the proposed component would only package these defaults, not replace them.
For configuring webpack to bundle images imported from your JavaScript (the asset-loading side), see Configuring Images and Assets with Webpack. This page is about the markup and serving side: what the browser receives.
The checklist
A "fast image" means:
- Responsive
srcset+sizes— the browser downloads a size appropriate for the layout, not a 2400px original on a phone. - Intrinsic
width/height(+ CSSaspect-ratio) — layout space is reserved before the image loads, so there is no CLS. loading="lazy"+decoding="async"on everything below the fold.fetchpriority="high"+loading="eager"+ a preload on the LCP/hero image only.- AVIF/WebP with a fallback via
<picture>.
Responsive srcset from the asset pipeline
For images that ship with your app (Sprockets/Propshaft), commit the size
variants and let image_tag build the srcset. Hash keys are resolved through
the asset pipeline just like the main src, so fingerprinting works:
<%= image_tag("team-photo-800.jpg",
srcset: {
"team-photo-400.jpg" => "400w",
"team-photo-800.jpg" => "800w",
"team-photo-1600.jpg" => "1600w"
},
sizes: "(max-width: 600px) 100vw, 600px",
width: 800,
height: 533,
loading: "lazy",
decoding: "async",
alt: "The team at launch") %>
sizes tells the browser how wide the image will render at each viewport
width; without it the browser assumes 100vw and over-downloads.
Responsive srcset from Active Storage variants
For user-uploaded images, generate the width set with
Active Storage variants.
You need the image_processing gem (libvips recommended):
# Gemfile
gem "image_processing", "~> 1.2"
<%# app/views/products/show.html.erb %>
<% widths = [400, 800, 1600] %>
<% variants = widths.index_with { |w| @product.photo.variant(resize_to_limit: [w, nil]) } %>
<%= image_tag url_for(variants[800].processed),
srcset: widths.map { |w| "#{url_for(variants[w].processed)} #{w}w" }.join(", "),
sizes: "(max-width: 600px) 100vw, 600px",
width: 800,
height: (800 * @product.photo_aspect_ratio).round,
loading: "lazy",
decoding: "async",
alt: @product.name %>
variant(...) does not resize anything until the variant is first served.
With the default redirect mode, the first visitor to hit each width pays the
transformation cost (or times out on large images), and a cold cache after a
storage migration pays it again for every image on the page — multiplied by
every width in your srcset.
Treat request-time generation as a fallback, not the plan:
-
Pre-generate variants when the upload happens, in a background job, by calling
.processed(which transforms and uploads the variant if missing):class Product < ApplicationRecord
has_one_attached :photo do |attachable|
attachable.variant :w400, resize_to_limit: [400, nil], preprocessed: true
attachable.variant :w800, resize_to_limit: [800, nil], preprocessed: true
attachable.variant :w1600, resize_to_limit: [1600, nil], preprocessed: true
end
endNamed variants with
preprocessed: true(Rails 7.1+) enqueue transformation right after upload instead of on first request. Reference them as@product.photo.variant(:w800). -
Run
analyzeon attach (Active Storage does this by default via a job) sometadata[:width]/metadata[:height]are available for CLS attributes without downloading the blob. Until that job has run,attachment.analyzed?isfalseand the dimensions are missing — guard for it and fall back to a default aspect ratio, as the snippets below do. -
Serve through a CDN. Put a CDN in front of your storage service (or use proxy mode plus a CDN in front of the app) so each generated variant is transformed once and cached at the edge — not re-served by your Rails dynos.
To get intrinsic dimensions for the width/height attributes, read the
analyzed metadata instead of hardcoding:
# app/models/product.rb
# Height ÷ width (≈0.667 for a 3:2 landscape photo) — the multiplier that
# turns a display width into the matching height attribute. Note that CSS
# `aspect-ratio` is the inverse: width / height.
def photo_aspect_ratio
meta = photo.metadata
return 2.0 / 3 unless meta["width"].to_i.positive? && meta["height"].to_i.positive?
meta["height"].to_f / meta["width"]
end
Guard on both dimensions: until the analyzer job has run
(photo.analyzed? is false), either value can be missing, and a zero height
would emit height="0" and an aspect-ratio of 0.
Passing image props to a React component
When the <img> lives inside a React component, keep the URL math in Rails —
where the asset pipeline and Active Storage live — and hand React a plain props
hash via react_component. Compute the props in a helper:
# app/helpers/images_helper.rb
module ImagesHelper
# Must match the named variants on the model (:w400, :w800, :w1600),
# pre-generated at upload time with preprocessed: true — see the
# variant-generation warning above.
RESPONSIVE_WIDTHS = [400, 800, 1600].freeze
# Returns {src:, srcSet:, sizes:, width:, height:} for an Active Storage attachment.
def responsive_image_props(attachment, sizes: "100vw", display_width: 800)
# Snap to the closest generated width so an unlisted value can't produce
# a nil variant lookup.
display_width = RESPONSIVE_WIDTHS.min_by { |w| (w - display_width).abs }
meta = attachment.metadata
aspect_ratio =
if meta["width"].to_i.positive? && meta["height"].to_i.positive?
meta["height"].to_f / meta["width"]
else
2.0 / 3 # analysis hasn't run yet — see the warning above
end
srcset = RESPONSIVE_WIDTHS.map { |w| "#{url_for(attachment.variant(:"w#{w}"))} #{w}w" }
{
src: url_for(attachment.variant(:"w#{display_width}")),
srcSet: srcset.join(", "),
sizes: sizes,
width: display_width,
height: (display_width * aspect_ratio).round
}
end
end
If you cannot define named variants (or are on Rails < 7.1), the request-time
form — attachment.variant(resize_to_limit: [w, nil]).processed — is a drop-in
replacement for attachment.variant(:"w#{w}"). But it transforms on first
request, which is exactly the footgun the warning above describes: acceptable
for a low-traffic admin page, not for anything user-facing.
<%# app/views/products/show.html.erb %>
<%= react_component("ProductHero", props: {
name: @product.name,
image: responsive_image_props(@product.photo, sizes: "(max-width: 600px) 100vw, 600px")
}) %>
The React side is just an <img> spreading those attributes — it renders
identically on the server and client, so there is nothing to hydrate
incorrectly:
// app/javascript/src/ProductHero/ror_components/ProductHero.jsx
export default function ProductHero({ name, image }) {
return (
<figure>
<img
src={image.src}
srcSet={image.srcSet}
sizes={image.sizes}
width={image.width}
height={image.height}
loading="lazy"
decoding="async"
alt={name}
/>
<figcaption>{name}</figcaption>
</figure>
);
}
For asset-pipeline images the same pattern applies — build the hash with
image_path/image_url instead of url_for(variant).
CLS prevention: intrinsic size + aspect-ratio
Always emit width and height attributes (the intrinsic pixel dimensions,
not the display size). Browsers use them to reserve the correct box before the
bytes arrive, which is what keeps CLS at zero. Then let CSS control the actual
display size:
img {
max-width: 100%;
height: auto; /* keep the reserved aspect ratio when width is constrained */
}
If you size an image with CSS alone (e.g. a background-size: cover-style
crop), reserve the space explicitly:
.card-thumb {
aspect-ratio: 3 / 2;
width: 100%;
object-fit: cover;
}
Defaults for non-hero images
Every image that can start below the fold should opt out of competing with critical resources:
loading="lazy"— the browser defers the download until the image nears the viewport.decoding="async"— decode off the main thread instead of blocking paint.
These are plain attributes in both ERB (loading: "lazy", decoding: "async")
and JSX (loading="lazy" decoding="async"). They are the right default for
everything except the LCP image — lazy-loading the hero is one of the most
common LCP regressions.
The LCP/hero image: prioritize and preload
For the one image that is your Largest Contentful Paint element, invert the defaults:
<%= image_tag("hero-1600.jpg",
srcset: { "hero-800.jpg" => "800w", "hero-1600.jpg" => "1600w" },
sizes: "100vw",
width: 1600,
height: 900,
loading: "eager",
fetchpriority: "high",
alt: "Hand-thrown ceramic mugs lined up on a workbench") %>
fetchpriority="high"tells the browser to fight for bandwidth for this request.loading="eager"(the default, but explicit beats accidentallazy).- Give the hero real
alttext — it is usually meaningful content. Reservealt: ""for purely decorative images.
Additionally, preload it from your layout's <head>, so the fetch starts
before the browser has parsed down to the <img> (or, for client-rendered
components, before React renders at all):
<%# app/views/layouts/application.html.erb %>
<head>
<%= yield :preloads %>
<%# ... %>
</head>
If the hero has a single source,
preload_link_tag
is the right tool:
<%# app/views/home/index.html.erb %>
<% content_for :preloads do %>
<%= preload_link_tag image_path("hero-1600.jpg"), as: "image", fetchpriority: "high" %>
<% end %>
For a responsive hero (the srcset case above), the preload must carry
imagesrcset/imagesizes so the browser preloads the same candidate the
<img> will pick. Use a plain <link> here — not preload_link_tag:
besides the tag, preload_link_tag also sends an HTTP Link: rel=preload
header built only from the fixed href (it does not include
imagesrcset/imagesizes), so browsers that honor the header would fetch the
full-size image on every viewport, duplicating the download the responsive
preload was supposed to avoid.
<%# app/views/home/index.html.erb %>
<% content_for :preloads do %>
<link
rel="preload"
as="image"
fetchpriority="high"
imagesrcset="<%= image_path('hero-800.jpg') %> 800w, <%= image_path('hero-1600.jpg') %> 1600w"
imagesizes="100vw"
/>
<% end %>
This is deliberately a layout-level concern: the preload belongs in the
document <head> your layout owns, declared by the page that knows its hero.
You do not need (and React on Rails does not currently provide) a
head-injection helper for it. Preload at most one or two images per page —
preloading more steals bandwidth from the things you actually wanted to
prioritize.
Modern formats: AVIF/WebP with fallback
AVIF and WebP are typically 30–50% smaller than JPEG at equivalent quality.
Active Storage variants can transcode formats too (libvips must be built with
AVIF support for :avif). Define them as named, pre-generated variants, like
the width variants earlier:
# app/models/product.rb — add format variants next to the width variants
attachable.variant :w800_avif, resize_to_limit: [800, nil], format: :avif, preprocessed: true
attachable.variant :w800_webp, resize_to_limit: [800, nil], format: :webp, preprocessed: true
<picture>
<source srcset="<%= url_for(@product.photo.variant(:w800_avif)) %>" type="image/avif" />
<source srcset="<%= url_for(@product.photo.variant(:w800_webp)) %>" type="image/webp" />
<%= image_tag url_for(@product.photo.variant(:w800)),
width: 800, height: 533, loading: "lazy", decoding: "async",
alt: @product.name %>
</picture>
The browser picks the first <source> it supports and falls back to the
<img> otherwise — older browsers never download the modern formats. On
Rails 7.1+ you can also use the
picture_tag
helper. In JSX the same markup is <picture> + <source> elements with
srcSet/type props.
For brevity this example is fixed-width — one 800px candidate per format.
In production, make each <source> responsive exactly like
the plain <img> above:
define the format variants at every width (:w400_avif, :w800_avif,
:w1600_avif, …) and give each <source> the multi-width srcset plus the
same sizes value. Otherwise the browser always downloads the one listed
candidate and you lose the responsive savings. Note the multiplication — two
formats × three widths is six variants per image — which is why these are
defined with preprocessed: true rather than transformed at request time.
Putting it together
| Concern | Non-hero image | LCP/hero image |
|---|---|---|
srcset/sizes | yes | yes |
width/height | always | always |
loading | lazy | eager |
decoding | async | (default) |
fetchpriority | (default) | high |
| Preload | no | preload <link> in the layout <head> |
| Formats | AVIF/WebP via <picture> | AVIF/WebP via <picture> |
To verify the result, check LCP and CLS in Chrome DevTools (Performance panel) or with the web-vitals library: the hero should be discovered in the preload scanner's first pass, and CLS from images should be ~0.
See also
- Configuring Images and Assets with Webpack — bundling images imported from JavaScript/SCSS.
- Font Optimization — the companion recipe for the other classic CLS source.
- web.dev: Optimize LCP and web.dev: CLS.