Skip to content

Commit bc6acf6

Browse files
authored
feat(next/image): add support for images.qualities in next.config (#74588)
Backport #74257 to 15.1.x
1 parent d253ac5 commit bc6acf6

File tree

24 files changed

+395
-9
lines changed

24 files changed

+395
-9
lines changed

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

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

258258
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`.
259259

260+
If the [`qualities`](#qualities) configuration is defined in `next.config.js`, the `quality` prop must match one of the values defined in the configuration.
261+
262+
> **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.
263+
260264
### `priority`
261265

262266
```js
@@ -681,6 +685,20 @@ module.exports = {
681685
}
682686
```
683687

688+
### `qualities`
689+
690+
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`.
691+
692+
```js filename="next.config.js"
693+
module.exports = {
694+
images: {
695+
qualities: [25, 50, 75],
696+
},
697+
}
698+
```
699+
700+
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.
701+
684702
### `formats`
685703

686704
The default [Image Optimization API](#loader) will automatically detect the browser's supported image formats via the request's `Accept` header in order to determine the best output format.
@@ -1076,6 +1094,7 @@ This `next/image` component uses browser native [lazy loading](https://caniuse.c
10761094
| Version | Changes |
10771095
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
10781096
| `v15.0.0` | `contentDispositionType` configuration default changed to `attachment`. |
1097+
| `v14.2.23` | `qualities` configuration added. |
10791098
| `v14.2.15` | `decoding` prop added and `localPatterns` configuration added. |
10801099
| `v14.2.14` | `remotePatterns.search` prop added. |
10811100
| `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
@@ -109,13 +109,14 @@ function getImageConfig(
109109
'process.env.__NEXT_IMAGE_OPTS': {
110110
deviceSizes: config.images.deviceSizes,
111111
imageSizes: config.images.imageSizes,
112+
qualities: config.images.qualities,
112113
path: config.images.path,
113114
loader: config.images.loader,
114115
dangerouslyAllowSVG: config.images.dangerouslyAllowSVG,
115116
unoptimized: config?.images?.unoptimized,
116117
...(dev
117118
? {
118-
// pass domains in development to allow validating on the client
119+
// additional config in dev to allow validating on the client
119120
domains: config.images.domains,
120121
remotePatterns: config.images?.remotePatterns,
121122
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
@@ -371,7 +371,8 @@ export const Image = forwardRef<HTMLImageElement | null, ImageProps>(
371371
const c = configEnv || configContext || imageConfigDefault
372372
const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
373373
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
374-
return { ...c, allSizes, deviceSizes }
374+
const qualities = c.qualities?.sort((a, b) => a - b)
375+
return { ...c, allSizes, deviceSizes, qualities }
375376
}, [configContext])
376377

377378
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
@@ -29,7 +29,7 @@ function normalizeSrc(src: string): string {
2929
}
3030

3131
const supportsFloat = typeof ReactDOM.preload === 'function'
32-
32+
const DEFAULT_Q = 75
3333
const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete
3434
const loadedImageURLs = new Set<string>()
3535
const allImgs = new Map<
@@ -190,8 +190,22 @@ function defaultLoader({
190190
}
191191
}
192192
}
193+
194+
if (quality && config.qualities && !config.qualities.includes(quality)) {
195+
throw new Error(
196+
`Invalid quality prop (${quality}) on \`next/image\` does not match \`images.qualities\` configured in your \`next.config.js\`\n` +
197+
`See more info: https://nextjs.org/docs/messages/next-image-unconfigured-qualities`
198+
)
199+
}
193200
}
194201

202+
const q =
203+
quality ||
204+
config.qualities?.reduce((prev, cur) =>
205+
Math.abs(cur - DEFAULT_Q) < Math.abs(prev - DEFAULT_Q) ? cur : prev
206+
) ||
207+
DEFAULT_Q
208+
195209
if (!config.dangerouslyAllowSVG && src.split('?', 1)[0].endsWith('.svg')) {
196210
// Special case to make svg serve as-is to avoid proxying
197211
// through the built-in Image Optimization API.
@@ -200,7 +214,7 @@ function defaultLoader({
200214

201215
return `${normalizePathTrailingSlash(config.path)}?url=${encodeURIComponent(
202216
src
203-
)}&w=${width}&q=${quality || 75}`
217+
)}&w=${width}&q=${q}`
204218
}
205219

206220
const loaders = new Map<
@@ -641,7 +655,8 @@ export default function Image({
641655
const c = configEnv || configContext || imageConfigDefault
642656
const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
643657
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
644-
return { ...c, allSizes, deviceSizes }
658+
const qualities = c.qualities?.sort((a, b) => a - b)
659+
return { ...c, allSizes, deviceSizes, qualities }
645660
}, [configContext])
646661

647662
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
@@ -539,6 +539,11 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
539539
loaderFile: z.string().optional(),
540540
minimumCacheTTL: z.number().int().gte(0).optional(),
541541
path: z.string().optional(),
542+
qualities: z
543+
.array(z.number().int().gte(1).lte(100))
544+
.min(1)
545+
.max(20)
546+
.optional(),
542547
})
543548
.optional(),
544549
logging: z

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export class ImageOptimizerCache {
219219
} = imageData
220220
const remotePatterns = nextConfig.images?.remotePatterns || []
221221
const localPatterns = nextConfig.images?.localPatterns
222+
const qualities = nextConfig.images?.qualities
222223
const { url, w, q } = query
223224
let href: string
224225

@@ -334,6 +335,18 @@ export class ImageOptimizerCache {
334335
}
335336
}
336337

338+
if (qualities) {
339+
if (isDev) {
340+
qualities.push(BLUR_QUALITY)
341+
}
342+
343+
if (!qualities.includes(quality)) {
344+
return {
345+
errorMessage: `"q" parameter (quality) of ${q} is not allowed`,
346+
}
347+
}
348+
}
349+
337350
const mimeType = getSupportedMimeType(formats || [], req.headers['accept'])
338351

339352
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
@@ -286,7 +286,8 @@ export function getImgProps(
286286
} else {
287287
const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
288288
const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
289-
config = { ...c, allSizes, deviceSizes }
289+
const qualities = c.qualities?.sort((a, b) => a - b)
290+
config = { ...c, allSizes, deviceSizes, qualities }
290291
}
291292

292293
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: 'attachment',
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
src.startsWith('/_next/static/media/') && 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
@@ -80,6 +81,7 @@ export function eventCliSession(
8081
| 'imageDomainsCount'
8182
| 'imageRemotePatternsCount'
8283
| 'imageLocalPatternsCount'
84+
| 'imageQualities'
8385
| 'imageSizes'
8486
| 'imageLoader'
8587
| 'imageFormats'
@@ -126,6 +128,7 @@ export function eventCliSession(
126128
? images.localPatterns.length
127129
: null,
128130
imageSizes: images?.imageSizes ? images.imageSizes.join(',') : null,
131+
imageQualities: images?.qualities ? images.qualities.join(',') : null,
129132
imageLoader: images?.loader,
130133
imageFormats: images?.formats ? images.formats.join(',') : null,
131134
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)