Mert Özen Further With Every Line
SEO & GEO Technical SEO — #13

Image SEO: Alt Text, File Names, srcset/picture, Image Sitemaps, CDN Domains, og:image and max-image-preview

Mert Özen Nov 15, 2026 12 min 5 views
Image SEO: Alt Text, File Names, srcset/picture, Image Sitemaps, CDN Domains, og:image and max-image-preview

Google finds images only in the src of the img element; CSS backgrounds aren't indexed, and srcset and picture always need a src fallback. We generate cover alt text from catalog data, keep file names stable, add image:loc to the sitemap, and set up the CDN domain and the preferred image (og:image, primaryImageOfPage, max-image-preview:large) with ASP.NET Core and Angular's NgOptimizedImage.

Version note: The code in this post was built and tested with .NET 10 (ASP.NET Core 10.0.15) and Angular 22.2 (@angular/core 22.2.6); the rules follow Google's image SEO guide (last updated 2 March 2026), the image sitemap page (10 December 2025) and the robots meta tag documentation (24 March 2026) as read in November 2026.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, is redesigning its book page. The designer has put the main cover in as the background-image of a div, because a discount badge will sit on top of it. The covers in the "Related books" list are loaded by a JavaScript library: the HTML contains <img data-src="..." class="lazy"> and no src. Marketing has written the alt text too: "dune book dune novel dune buy cheap dune".

Three questions. Will someone searching Google Images for "Dune book cover" find these covers? Who is the alt text for? And if the same cover sits on both the Turkish and the English page, should the file name be translated per language?

The first twelve posts of this Technical SEO series dealt with the page itself: URLs, robots.txt, sitemaps, canonicals, rendering, hreflang, migrations. The final block, which starts with this post, looks at the media on the page, catalog filters and auditing. The first topic is images.

Where Does Google Find an Image?

The first rule in Google's image SEO guide is simple: Google finds images in the src attribute of the <img> element, including an <img> inside <picture>. Images set with CSS, that is background-image, aren't indexed. That's why the main cover in the opening doesn't exist for Google Images. Decorations such as the discount badge can stay in CSS, but the cover itself has to be an <img>.

The second rule is about srcset and <picture>. The guide points out that some browsers and crawlers don't understand these attributes and recommends always giving a fallback URL in src. If you use <picture>, the <img> inside it needs a src too.

The supported formats are BMP, GIF, JPEG, PNG, WebP, SVG and AVIF. The guide also recommends that the file extension matches the actual format. Embedding an image in the HTML as a data URI is possible, but it makes the page larger, so use it with care.

Lazy loading has its own document. Google doesn't interact with the page, so it doesn't scroll or click; content has to load when it enters the viewport, and the browser's own loading="lazy" support does exactly that. In the data-src pattern from the opening, the real URL waits for a JavaScript library to move it into src. If the library doesn't run during rendering, Google is left with an <img> without a URL. The way to check is the rendered HTML in Search Console's URL Inspection tool: the image URLs should appear in src.

Google's guide calls alt text the most important attribute of image metadata. Google uses it together with the page content and its own computer vision algorithms to understand what the image shows; if the image is a link, the alt text also acts as anchor text. The same text is the image itself for someone using a screen reader. The guide's examples go from one end to the other: an image without alt is bad, a string of keywords is bad too, a single word is a little better, and a short, concrete sentence describing what's in the image is best. The "dune book dune novel..." text from the opening falls into the second group.

In Bookstore we don't write cover alt text by hand; we generate it from catalog data: title, author and edition, in the language of the page. That's exactly what a cover shows:

// alt text from catalog data: title, author, edition
public static string Alt(Book book, string lang) => lang == "tr"
    ? $"{book.Title}, {book.Author}, {FormatTr(book.Format)} kapağı"
    : $"Cover of {book.Title} by {book.Author}, {FormatEn(book.Format)}";

private static string FormatTr(BookFormat format) => format switch
{
    BookFormat.Hardcover => "ciltli baskı",
    BookFormat.Paperback => "karton kapaklı baskı",
    _ => "e-kitap",
};

The Turkish strings in this code are deliberate: the topic is text that changes with the page language. The result is "Dune, Frank Herbert, ciltli baskı kapağı" on the Turkish page and "Cover of Dune by Frank Herbert, hardcover edition" on the English one. It repeats the text printed on the cover (title and author), because for someone who can't see it, that's the information the cover carries.

Decorative images want the opposite: alt="". Writing alt text for a section divider or an icon makes a screen reader read a useless sentence and adds text to the page that carries no information. Removing the alt attribute entirely is something else: screen readers then often read the file name. The rule is "alt is always there, and empty if the image is decorative". The HTML side is covered in the "Images and Media" post of the HTML5 and Semantic HTML series.

File Names and URLs: Short, Descriptive, Stable

The guide says a file name gives Google a light hint about the image's subject; it recommends a short, descriptive name instead of IMG00023.JPG, avoiding generic names such as image1.jpg, and automating the naming on large sites. In Bookstore the file name is built from the book and the author with the Slugifier from the first post of the series:

// language-independent file name: book + author, e.g. dune-frank-herbert-640.jpg
public static string FileStem(Book book) => $"{book.Slug}-{Slugifier.ToSlug(book.Author)}";

public static string Url(Book book, int width, string ext, string baseUrl) =>
    $"{baseUrl}/images/covers/{FileStem(book)}-{width}.{ext}";

The "Şeker Portakalı" cover becomes seker-portakali-jose-mauro-de-vasconcelos-640.jpg. The guide recommends translating file names on localized sites as well, and following URL encoding rules for non-Latin characters. Bookstore doesn't, and the reason is another rule in the same guide: if an image is used on more than one page, always reference it with the same URL. A book cover doesn't change with the language; the title and author are the same in both languages anyway. When the Turkish and English pages show the same file, Google can crawl the image once and use it for both pages. Translating the file name makes sense for images whose content changes with the language, such as a campaign banner with Turkish text.

The "same URL" rule applies across deploys too. Adding a version number that changes with every build (?v=1843) to an image URL means a new image for Google on every deploy. The right way to bust a cache is to use a hash of the file content: as long as the file doesn't change, the URL doesn't either.

srcset and picture, Together with src

The main cover on the book page comes in three formats and three widths. The browser picks the first <source> format it supports; for a client that recognizes none of them, the src of the <img> is the fallback:

<picture>
    <source type="image/avif" srcset="@CoverImages.SrcSet(book, "avif", img)" sizes="@Sizes">
    <source type="image/webp" srcset="@CoverImages.SrcSet(book, "webp", img)" sizes="@Sizes">
    <img src="@CoverImages.Url(book, 640, "jpg", img)" srcset="@CoverImages.SrcSet(book, "jpg", img)" sizes="@Sizes"
         alt="@CoverImages.Alt(book, Model.Lang)" width="320" height="480" fetchpriority="high">
</picture>
<img src="/images/divider.svg" alt="" width="240" height="12">

The main cover is above the fold, so it doesn't get loading="lazy"; the small covers under "Related books" do, but their src is always filled in. I started the app with production settings and looked at the page:

$ curl -s http://127.0.0.1:52213/books/dune | grep -E 'og:image|robots|<img' | head -5
    <meta name="robots" content="max-image-preview:large">
        <meta property="og:image" content="https://cdn.bookstore.example/images/covers/dune-frank-herbert-1200.jpg">
        <img src="https://cdn.bookstore.example/images/covers/dune-frank-herbert-640.jpg" srcset="https://cdn.bookstore.example/images/covers/dune-frank-herbert-320.jpg 320w, https://cdn.bookstore.example/images/covers/dune-frank-herbert-640.jpg 640w, https://cdn.bookstore.example/images/covers/dune-frank-herbert-1200.jpg 1200w" sizes="(max-width: 600px) 50vw, 320px"
    <img src="/images/divider.svg" alt="" width="240" height="12">
                    <img src="https://cdn.bookstore.example/images/covers/dune-messiah-frank-herbert-320.jpg" alt="Dune Messiah, Frank Herbert, karton kapaklı baskı kapağı"

On the first try the alt texts came out as bask&#x131; kapa&#x11F;&#x131;. ASP.NET Core's default HTML encoder turns non-Latin characters into entities. Browsers and Google read that correctly, but it's hard on anyone reading the source. Passing UnicodeRanges.All to WebEncoderOptions leaves the text as it is. Format choice, resizing and the performance side of sizes are covered in the "Image and Font Optimization" post of the Web Performance series.

Test: Does Every img Have a Real src?

Any of these rules can break silently with a page change. In Bookstore the book page is requested with WebApplicationFactory and parsed with AngleSharp:

[Fact]
public async Task Every_img_has_a_real_src_and_an_alt_attribute()
{
    IDocument doc = await GetAsync("/books/dune");
    foreach (IElement img in doc.QuerySelectorAll("img"))
    {
        Assert.False(string.IsNullOrWhiteSpace(img.GetAttribute("src")), img.OuterHtml);
        Assert.True(img.HasAttribute("alt"), img.OuterHtml); // a decorative image has alt="", but the attribute is there
        Assert.NotNull(img.GetAttribute("width"));
        Assert.NotNull(img.GetAttribute("height"));
    }
}

I put the data-src pattern from the opening back into the related books list and ran the test:

$ dotnet test --filter "FullyQualifiedName~Every_img"
...
  Failed Bookstore.Web.Tests.BookPageImageTests.Every_img_has_a_real_src_and_an_alt_attribute [226 ms]
  Error Message:
   <img data-src="/images/covers/dune-messiah-frank-herbert-320.jpg" class="lazy" alt="Dune Messiah, Frank Herbert, karton kapaklı baskı kapağı" width="160" height="240">
...
Failed!  - Failed:     1, Passed:     0, Skipped:     0, Total:     1, Duration: 232 ms - Bookstore.Web.Tests.dll (net10.0)

The failure message points straight at the offending element. The same test file also checks that the main cover isn't lazy, the alt text in both languages, and that every cover file the code produces actually exists in wwwroot; the last one catches broken image URLs before deploy when the file name rule changes.

Image Sitemaps: Adding image:loc to the Sitemap

An image sitemap is the way to tell Google about images it might not find on its own, for example ones loaded with JavaScript. It can be a separate file or part of an existing sitemap. The format is plain: the http://www.google.com/schemas/sitemap-image/1.1 namespace, one <image:image> per image and an <image:loc> inside it. A <url> can carry up to 1,000 images. In May 2022 <image:caption>, <image:title>, <image:geo_location> and <image:license> were removed from the documentation; today there are just two valid tags.

The streaming sitemap writer from the fourth post of the series gets two additions: the namespace on the root element and the cover inside every <url>:

await xml.WriteAttributeStringAsync("xmlns", "image", null, ImageNs);

// image sitemap: only image:image and image:loc; caption, title, license were removed in 2022
private static async Task WriteImageAsync(XmlWriter xml, string imageUrl)
{
    await xml.WriteStartElementAsync("image", "image", ImageNs);
    await xml.WriteElementStringAsync("image", "loc", ImageNs, imageUrl);
    await xml.WriteEndElementAsync();
}
$ curl -s http://127.0.0.1:52213/sitemaps/books.xml | xmllint --format - | head -14
<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://bookstore.example/books/dune</loc>
    <lastmod>2026-11-01</lastmod>
    <xhtml:link rel="alternate" hreflang="tr" href="https://bookstore.example/books/dune"/>
    <xhtml:link rel="alternate" hreflang="en" href="https://bookstore.example/en/books/dune"/>
    <xhtml:link rel="alternate" hreflang="x-default" href="https://bookstore.example/books/dune"/>
    <image:image>
      <image:loc>https://cdn.bookstore.example/images/covers/dune-frank-herbert-640.jpg</image:loc>
    </image:image>
  </url>
  <url>
    <loc>https://bookstore.example/en/books/dune</loc>

Each book's Turkish and English pages are separate <url> entries in the sitemap, and both point to the same cover URL (38 books, 76 <image:loc> entries). There's no need to list every width from srcset: the goal is to get the image discovered, not to enumerate its variants. The tests pin this down: one image per URL, on the CDN, and no image tag other than image and loc in the file.

CDN Domains and Search Console

The covers live on cdn.bookstore.example, the pages on bookstore.example. Unlike regular sitemaps, an image sitemap may list images on another domain. Google's guides ask for two things here: verify the CDN domain in Search Console as well, so you can be told about crawl errors on that host; and don't block the images you want indexed with robots.txt.

The second point hides an easily missed detail. robots.txt applies per host (the second post of the series). For an image at cdn.bookstore.example/images/... the file that counts is cdn.bookstore.example/robots.txt, not bookstore.example/robots.txt. What the CDN returns at that address depends on its setup: a CDN that pulls files from the origin may forward the robots.txt request too and serve the origin's file as it is. In that case an old line on the origin that blocks /images/ closes the images on the CDN to Google as well. Check it through the CDN property in Search Console or directly with curl https://cdn.bookstore.example/robots.txt.

This section is based on the official documentation.

The Preferred Image: primaryImageOfPage, og:image and max-image-preview

When a page has several images, Google decides on its own which one to show as the preview, but you can state your preference. According to the section added to the guide in March 2026, Google uses both schema.org markup and the og:image meta tag as sources: primaryImageOfPage on a WebPage, image attached to the page's main entity (mainEntity), or og:image. The guide recommends avoiding generic images such as a logo, text-heavy images, extreme aspect ratios and low resolutions.

In Bookstore all three signals point to the same file, the 1200-pixel-wide version of the cover:

// preferred image: og:image and primaryImageOfPage are the same large cover
string preferredImage = CoverImages.Url(book, 1200, "jpg", _site.AbsoluteImageBase);

Dictionary<string, object> jsonLd = new()
{
    ["@context"] = "https://schema.org",
    ["@type"] = "ItemPage",
    ["@id"] = canonical,
    ["url"] = canonical,
    ["primaryImageOfPage"] = preferredImage,
    ["mainEntity"] = new Dictionary<string, object>
    {
        ["@type"] = "Book",
        ["name"] = book.Title,
        ["author"] = new Dictionary<string, object> { ["@type"] = "Person", ["name"] = book.Author },
        ["isbn"] = book.Isbn,
        ["image"] = preferredImage,
    },
};

The 1200 comes from the Discover documentation: it says large images are more likely to bring visits from Discover and recommends at least 1200 pixels of width, more than 300,000 pixels in total and a 16:9 ratio; the condition for the large preview is the max-image-preview:large setting. According to the robots meta tag documentation, that setting says the image preview may be shown as large as the width of the viewport; standard is the default size and none means no preview. Without the setting, Google may use the default size.

A book cover is a compromise here: it's 2:3 and has text on it, so it isn't the ideal image the Discover documentation describes. But it's the image that best represents the page, and Google itself tries to crop images automatically. For text-heavy pages such as Bookstore's blog posts, generating a 16:9 cover image is the better choice. One of the tests verifies that og:image and primaryImageOfPage are the same file and that the robots meta contains max-image-preview:large; if the two signals diverge, the build breaks.

Angular: The Same Rules with NgOptimizedImage

In Bookstore's Angular client (bookstore-web) the covers are rendered with NgOptimizedImage. The alt text rule is the same in TypeScript; file names and widths live in an image loader:

@Component({
  selector: 'app-book-cover',
  imports: [NgOptimizedImage],
  template: `<img [ngSrc]="book().coverStem" [alt]="alt()" [width]="width()" [height]="width() * 1.5" [priority]="priority()" />`,
})
export class BookCover {
  readonly book = input.required<Book>();
  readonly width = input(320);
  // the main cover above the fold: not lazy, fetchpriority=high, preloaded during SSR
  readonly priority = input(false);
  private readonly locale = inject(LOCALE_ID);
  protected readonly alt = computed(() => coverAlt(this.book(), this.locale));
}
{
  provide: IMAGE_LOADER,
  useValue: (config: ImageLoaderConfig) => {
    // the closest file at least as wide as requested
    const width = COVER_WIDTHS.find((w) => w >= (config.width ?? 640)) ?? 1200;
    return `https://cdn.bookstore.example/images/covers/${config.src}-${width}.jpg`;
  },
},

ng build prerendered the book pages; here are the preload link and two images from the HTML generated for Dune:

$ grep -o '<link as="image"[^>]*>' dist/bookstore-web/browser/books/dune/index.html
<link as="image" href="https://cdn.bookstore.example/images/covers/dune-frank-herbert-640.jpg" rel="preload" fetchpriority="high" imagesrcset="https://cdn.bookstore.example/images/covers/dune-frank-herbert-320.jpg 1x, https://cdn.bookstore.example/images/covers/dune-frank-herbert-640.jpg 2x">
$ grep -o '<img[^>]*>' dist/bookstore-web/browser/books/dune/index.html | head -2
<img alt="Cover of Dune by Frank Herbert, hardcover edition" width="320" height="480" loading="eager" fetchpriority="high" decoding="sync" ng-img="true" src="https://cdn.bookstore.example/images/covers/dune-frank-herbert-640.jpg" srcset="https://cdn.bookstore.example/images/covers/dune-frank-herbert-320.jpg 1x, https://cdn.bookstore.example/images/covers/dune-frank-herbert-640.jpg 2x">
<img alt="Cover of Foundation by Isaac Asimov, ebook edition" width="160" height="240" loading="lazy" fetchpriority="auto" decoding="auto" ng-img="true" src="https://cdn.bookstore.example/images/covers/foundation-isaac-asimov-640.jpg" srcset="https://cdn.bookstore.example/images/covers/foundation-isaac-asimov-320.jpg 1x, https://cdn.bookstore.example/images/covers/foundation-isaac-asimov-320.jpg 2x">

What matters for SEO: the directive fills in src in the initial HTML and uses the browser's own loading="lazy" for lazy loading; there's no intermediate step like data-src. The app's locale is en-US, so the alt text is English. One more detail shows up in the output: the 1x and 2x candidates of the 160-pixel cover resolve to the same file, because the loader rounds the request up to the nearest larger file it has, and there's no file smaller than 320. It's harmless, but it shows that ngSrcset has to stay in line with the file widths you actually have.

In Production

Manage image URLs as a data rule. If the file name, the list of widths and the CDN root live in one place (CoverImages in Bookstore), the page, the sitemap, the JSON-LD and the Angular client produce the same URL. Four different URLs for the same image are four separate images to Google.

Change the URL when you change the image, and leave it alone when you don't. If a cover is replaced, the new file gets a new content hash and the URL changes; as long as the cover stays the same, so does the URL. URLs that change with every deploy make Google discover the same image over and over as if it were new.

If you don't want your images shown full size in Google Images, the guide describes a way: when the request's referrer is a Google domain, return 200 or an empty 204. Google still crawls the page and may show the small preview taken at crawl time; the guide states that this isn't considered cloaking. Keeping an image out of the index entirely is a different matter: noimageindex, or X-Robots-Tag: noindex for the image file (the third post of the series).

Common Pitfalls

Serving a content image as a CSS background-image. Google doesn't index CSS images.

Lazy loading with data-src and an empty src. The browser's own loading="lazy" is simpler and safe for Google.

Writing long alt text for a decorative image, or turning the alt of a content image into a keyword list.

Adding a version parameter that changes on every deploy to image URLs.

Using the site logo as og:image. The guide recommends against generic images as the preferred image.

Not verifying the CDN domain in Search Console and never checking the CDN's own robots.txt.

A Small Experiment

★☆☆ Fetch the initial HTML of a page on your own site with curl and count the <img lines. Is there an image with an empty src, without an alt attribute, or a content image set with CSS?

★★☆ Add a test to BookPageImageTests: for every <picture> on the page, check that each URL in the srcset of its <source> elements maps to a file in wwwroot. Then add 1600 to CoverImages.Widths and see what the test catches.

★★★ Write an endpoint that generates a 16:9, 1200-pixel-wide share image for Bookstore's blog posts (title and cover together). Keep the image URL the same as long as the post content doesn't change; point og:image and primaryImageOfPage to it and have the tests verify that the two match.

The setup we built for images (one stable URL, and the page, the sitemap and the structured data saying the same thing) matters even more for video, because a video has three URLs at once: its page, its player and its file. In the next post we'll build the watch page concept, the video sitemap, key moments with Clip and SeekToAction, and the thumbnail rules on Bookstore's video pages.