cloudflare / cloudflare/vinext
next/image ignores images.loaderFile for remote src (renders unoptimized); per-component loader prop yields width=0
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Summary
Under vinext, `next/image` renders **remote** images (an absolute `https://…` `src`)
**unoptimized** — the original full-size file, no resizing, no `srcSet`. The two
mechanisms Next provides to customize image optimization both fail:
1. **`next.config` `images.loaderFile` is ignored** — the custom loader is never
invoked, so `` emits the raw URL.
2. **A per-component `loader` prop** *is* invoked, but `next/image`'s responsive
width computation is **non-deterministic** — the loader is repeatedly called with
`width=0` (so the generated URL is `…/width=0/…`, no `srcSet`), and only
occasionally with the correct device widths. Same source, different result across
builds.
> **Bug 1 is reproduced deterministically by this project** (`pnpm install && pnpm build
> && pnpm preview`, then `curl localhost:8798`): the remote image renders
> `` — the `/_MYCDN_/`
> custom-loader marker never appears, and there is no `srcSet`. Bug 2 (`width=0` with a
> per-`` `loader` prop) is intermittent and reproduces in a larger app; see below.
Local (static-import) images optimize correctly via `imagesOptimizer()` →
`/_next/image`. Only **remote** images are affected. `imagesOptimizer()` itself also
rejects remote URLs (`/_next/image?url=https://…` → `400 Bad Request`) and never
fetches them, so there is no working path to optimize remote images under vinext.
The equivalent `next build` (webpack) output honors `loaderFile` and produces the
custom-loader `srcSet` correctly, so this is vinext-specific.
## Environment
| package | version |
|---|---|
| `vinext` | `1.0.0-beta.3` |
| `@vinext/cloudflare` | `1.0.0-beta.3` |
| `@cloudflare/vite-plugin` | `1.42.0` |
| `@vitejs/plugin-rsc` | `0.5.28` |
| `vite` | `8.1.5` |
| `next` | `16.2.x` |
| `react` / `react-dom` | `19.2.x` |
## Reproduction
A minimal, self-contained repro — all files are inline below (a tarball is also
available on request). Install, build the production bundle, then inspect the
rendered HTML for the remote ``:
```bash
pnpm install
pnpm build # VINEXT_BUILD=1 vite build (see package.json)
pnpm preview # serve the built worker, or use `pnpm dev` (vinext dev)
# then request the page and grep the remote image tag:
curl -s http://localhost:8787/ | grep -oE ']*picsum[^>]*>'
```
### Custom loader (`image-loader.ts`)
```ts
import type { ImageLoaderProps } from 'next/image';
// Marker prefix `/_MYCDN_/` makes it obvious in the output whether the loader ran.
export default function myLoader({ src, width, quality }: ImageLoaderProps) {
return `/_MYCDN_/width=${width},quality=${quality ?? 75}/${src.replace(/^\//, '')}`;
}
```
### `next.config.ts`
```ts
const nextConfig = {
images: {
loader: 'custom',
loaderFile: './image-loader.ts',
remotePatterns: [{ protocol: 'https', hostname: 'picsum.photos' }],
},
};
export default nextConfig;
```
### Page (`src/app/page.tsx`)
```tsx
import Image from 'next/image';
export default function Home() {
return (
{/* remote src */}
);
}
```
## Expected
The remote `` uses the custom loader, e.g.:
```html
```
(this is exactly what `next build` produces for the same config).
## Actual
**Bug 1 — `loaderFile` ignored:** the loader marker `/_MYCDN_/` never appears; the
remote image is raw:
```html
```
**Bug 2 — with a per-component `loader={myLoader}` prop:** the loader runs but with
`width=0`, and no `srcSet`:
```html
```
This is **non-deterministic** — some builds of identical source produce the correct
`640…3840w` `srcSet`, others produce `width=0` on every image. (Verified it is not an
ISR/data-cache artifact: the data cache was empty and each response was a fresh
render.)
## Evidence / likely cause
- `grep -rl loaderFile /dist` → **0 files**: vinext never reads
`images.loaderFile`. `dist/config/next-config.js` has no `loaderFile` /
`images.loader` / `deviceSizes` / `imageSizes` handling.
- `next/image` picks its loader in `next/dist/shared/lib/get-img-props.js`:
`let loader = rest.loader || defaultLoader`, where `defaultLoader` is
`next/dist/shared/lib/image-loader`'s default export (imported by
`next/dist/client/image-component.js` as `defaultLoader: _imageloader.default`).
Next's webpack build swaps that module for the `loaderFile`; vinext does not, so
the built-in `/_next/image` loader stays the default — but for a remote `src` the
built-in path is not usable, so the URL is emitted raw.
- The `width=0` with a custom `loader` prop points to vinext not consistently
injecting the `next/image` device/image-sizes config into the bundle, so the width
list `next/image` iterates degenerates to `[0]`.
- `imagesOptimizer()` (@vinext/cloudflare) only serves local assets; a
`/_next/image?url=https://…` request returns `400 Bad Request`. `VinextImageConfig`
exposes only `{ optimizer }` — no remote-allowlist or global client-loader option.
## Impact
Any app that renders CMS/remote images through `next/image` (blogs, docs, avatars)
ships full-resolution originals under vinext (e.g. a 1.29 MB PNG vs a 48 KB optimized
JPEG in our case, ~27×). Local/UI images are fine.
## Ask
A way to configure the client-side image loader under vinext — honor
`images.loaderFile`, or a `VinextImageConfig` field for a global loader — and fix the
non-deterministic width computation (`width=0`) when a per-`` `loader` prop is
used.
---
Possibly related (but distinct): #1513 was about the default loader's endpoint
name; this report is specifically that `images.loaderFile` is never applied to
**remote** `src` values, plus the `width=0` non-determinism with a `loader` prop.
Contributor guide
Research direction
Run the listed pnpm install, build, preview, and curl reproduction first. Trace images.loaderFile through dist/config/next-config.js and the loader selection in next/dist/shared/lib/get-img-props.js and next/dist/client/image-component.js, then inspect imagesOptimizer() and VinextImageConfig. Done means remote images use the configured loader with a deterministic responsive width list, while the reproduction no longer emits the raw URL or width=0.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nextjs, typescript, vite
- Domain
- build-system, frontend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100