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.
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 path | The bundler, at build time | The browser, at request time |
| A typo produces | A build error | A 404 in production |
| Filename in the output | Content-hashed (cover.a91f3c.jpg) | Unchanged |
| Cache busting | Free — the hash changes | Yours to arrange |
| Can the path be computed? | No — it must be a literal | Yes |
| Lives in | src/, next to the component | public/, outside the module graph |
| Bundler must walk the file | Yes | Never |
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:
widthandheighton every image — 81width=and 80height=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— 10loading="lazy", and exactly one conditionalloading={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 thepriorityprop existed.fetchPriority="high"on that same first card, and on the hero.decoding="async"so decoding does not block the main thread.altdescribing 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
| Symptom | Cause | Fix |
|---|---|---|
| Image renders in dev, 404s in production | Path is a string and nothing verifies it; the dev server happened to have the file | Add a test that asserts every referenced path exists under public/ |
import of a .jpg fails to type-check | TypeScript has no declaration for image modules | Add an *.d.ts declaring declare module "*.jpg", or switch that image to a public path |
| Page jumps as images load | No width/height on the <img> | Set both to the intrinsic ratio; CSS can still resize it |
| The first image on the page is slow | It 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 one | Public files keep their filename, so the URL never changed | Rename the file, or serve it with a content hash in the path |
A computed src returns a path with no file behind it | A derivative was never generated | Fail 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?.
