Skip to main content
ASoc
Tutorial

React Images: 26 `<img>` Tags and Not One `import`

1,160 image files, zero imported ones. The import-vs-string-path choice is really a choice about who verifies the path — and what you have to build once the bundler stops.

The ASoc Team9 min read

There are two ways to get a picture into a React component: import cover from "./cover.jpg" and let the bundler hash it into your build, or write <img src="/cover.jpg" /> and let a static file server find it. This storefront renders 1,160 image files across 500+ pages and uses the second one exclusively — 26 <img> tags in the app tree, and not a single import of an image anywhere.

The census

$ grep -rn "import .* from '.*\.\(png\|jpg\|webp\|svg\)'" src/ scripts/ | wc -l
0
$ grep -rho '<img' src/components src/app src/data | wc -l
26
$ find public -type f \( -name '*.webp' -o -name '*.jpg' -o -name '*.png' -o -name '*.svg' \) | wc -l
1160

Twenty-six <img> tags in the application tree — 18 in components, 2 in src/app, 6 in data files that embed markup. Zero imported image files. Zero next/image, too: the only occurrence of that string outside blog prose is src/proxy.ts:40, where _next/image appears in a matcher excluding the built-in optimizer from the middleware.

1,160 image files sit under public/ (684 WebP, 446 JPEG, 13 PNG, 17 SVG — 95 MB), and every one of them is reached by a string.

The two models, and what actually differs

import cover from "./cover.jpg"<img src="/cover.jpg" />
Who resolves the pathThe bundler, at build timeThe browser, at request time
A typo producesA build errorA 404 in production
Filename in the outputContent-hashed (cover.a91f3c.jpg)Unchanged
Cache bustingFree — the hash changesYours to arrange
Can the path be computed?No — it must be a literalYes
Lives insrc/, next to the componentpublic/, outside the module graph
Bundler must walk the fileYesNever

The row that decides it for a catalog is the computed-path one. The row that costs you is the typo one — and the two are the same trade seen from either end.

Why a computed path was non-negotiable here

Nothing in this codebase renders a product's cover image directly. TemplateCard renders this:

<img
  alt={`${product.name} — ${product.seoLabel}`}
  className="absolute inset-0 h-full w-full object-cover"
  decoding="async"
  fetchPriority={priority ? "high" : undefined}
  height={330}
  loading={priority ? "eager" : "lazy"}
  src={cardImage(product.screenshots[0])}
  width={530}
/>

cardImage() lives in src/lib/imageVariants.ts and is four lines of string surgery:

const RASTER = /\.(?:jpe?g|png|webp)$/i;

function variant(src: string, suffix: string): string {
  return RASTER.test(src) ? src.replace(RASTER, `-${suffix}.webp`) : src;
}

export function cardImage(src: string): string {
  return variant(src, "card");
}

screenshots[0] is the SEO copy — it is what og:image and the Product schema's image resolve to, so it stays a full-size JPEG. What a card paints is a pre-built derivative sitting next to it. For one real product:

$ ls -l public/images/templates/asoc-blueprint/
136457  cover.jpg        # og:image, Product schema
 28200  cover-card.webp  # 1060w — what every card renders
 36128  cover-view.webp  # 1600w — the detail-page frame

136,457 bytes down to 28,200 for the picture a visitor actually sees, and 110 products carry the pair. That substitution is one .replace() on a string. With import, it is not expressible at all: the specifier has to be a literal the bundler can see, so a per-product derivative would mean 110 hand-written import statements, or a build step that generates them.

The cost of that flexibility arrives exactly where the table says it does. cardImage() will happily hand you a path to a file that does not exist, and React will happily render it.

So you buy the build error back

If the bundler no longer verifies the path, something else has to. Two tests in src/data/__tests__/catalog.test.ts do it:

it("every image the catalog references exists in public/", () => {
  for (const p of catalog) {
    for (const s of [...p.screenshots, ...(p.gallery ?? [])])
      expect(
        existsSync(join(process.cwd(), "public", s)),
        `${p.slug} missing file: ${s}`,
      ).toBe(true);
  }
});

it("every cover has the WebP variants the components render", () => {
  for (const p of catalog) {
    const cover = p.screenshots[0];
    if (!hasVariants(cover)) continue; // the placeholder SVG has none
    for (const v of [cardImage(cover), viewImage(cover)])
      expect(
        existsSync(join(process.cwd(), "public", v)),
        `${p.slug} missing variant: ${v} — run scripts/images/build-variants.ts`,
      ).toBe(true);
  }
});

The first replaces what import gave away. The second covers a failure import never had to think about: the path in the data file is correct, the file it names exists, and the file the component asks for — the -card.webp derivative — was never generated. Onboard a product without running scripts/images/build-variants.ts and every card ships a broken image while the catalog looks perfectly healthy.

That is the honest summary of the trade. You do not avoid the work by choosing strings; you relocate it from the bundler into a test you have to write, and you get a better error message than the bundler would have given you (asoc-blueprint missing variant: … — run scripts/images/build-variants.ts).

The one image here that is not a URL

There is exactly one place in this codebase where an image is loaded as bytes rather than referenced by path, and it is the one place React is not rendering to a browser. src/app/opengraph-image.tsx builds the social card through Satori:

// Satori accepts an ArrayBuffer for <img src> (per the Next.js docs); the DOM
// types only allow a string, hence the cast.
const heroSrc = Uint8Array.from(heroBanner).buffer as unknown as string;

<img src={heroSrc} width={620} height={404} alt="" />

<img> there is not an HTML element. It is a JSX tag a rendering library interprets to draw a PNG, and it accepts a buffer that no browser would. The as unknown as string cast is the seam showing: React's DOM types describe the DOM, and this renderer is not one. Worth knowing before you assume every <img> in a React tree obeys the same rules — the tag is a convention shared by several renderers, not a guarantee about the target.

Attributes that are not optional

Whichever model you pick, the <img> still has to be a good one. What the card above sets, and why:

  • width and height on every image — 81 width= and 80 height= attributes across the tree. These reserve the box before the bytes arrive; omit them and the page reflows when each image lands, which is Cumulative Layout Shift.
  • loading — 10 loading="lazy", and exactly one conditional loading={priority ? "eager" : "lazy"}. The first card of a page-opening grid is that page's Largest Contentful Paint element; leaving it lazy cost this site 1.67 s of load delay before the priority prop existed.
  • fetchPriority="high" on that same first card, and on the hero.
  • decoding="async" so decoding does not block the main thread.
  • alt describing the picture's job, not its filename.

The hero adds the one thing a plain <img> does give you for free, which is responsive sources:

<img
  src="/images/hero-desktop.webp"
  srcSet="/images/hero-desktop-800.webp 800w, /images/hero-desktop.webp 1600w"
  sizes="(max-width: 745px) 100vw, 745px"
  width="1600"
  height="806"
  fetchPriority="high"
  loading="eager"
  alt="ASoc analytics dashboard"
/>

The frame is ~330px wide on a phone; shipping the 1600px file there spent 43 KiB of 46 KiB for nothing.

Troubleshooting

SymptomCauseFix
Image renders in dev, 404s in productionPath is a string and nothing verifies it; the dev server happened to have the fileAdd a test that asserts every referenced path exists under public/
import of a .jpg fails to type-checkTypeScript has no declaration for image modulesAdd an *.d.ts declaring declare module "*.jpg", or switch that image to a public path
Page jumps as images loadNo width/height on the <img>Set both to the intrinsic ratio; CSS can still resize it
The first image on the page is slowIt is the LCP element and it is loading="lazy"loading="eager" + fetchPriority="high" on that one image only
Replaced an image, browsers still show the old onePublic files keep their filename, so the URL never changedRename the file, or serve it with a content hash in the path
A computed src returns a path with no file behind itA derivative was never generatedFail the build: assert the computed path, not just the source path

Frequently asked questions

Is <img> or next/image the right default in a Next.js app? next/image is the right default. It is doing real work — resizing, format negotiation, layout reservation — and below roughly a dozen images the request-time optimization costs nothing worth counting. This codebase is on the other side of that line: 1,160 files, right-sized once at build time by a script, served as plain static assets, with no request-time optimizer to pay for.

Where should images live — src/ or public/? In src/ when they belong to one component and change with it; that is what colocation plus import is for. In public/ when they are content — a catalog of covers, avatars, uploads — where the set is open and the paths are data. The question is whether the set of images is closed at build time.

Does require() still work for images in React? In a bundler that supports it, yes — <img src={require('./house.jpg')} /> is the CommonJS spelling of the same import. It has the same properties: literal specifier, build-time resolution, content hash. It is not a way to compute a path at runtime.

How do I cache-bust a file in public/? By changing its name. The URL is the filename, so an edited file with the same name is the same URL, and a browser holding a long-lived cache entry has no reason to re-fetch it. This is the one genuine ergonomic loss versus imported assets, where the content hash does it automatically.

Templates in this post

ASoc Vault (a fintech SaaS landing page), ASoc Vox (an AI voiceover marketing site) and ASoc Weave (an AI website-builder landing page) all ship the markup above — sized <img> tags, WebP under public/, lazy below the fold and eager at the top.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the byte-level version of this decision — why the optimizer was dropped and what the migration measured — see Next.js Image Optimization Without next/image; for where a card's cover sits in a page's load order, What Actually Builds a Landing Page?.

Keep reading

Tutorial9 min read

React Protected Routes: What a Server-Rendered Guard Does Differently

React Router's client-side wrapper isn't the only pattern. This codebase's redirect guard runs on the server, plus the open-redirect check most tutorials skip on the ?next= param.

Read more
Tutorial10 min read

A React Sidebar in 60 Lines and Zero JavaScript

Sticky positioning without a scroll listener, aria-current for the active row, and the hard-coded active-state bug that only appears when you add a second page.

Read more