cloudflare / cloudflare/vinext

next/image ignores images.loaderFile for remote src (renders unoptimized); per-component loader prop yields width=0

Open
#2,699 1 comment 0 reactions 0 assignees View on GitHub
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 */}
remote

);
}
```

## 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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.