Skip to content

Commit a85f441

Browse files
authored
feat(next/image): add support for images.qualities in next.config (#74500)
Backports PR #74257 to 14.x
1 parent adfe537 commit a85f441

File tree

24 files changed

+395
-9
lines changed

24 files changed

+395
-9
lines changed

docs/02-app/02-api-reference/01-components/image.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ quality={75} // {number 1-100}
247247

248248
The quality of the optimized image, an integer between `1` and `100`, where `100` is the best quality and therefore largest file size. Defaults to `75`.
249249

250+
If the [`qualities`](#qualities) configuration is defined in `next.config.js`, the `quality` prop must match one of the values defined in the configuration.
251+
252+
> **Good to know**: If the original source image was already low quality, setting the quality prop too high could cause the resulting optimized image to be larger than the original source image.
253+
250254
### `priority`
251255

252256
```js
@@ -672,6 +676,20 @@ module.exports = {
672676
}
673677
```
674678

679+
### `qualities`
680+
681+
The default [Image Optimization API](#loader) will automatically allow all qualities from 1 to 100. If you wish to restrict the allowed qualities, you can add configuration to `next.config.js`.
682+
683+
```js filename="next.config.js"
684+
module.exports = {
685+
images: {
686+
qualities: [25, 50, 75],
687+
},
688+
}
689+
```
690+
691+
In this example above, only three qualities are allowed: 25, 50, and 75. If the [`quality`](#quality) prop does not match a value in this array, the image will fail with 400 Bad Request.
692+
675693
### `formats`
676694

677695
The default [Image Optimization API](#loader) will automatically detect the browser's supported image formats via the request's `Accept` header.
@@ -1050,6 +1068,7 @@ This `next/image` component uses browser native [lazy loading](https://caniuse.c
10501068

10511069
| Version | Changes |
10521070
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1071+
| `v14.2.23` | `qualities` configuration added. |
10531072
| `v14.2.15` | `decoding` prop added and `localPatterns` configuration added. |
10541073
| `v14.2.14` | `remotePatterns.search` prop added. |
10551074
| `v14.2.0` | `overrideSrc` prop added. |

errors/invalid-images-config.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ module.exports = {
4141
localPatterns: [],
4242
// limit of 50 objects
4343
remotePatterns: [],
44+
// limit of 20 integers
45+
qualities: [25, 50, 75],
4446
// when true, every image will be unoptimized
4547
unoptimized: false,
4648
},
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
title: '`next/image` Un-configured qualities'
3+
---
4+
5+
## Why This Error Occurred
6+
7+
One of your pages that leverages the `next/image` component, passed a `quality` value that isn't defined in the `images.qualities` property in `next.config.js`.
8+
9+
## Possible Ways to Fix It
10+
11+
Add an entry to `images.qualities` array in `next.config.js` with the expected value. For example:
12+
13+
```js filename="next.config.js"
14+
module.exports = {
15+
images: {
16+
qualities: [25, 50, 75],
17+
},
18+
}
19+
```
20+
21+
## Useful Links
22+
23+
- [Image Optimization Documentation](/docs/pages/building-your-application/optimizing/images)
24+
- [Qualities Config Documentation](/docs/pages/api-reference/components/image#qualities)

packages/next/src/build/webpack/plugins/define-env-plugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,14 @@ function getImageConfig(
108108
'process.env.__NEXT_IMAGE_OPTS': {
109109
deviceSizes: config.images.deviceSizes,
110110
imageSizes: config.images.imageSizes,
111+
qualities: config.images.qualities,
111112
path: config.images.path,
112113
loader: config.images.loader,
113114
dangerouslyAllowSVG: config.images.dangerouslyAllowSVG,
114115
unoptimized: config?.images?.unoptimized,
115116
...(dev
116117
? {
117-
// pass domains in development to allow validating on the client
118+
// additional config in dev to allow validating on the client
118119
domains: config.images.domains,
119120
remotePatterns: config.images?.remotePatterns,
120121
localPatterns: config.images?.localPatterns,

packages/next/src/client/image-component.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,8 @@ export const Image = forwardRef<HTMLImageElement | null, ImageProps>(
374374
const c = configEnv || configContext || imageConfigDefault
375375
const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
376376
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
377-
return { ...c, allSizes, deviceSizes }
377+
const qualities = c.qualities?.sort((a, b) => a - b)
378+
return { ...c, allSizes, deviceSizes, qualities }
378379
}, [configContext])
379380

380381
const { onLoad, onLoadingComplete } = props

packages/next/src/client/legacy/image.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { normalizePathTrailingSlash } from '../normalize-trailing-slash'
2525
function normalizeSrc(src: string): string {
2626
return src[0] === '/' ? src.slice(1) : src
2727
}
28-
28+
const DEFAULT_Q = 75
2929
const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete
3030
const loadedImageURLs = new Set<string>()
3131
const allImgs = new Map<
@@ -186,8 +186,22 @@ function defaultLoader({
186186
}
187187
}
188188
}
189+
190+
if (quality && config.qualities && !config.qualities.includes(quality)) {
191+
throw new Error(
192+
`Invalid quality prop (${quality}) on \`next/image\` does not match \`images.qualities\` configured in your \`next.config.js\`\n` +
193+
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-qualities`
194+
)
195+
}
189196
}
190197

198+
const q =
199+
quality ||
200+
config.qualities?.reduce((prev, cur) =>
201+
Math.abs(cur - DEFAULT_Q) < Math.abs(prev - DEFAULT_Q) ? cur : prev
202+
) ||
203+
DEFAULT_Q
204+
191205
if (src.endsWith('.svg') && !config.dangerouslyAllowSVG) {
192206
// Special case to make svg serve as-is to avoid proxying
193207
// through the built-in Image Optimization API.
@@ -196,7 +210,7 @@ function defaultLoader({
196210

197211
return `${normalizePathTrailingSlash(config.path)}?url=${encodeURIComponent(
198212
src
199-
)}&w=${width}&q=${quality || 75}`
213+
)}&w=${width}&q=${q}`
200214
}
201215

202216
const loaders = new Map<
@@ -637,7 +651,8 @@ export default function Image({
637651
const c = configEnv || configContext || imageConfigDefault
638652
const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
639653
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
640-
return { ...c, allSizes, deviceSizes }
654+
const qualities = c.qualities?.sort((a, b) => a - b)
655+
return { ...c, allSizes, deviceSizes, qualities }
641656
}, [configContext])
642657

643658
let rest: Partial<ImageProps> = all

packages/next/src/server/config-schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,11 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
505505
loaderFile: z.string().optional(),
506506
minimumCacheTTL: z.number().int().gte(0).optional(),
507507
path: z.string().optional(),
508+
qualities: z
509+
.array(z.number().int().gte(1).lte(100))
510+
.min(1)
511+
.max(20)
512+
.optional(),
508513
})
509514
.optional(),
510515
logging: z

packages/next/src/server/image-optimizer.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export class ImageOptimizerCache {
175175
} = imageData
176176
const remotePatterns = nextConfig.images?.remotePatterns || []
177177
const localPatterns = nextConfig.images?.localPatterns
178+
const qualities = nextConfig.images?.qualities
178179
const { url, w, q } = query
179180
let href: string
180181

@@ -281,6 +282,18 @@ export class ImageOptimizerCache {
281282
}
282283
}
283284

285+
if (qualities) {
286+
if (isDev) {
287+
qualities.push(BLUR_QUALITY)
288+
}
289+
290+
if (!qualities.includes(quality)) {
291+
return {
292+
errorMessage: `"q" parameter (quality) of ${q} is not allowed`,
293+
}
294+
}
295+
}
296+
284297
const mimeType = getSupportedMimeType(formats || [], req.headers['accept'])
285298

286299
const isStatic = url.startsWith(

packages/next/src/shared/lib/get-img-props.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,8 @@ export function getImgProps(
283283
} else {
284284
const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
285285
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
286-
config = { ...c, allSizes, deviceSizes }
286+
const qualities = c.qualities?.sort((a, b) => a - b)
287+
config = { ...c, allSizes, deviceSizes, qualities }
287288
}
288289

289290
if (typeof defaultLoader === 'undefined') {

packages/next/src/shared/lib/image-config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ export type ImageConfigComplete = {
118118
/** @see [Remote Patterns](https://nextjs.org/docs/api-reference/next/image#localPatterns) */
119119
localPatterns: LocalPattern[] | undefined
120120

121+
/** @see [Qualities](https://nextjs.org/docs/api-reference/next/image#qualities) */
122+
qualities: number[] | undefined
123+
121124
/** @see [Unoptimized](https://nextjs.org/docs/api-reference/next/image#unoptimized) */
122125
unoptimized: boolean
123126
}
@@ -139,5 +142,6 @@ export const imageConfigDefault: ImageConfigComplete = {
139142
contentDispositionType: 'inline',
140143
localPatterns: undefined, // default: allow all local images
141144
remotePatterns: [], // default: allow no remote images
145+
qualities: undefined, // default: allow all qualities
142146
unoptimized: false,
143147
}

packages/next/src/shared/lib/image-loader.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ImageLoaderPropsWithConfig } from './image-config'
22

3+
const DEFAULT_Q = 75
4+
35
function defaultLoader({
46
config,
57
src,
@@ -72,11 +74,23 @@ function defaultLoader({
7274
}
7375
}
7476
}
77+
78+
if (quality && config.qualities && !config.qualities.includes(quality)) {
79+
throw new Error(
80+
`Invalid quality prop (${quality}) on \`next/image\` does not match \`images.qualities\` configured in your \`next.config.js\`\n` +
81+
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-qualities`
82+
)
83+
}
7584
}
7685

77-
return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${
78-
quality || 75
79-
}${
86+
const q =
87+
quality ||
88+
config.qualities?.reduce((prev, cur) =>
89+
Math.abs(cur - DEFAULT_Q) < Math.abs(prev - DEFAULT_Q) ? cur : prev
90+
) ||
91+
DEFAULT_Q
92+
93+
return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${q}${
8094
process.env.NEXT_DEPLOYMENT_ID
8195
? `&dpl=${process.env.NEXT_DEPLOYMENT_ID}`
8296
: ''

packages/next/src/telemetry/events/version.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type EventCliSessionStarted = {
2424
imageDomainsCount: number | null
2525
imageRemotePatternsCount: number | null
2626
imageLocalPatternsCount: number | null
27+
imageQualities: string | null
2728
imageSizes: string | null
2829
imageLoader: string | null
2930
imageFormats: string | null
@@ -77,6 +78,7 @@ export function eventCliSession(
7778
| 'imageDomainsCount'
7879
| 'imageRemotePatternsCount'
7980
| 'imageLocalPatternsCount'
81+
| 'imageQualities'
8082
| 'imageSizes'
8183
| 'imageLoader'
8284
| 'imageFormats'
@@ -120,6 +122,7 @@ export function eventCliSession(
120122
? images.localPatterns.length
121123
: null,
122124
imageSizes: images?.imageSizes ? images.imageSizes.join(',') : null,
125+
imageQualities: images?.qualities ? images.qualities.join(',') : null,
123126
imageLoader: images?.loader,
124127
imageFormats: images?.formats ? images.formats.join(',') : null,
125128
nextConfigOutput: nextConfig?.output || null,

test/integration/image-optimizer/test/index.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,84 @@ describe('Image Optimizer', () => {
258258
)
259259
})
260260

261+
it('should error when qualities length exceeds 20', async () => {
262+
await nextConfig.replace(
263+
'{ /* replaceme */ }',
264+
JSON.stringify({
265+
images: {
266+
qualities: [
267+
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
268+
20, 21,
269+
],
270+
},
271+
})
272+
)
273+
let stderr = ''
274+
275+
app = await launchApp(appDir, await findPort(), {
276+
onStderr(msg) {
277+
stderr += msg || ''
278+
},
279+
})
280+
await waitFor(1000)
281+
await killApp(app).catch(() => {})
282+
await nextConfig.restore()
283+
284+
expect(stderr).toContain(
285+
`Array must contain at most 20 element(s) at "images.qualities"`
286+
)
287+
})
288+
289+
it('should error when qualities array has a value thats not an integer', async () => {
290+
await nextConfig.replace(
291+
'{ /* replaceme */ }',
292+
JSON.stringify({
293+
images: {
294+
qualities: [1, 2, 3, 9.9],
295+
},
296+
})
297+
)
298+
let stderr = ''
299+
300+
app = await launchApp(appDir, await findPort(), {
301+
onStderr(msg) {
302+
stderr += msg || ''
303+
},
304+
})
305+
await waitFor(1000)
306+
await killApp(app).catch(() => {})
307+
await nextConfig.restore()
308+
309+
expect(stderr).toContain(
310+
`Expected integer, received float at "images.qualities[3]"`
311+
)
312+
})
313+
314+
it('should error when qualities array is empty', async () => {
315+
await nextConfig.replace(
316+
'{ /* replaceme */ }',
317+
JSON.stringify({
318+
images: {
319+
qualities: [],
320+
},
321+
})
322+
)
323+
let stderr = ''
324+
325+
app = await launchApp(appDir, await findPort(), {
326+
onStderr(msg) {
327+
stderr += msg || ''
328+
},
329+
})
330+
await waitFor(1000)
331+
await killApp(app).catch(() => {})
332+
await nextConfig.restore()
333+
334+
expect(stderr).toContain(
335+
`Array must contain at least 1 element(s) at "images.qualities"`
336+
)
337+
})
338+
261339
it('should error when loader contains invalid value', async () => {
262340
await nextConfig.replace(
263341
'{ /* replaceme */ }',

test/integration/next-image-new/app-dir-localpatterns/test/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ function runTests(mode: 'dev' | 'server') {
9898
],
9999
minimumCacheTTL: 60,
100100
path: '/_next/image',
101+
qualities: undefined,
101102
sizes: [
102103
640, 750, 828, 1080, 1200, 1920, 2048, 3840, 16, 32, 48, 64, 96,
103104
128, 256, 384,
Loading
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Image from 'next/image'
2+
3+
import src from '../images/test.png'
4+
5+
const Page = () => {
6+
return (
7+
<main>
8+
<Image alt="q-100" id="q-100" quality={100} src={src} />
9+
</main>
10+
)
11+
}
12+
13+
export default Page
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const metadata = {
2+
title: 'Next.js',
3+
description: 'Generated by Next.js',
4+
}
5+
6+
export default function RootLayout({ children }) {
7+
return (
8+
<html lang="en">
9+
<body>{children}</body>
10+
</html>
11+
)
12+
}

0 commit comments

Comments
 (0)