Image SEO: 7 things I'd do differently on the next big site (starting with hashed filenames)
Images are where developer defaults and SEO quietly collide. Bundlers, CDNs and component libraries all make reasonable choices that add up to images search engines can't find, can't understand, or forget every time you
Images are where developer defaults and SEO quietly collide. Bundlers, CDNs and component libraries all make reasonable choices that add up to images search engines can't find, can't understand, or forget every time you deploy.
Here's what I'd do differently from day one, in rough order of how much each one hurts.
1. I wouldn't let image URLs change on every deploy
Many build pipelines rename assets with content hashes: hero.3f9a1c.webp. That's great for caching. But if the hash depends on something other than the image content (build-tool version, a config change, re-encoding), every image URL changes on deploys where the image didn't.
For image search, a new URL is a new image. Accumulated signals don't carry over, and the old URLs 404.
What I'd do:
- Keep content images (product photos, article illustrations) at stable URLs managed by the CMS or media library, not the JS bundler.
- If you hash, make sure the hash is content-only, so an image that didn't change keeps its URL.
- If image URLs do change, redirect the old ones.
Quick check, run before and after a deploy:
curl -s https://example.com/products/blue-chair | grep -oE 'src="[^"]+\.(jpg|jpeg|png|webp|avif)' | sort > before.txt
# ...deploy...
curl -s https://example.com/products/blue-chair | grep -oE 'src="[^"]+\.(jpg|jpeg|png|webp|avif)' | sort > after.txt
diff before.txt after.txt
If the diff isn't empty and no image changed, you've found the problem.
2. I'd keep important images out of CSS backgrounds
.hero { background-image: url(/img/hero.jpg); }
Search engines index images from <img> (and <picture>) elements. CSS backgrounds generally aren't indexed as content images, and they have no alt text. Decorative backgrounds are fine. A product shot or an infographic that should rank needs to be a real image element.
<img src="/img/blue-chair-front.jpg" alt="Blue velvet armchair, front view"
width="1200" height="900">
3. I'd always set width and height
Missing dimensions are one of the most common causes of layout shift (CLS): the page jumps when images load. Set width and height attributes that match the aspect ratio, and let CSS make the image responsive:
img { max-width: 100%; height: auto; }
Modern browsers use the attributes to reserve space before the image arrives.
4. I wouldn't lazy-load the LCP image
loading="lazy" on every image is a common blanket default from component libraries. On the hero image, which is often the Largest Contentful Paint element, it delays the most important paint on the page.
<!-- above the fold: load eagerly and prioritize -->
<img src="/img/hero.jpg" alt="..." width="1600" height="900" fetchpriority="high">
<!-- below the fold: lazy is fine -->
<img src="/img/detail.jpg" alt="..." width="800" height="600" loading="lazy">
Audit it with a quick script that lists the first images on each template and their loading attributes.
5. I'd give alt text an owner
On most sites, alt text is either missing, the filename (IMG_4821), or stuffed with keywords. That happens because nobody owns it. Devs assume content will write it, and content doesn't know the field exists.
What I'd do:
- Make alt text a required field in the CMS for content images, with a "decorative" checkbox that outputs
alt="". - Write alt for people using screen readers first. Describe what's in the image and why it's there. Good alt text for accessibility is good alt text for search.
- Add a CI or crawl check for images with no alt attribute at all:
# missing_alt.py
import re, sys, requests
for url in open(sys.argv[1]).read().split():
html = requests.get(url, timeout=10).text
imgs = re.findall(r"<img\b[^>]*>", html, re.I)
missing = [i for i in imgs if not re.search(r"\balt=", i, re.I)]
if missing:
print(f"{len(missing):>3} missing alt {url}")
Note the difference: alt="" is a deliberate choice for decorative images. A missing alt attribute is a bug.
6. I'd use descriptive filenames from the start
blue-velvet-armchair-front.jpg beats DSC_0042.jpg. Filenames are a minor signal, but they're free at upload time and painful to change later, because changing them changes the URL (see #1). Have the CMS slugify a human-entered name on upload.
7. I'd serve modern formats with a fallback, at the right size
AVIF or WebP with a JPEG fallback, and srcset so phones don't download desktop-sized files:
<picture>
<source type="image/avif" srcset="/img/chair-800.avif 800w, /img/chair-1600.avif 1600w">
<source type="image/webp" srcset="/img/chair-800.webp 800w, /img/chair-1600.webp 1600w">
<img src="/img/chair-1600.jpg" alt="Blue velvet armchair, front view"
width="1600" height="1200" sizes="(max-width: 800px) 100vw, 800px">
</picture>
Most image CDNs can do this for you. Just keep the base URL stable.
A one-file image audit
Most of the items above can be checked from raw HTML in one pass. This script flags missing alt attributes, missing dimensions, lazy-loaded images near the top of the page, camera-style filenames, and counts inline CSS background images:
# image_audit.py usage: python image_audit.py urls.txt
import re, sys, requests
def attr(tag, name):
m = re.search(rf'\b{name}\s*=\s*["\']([^"\']*)["\']', tag, re.I)
return m.group(1) if m else None
for url in open(sys.argv[1]).read().split():
html = requests.get(url, timeout=10).text
imgs = re.findall(r"<img\b[^>]*>", html, re.I)
problems = []
for n, tag in enumerate(imgs):
src = attr(tag, "src") or attr(tag, "data-src") or "?"
if attr(tag, "alt") is None:
problems.append(f"missing alt: {src}")
if not (attr(tag, "width") and attr(tag, "height")):
problems.append(f"no dimensions: {src}")
if n < 2 and (attr(tag, "loading") or "").lower() == "lazy":
problems.append(f"lazy near top: {src}")
if re.search(r"(IMG|DSC)_?\d+", src):
problems.append(f"camera filename: {src}")
bg = re.findall(r"background(?:-image)?\s*:[^;\"]*url\(", html, re.I)
print(f"{url}: {len(imgs)} <img>, {len(bg)} inline CSS backgrounds")
for p in problems:
print(" ", p)
A few notes on reading the output:
- Run it on one URL per template (home, category, product, article), not every page. Image problems come from templates and components, so one representative URL per template finds most of them.
- "Lazy near top" is a heuristic for #4. The hero is usually among the first images in the HTML, but confirm the actual LCP element in Lighthouse or the DevTools Performance panel before changing anything.
- It only sees raw HTML. Images injected by JavaScript won't appear, and that's worth knowing in itself, because crawlers that don't render won't see them either. Background images set in external stylesheets won't be counted, so check the hero component's CSS by hand.
Save the output from each run. Diffing it after a redesign or a dependency upgrade is the cheapest way to catch a component library quietly changing its defaults.
The pattern
Every item here is a sensible default from someone's point of view: bundlers want cache-busting, designers want CSS heroes, component libraries want lazy loading everywhere. Image SEO problems come from nobody looking at the combined result. A periodic crawl audit (a tool like seotto.com, or scripts like the ones above) is how you see it.
Related reading
- JavaScript SEO FAQ: 11 questions frontend devs ask (with straight answers)
- Core Web Vitals regressed and you found out 4 weeks later: catching it with the CrUX API and a lab gate
- View source vs Inspect element: the SEO bug hiding between raw and rendered HTML
- Find duplicate and missing title tags with a Python crawl script
Which of these is lurking on your site right now? I'd put money on #4, the lazy-loaded hero. Check yours and tell me in the comments.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.