diff --git a/.changeset/lemon-pears-burn.md b/.changeset/lemon-pears-burn.md
new file mode 100644
index 000000000..5c2370e50
--- /dev/null
+++ b/.changeset/lemon-pears-burn.md
@@ -0,0 +1,7 @@
+---
+'@cloudflare/next-on-pages': minor
+---
+
+Support for images via `/_next/image`, falling back to the raw image URL when image resizing is not available.
+
+Due to limitations with Cloudflare Pages, it is not currently possible to send requests through image resizing.
diff --git a/packages/next-on-pages/docs/supported.md b/packages/next-on-pages/docs/supported.md
index 05beb842b..02be65361 100644
--- a/packages/next-on-pages/docs/supported.md
+++ b/packages/next-on-pages/docs/supported.md
@@ -49,7 +49,7 @@ If you're application is using a package which relies on unsupported Node.js API
| routes `missing` | ✅ |
| routes `locale` | ✅ |
| routes `middlewarePath` | ✅ |
-| images1 | 🔄 |
+| images1 | ✅ |
| wildcard | ✅ |
| overrides | ✅ |
| cache | ❌ |
@@ -59,7 +59,7 @@ If you're application is using a package which relies on unsupported Node.js API
- 🔄: Not currently supported, but it's probably possible and we may add support in the future
- ❌: Not supported and unlikely to be supported in the future
-- _1_ - **images**: If you want to use `next/image`, you can provide your own [custom loader](https://nextjs.org/docs/api-reference/next/image#loader) and use Cloudflare Image Resizing, as per [Cloudflare's Image Resizing documentation](https://developers.cloudflare.com/images/image-resizing/integration-with-frameworks/#nextjs).
+- _1_ - **images**: If you want to use `next/image`, there are two options; allow the library to take care of incoming requests, or using a custom loader. Requests are intercepted in the router and image resizing is attempted to be used (due to limitations with Pages, it is not currently possible to use image resizing) - if image resizing is not available, it falls back to fetching the normal image URL. Alternatively, you can provide your own [custom loader](https://nextjs.org/docs/api-reference/next/image#loader) and use Cloudflare Image Resizing, as per [Cloudflare's Image Resizing documentation](https://developers.cloudflare.com/images/image-resizing/integration-with-frameworks/#nextjs).
### next.config.js Properties
diff --git a/packages/next-on-pages/templates/_worker.js/index.ts b/packages/next-on-pages/templates/_worker.js/index.ts
index 4996889c7..f3baa3f76 100644
--- a/packages/next-on-pages/templates/_worker.js/index.ts
+++ b/packages/next-on-pages/templates/_worker.js/index.ts
@@ -1,5 +1,5 @@
import { handleRequest } from './handleRequest';
-import { adjustRequestForVercel } from './utils';
+import { adjustRequestForVercel, handleImageResizingRequest } from './utils';
import type { AsyncLocalStorage } from 'node:async_hooks';
declare const __NODE_ENV__: string;
@@ -19,18 +19,26 @@ export default {
{ status: 503 }
);
}
- return envAsyncLocalStorage.run({ ...env, NODE_ENV: __NODE_ENV__ }, () => {
- const adjustedRequest = adjustRequestForVercel(request);
+ return envAsyncLocalStorage.run(
+ { ...env, NODE_ENV: __NODE_ENV__ },
+ async () => {
+ const url = new URL(request.url);
+ if (url.pathname.startsWith('/_next/image')) {
+ return handleImageResizingRequest(request, __CONFIG__.images);
+ }
- return handleRequest(
- {
- request: adjustedRequest,
- ctx,
- assetsFetcher: env.ASSETS,
- },
- __CONFIG__,
- __BUILD_OUTPUT__
- );
- });
+ const adjustedRequest = adjustRequestForVercel(request);
+
+ return handleRequest(
+ {
+ request: adjustedRequest,
+ ctx,
+ assetsFetcher: env.ASSETS,
+ },
+ __CONFIG__,
+ __BUILD_OUTPUT__
+ );
+ }
+ );
},
} as ExportedHandler<{ ASSETS: Fetcher }>;
diff --git a/packages/next-on-pages/templates/_worker.js/utils/images.ts b/packages/next-on-pages/templates/_worker.js/utils/images.ts
new file mode 100644
index 000000000..1d3b3db9e
--- /dev/null
+++ b/packages/next-on-pages/templates/_worker.js/utils/images.ts
@@ -0,0 +1,149 @@
+/**
+ * Checks whether the given URL matches the given remote pattern from the Vercel build output
+ * images configuration.
+ *
+ * https://vercel.com/docs/build-output-api/v3/configuration#images
+ *
+ * @param url URL to check.
+ * @param pattern Remote pattern to match against.
+ * @returns Whether the URL matches the remote pattern.
+ */
+export function isRemotePatternMatch(
+ url: URL,
+ { protocol, hostname, port, pathname }: VercelImageRemotePattern
+): boolean {
+ // Protocol must match if defined.
+ if (protocol && url.protocol.replace(/:$/, '') !== protocol) return false;
+ // Hostname must match regexp.
+ if (!new RegExp(hostname).test(url.hostname)) return false;
+ // Port must match regexp if defined.
+ if (port && !new RegExp(port).test(url.port)) return false;
+ // Pathname must match regexp if defined.
+ if (pathname && !new RegExp(pathname).test(url.pathname)) return false;
+ // All checks passed.
+ return true;
+}
+
+type ResizingProperties = {
+ imageUrl: URL;
+ options: RequestInitCfPropertiesImage;
+};
+
+/**
+ * Derives the properties to use for image resizing from the incoming request, respecting the
+ * images configuration spec from the Vercel build output config.
+ *
+ * https://vercel.com/docs/build-output-api/v3/configuration#images
+ *
+ * @param request Incoming request.
+ * @param config Images configuration from the Vercel build output.
+ * @returns Resizing properties if the request is valid, otherwise undefined.
+ */
+export function getResizingProperties(
+ request: Request,
+ config?: VercelImagesConfig
+): ResizingProperties | undefined {
+ if (request.method !== 'GET') return undefined;
+
+ const { origin, searchParams } = new URL(request.url);
+
+ const rawUrl = searchParams.get('url');
+ const width = Number.parseInt(searchParams.get('w') ?? '', 10);
+ // 75 is the default quality - https://nextjs.org/docs/app/api-reference/components/image#quality
+ const quality = Number.parseInt(searchParams.get('q') ?? '75', 10);
+
+ if (!rawUrl || Number.isNaN(width) || Number.isNaN(quality)) return undefined;
+ if (!config?.sizes?.includes(width)) return undefined;
+ if (quality < 0 || quality > 100) return undefined;
+
+ const url = new URL(rawUrl, origin);
+
+ // SVGs must be allowed by the config.
+ if (url.pathname.endsWith('.svg') && !config?.dangerouslyAllowSVG) {
+ return undefined;
+ }
+
+ if (
+ // Relative URL means same origin as deployment and is allowed.
+ !(rawUrl.startsWith('/') || rawUrl.startsWith('%2F')) &&
+ // External image URL must be allowed by domains or remote patterns.
+ !config?.domains?.includes(url.hostname) &&
+ !config?.remotePatterns?.find(pattern => isRemotePatternMatch(url, pattern))
+ ) {
+ return undefined;
+ }
+
+ const acceptHeader = request.headers.get('Accept') ?? '';
+ const format = config?.formats
+ ?.find(format => acceptHeader.includes(format))
+ ?.replace('image/', '') as VercelImageFormatWithoutPrefix;
+
+ return {
+ imageUrl: url,
+ options: { width, quality, format },
+ };
+}
+
+/**
+ * Formats the given response to match the images configuration spec from the Vercel build output
+ * config.
+ *
+ * Applies headers for `Content-Security-Policy` and `Content-Disposition`, if defined in the config.
+ *
+ * https://vercel.com/docs/build-output-api/v3/configuration#images
+ *
+ * @param resp Response to format.
+ * @param imageUrl Image URL that was resized.
+ * @param config Images configuration from the Vercel build output.
+ * @returns Formatted response.
+ */
+export function formatResp(
+ resp: Response,
+ imageUrl: URL,
+ config?: VercelImagesConfig
+): Response {
+ const newHeaders = new Headers(resp.headers);
+
+ if (config?.contentSecurityPolicy) {
+ newHeaders.set('Content-Security-Policy', config.contentSecurityPolicy);
+ }
+
+ if (config?.contentDispositionType) {
+ const fileName = imageUrl.pathname.split('/').pop();
+ const contentDisposition = fileName
+ ? `${config.contentDispositionType}; filename="${fileName}"`
+ : config.contentDispositionType;
+
+ newHeaders.set('Content-Disposition', contentDisposition);
+ }
+
+ return new Response(resp.body, { headers: newHeaders });
+}
+
+/**
+ * Handles image resizing requests.
+ *
+ * @param request Incoming request.
+ * @param config Images configuration from the Vercel build output.
+ * @returns Resized image response if the request is valid, otherwise a 400 response.
+ */
+export async function handleImageResizingRequest(
+ request: Request,
+ config?: VercelImagesConfig
+): Promise {
+ const opts = getResizingProperties(request, config);
+
+ if (!opts) {
+ return new Response('Invalid image resizing request', { status: 400 });
+ }
+
+ const { imageUrl } = opts;
+
+ // NOTE: Pages does not support the RequestInit image resizing yet.
+ // const imageReq = new Request(imageUrl, { headers: request.headers });
+ // const imageResp = await fetch(imageReq, { cf: { image: options } });
+ // if (imageResp.status === 200) return formatResp(imageResp, imageUrl, config);
+
+ const imageResp = await fetch(imageUrl, { headers: request.headers });
+ return formatResp(imageResp, imageUrl, config);
+}
diff --git a/packages/next-on-pages/templates/_worker.js/utils/index.ts b/packages/next-on-pages/templates/_worker.js/utils/index.ts
index 16e3bdf04..df2a0ff3f 100644
--- a/packages/next-on-pages/templates/_worker.js/utils/index.ts
+++ b/packages/next-on-pages/templates/_worker.js/utils/index.ts
@@ -3,3 +3,4 @@ export * from './request';
export * from './http';
export * from './pcre';
export * from './routing';
+export * from './images';
diff --git a/packages/next-on-pages/tests/templates/utils/images.test.ts b/packages/next-on-pages/tests/templates/utils/images.test.ts
new file mode 100644
index 000000000..6fe389d34
--- /dev/null
+++ b/packages/next-on-pages/tests/templates/utils/images.test.ts
@@ -0,0 +1,251 @@
+import { describe, expect, test } from 'vitest';
+import {
+ formatResp,
+ getResizingProperties,
+ isRemotePatternMatch,
+} from '../../../templates/_worker.js/utils';
+
+describe('isRemotePatternMatch', () => {
+ test('hostname matches correctly', () => {
+ const config: VercelImageRemotePattern = {
+ hostname: '^via\\.placeholder\\.com$',
+ };
+
+ const validUrl = new URL('https://via.placeholder.com/images/1.jpg');
+ expect(isRemotePatternMatch(validUrl, config)).toEqual(true);
+
+ const invalidUrl = new URL('https://example.com/images/1.jpg');
+ expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false);
+ });
+
+ test('protocol matches correctly', () => {
+ const config: VercelImageRemotePattern = {
+ protocol: 'https',
+ hostname: '^via\\.placeholder\\.com$',
+ };
+
+ const validUrl = new URL('https://via.placeholder.com/images/1.jpg');
+ expect(isRemotePatternMatch(validUrl, config)).toEqual(true);
+
+ const invalidUrl = new URL('http://via.placeholder.com/images/1.jpg');
+ expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false);
+ });
+
+ test('port matches correctly', () => {
+ const config: VercelImageRemotePattern = {
+ hostname: '^via\\.placeholder\\.com$',
+ port: '9000',
+ };
+
+ const validUrl = new URL('https://via.placeholder.com:9000/images/1.jpg');
+ expect(isRemotePatternMatch(validUrl, config)).toEqual(true);
+
+ const invalidUrl = new URL('http://via.placeholder.com/images/1.jpg');
+ expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false);
+ });
+
+ test('pathname matches correctly', () => {
+ const config: VercelImageRemotePattern = {
+ hostname: '^via\\.placeholder\\.com$',
+ pathname: '^/images/.*$',
+ };
+
+ const validUrl = new URL('https://via.placeholder.com:9000/images/1.jpg');
+ expect(isRemotePatternMatch(validUrl, config)).toEqual(true);
+
+ const invalidUrl = new URL('http://via.placeholder.com/videos/1.mp4');
+ expect(isRemotePatternMatch(invalidUrl, config)).toEqual(false);
+ });
+});
+
+const baseUrl = 'https://localhost/_next/image?url=';
+const baseValidUrl = `${baseUrl}%2Fimages%2F1.jpg`;
+const baseConfig: VercelImagesConfig = {
+ domains: ['example.com'],
+ sizes: [640, 750, 828, 1080, 1200],
+ remotePatterns: [{ hostname: '^via\\.placeholder\\.com$' }],
+ formats: ['image/avif', 'image/webp'],
+};
+
+describe('getResizingProperties', () => {
+ test('invalid method fails', () => {
+ const url = new URL(baseValidUrl);
+ const req = new Request(url, { method: 'POST' });
+
+ expect(getResizingProperties(req)).toEqual(undefined);
+ });
+
+ describe('request search params', () => {
+ test('invalid url fails', () => {
+ const url = new URL(baseUrl);
+ const req = new Request(url);
+
+ expect(getResizingProperties(req)).toEqual(undefined);
+ });
+
+ test('invalid width fails', () => {
+ const url = new URL(`${baseValidUrl}&w=abc`);
+ const req = new Request(url);
+
+ expect(getResizingProperties(req)).toEqual(undefined);
+ });
+
+ test('invalid quality fails', () => {
+ const url = new URL(`${baseValidUrl}&w=100&q=abc`);
+ const req = new Request(url);
+
+ expect(getResizingProperties(req)).toEqual(undefined);
+ });
+
+ test('invalid width in images config fails', () => {
+ const url = new URL(`${baseValidUrl}&w=100`);
+ const req = new Request(url);
+
+ expect(getResizingProperties(req, baseConfig)).toEqual(undefined);
+ });
+
+ test('invalid quality (>100) fails', () => {
+ const url = new URL(`${baseValidUrl}&w=640&q=150`);
+ const req = new Request(url);
+
+ expect(getResizingProperties(req, baseConfig)).toEqual(undefined);
+ });
+
+ test('invalid quality (<0) fails', () => {
+ const url = new URL(`${baseValidUrl}&w=640&q=-1`);
+ const req = new Request(url);
+
+ expect(getResizingProperties(req, baseConfig)).toEqual(undefined);
+ });
+ });
+
+ describe('relative (same origin) image', () => {
+ test('image with valid request options succeeds', () => {
+ const url = new URL(`${baseValidUrl}&w=640`);
+ const req = new Request(url);
+
+ const result = getResizingProperties(req, baseConfig);
+ expect(result).toEqual({
+ imageUrl: new URL('https://localhost/images/1.jpg'),
+ options: { format: undefined, width: 640, quality: 75 },
+ });
+ });
+
+ test('svg image fails when config disallows svgs', () => {
+ const url = new URL(`${baseValidUrl.replace('jpg', 'svg')}&w=640`);
+ const req = new Request(url);
+ const config = { ...baseConfig, dangerouslyAllowSVG: false };
+
+ expect(getResizingProperties(req, config)).toEqual(undefined);
+ });
+
+ test('svg image succeeds when config allows svgs', () => {
+ const url = new URL(`${baseValidUrl.replace('jpg', 'svg')}&w=640`);
+ const req = new Request(url);
+ const config = { ...baseConfig, dangerouslyAllowSVG: true };
+
+ const result = getResizingProperties(req, config);
+ expect(result).toEqual({
+ imageUrl: new URL('https://localhost/images/1.svg'),
+ options: { format: undefined, width: 640, quality: 75 },
+ });
+ });
+
+ test('svg image succeeds when config allows them', () => {
+ const url = new URL(`${baseValidUrl.replace('jpg', 'svg')}&w=640`);
+ const req = new Request(url);
+ const config = { ...baseConfig, dangerouslyAllowSVG: true };
+
+ const result = getResizingProperties(req, config);
+ expect(result).toEqual({
+ imageUrl: new URL('https://localhost/images/1.svg'),
+ options: { format: undefined, width: 640, quality: 75 },
+ });
+ });
+ });
+
+ describe('external image', () => {
+ test('external image fails with disallowed domain', () => {
+ const url = new URL(
+ `${baseUrl}https%3A%2F%2Finvalid.com%2Fimage.jpg&w=640`
+ );
+ const req = new Request(url);
+
+ expect(getResizingProperties(req, baseConfig)).toEqual(undefined);
+ });
+
+ test('external image succeeds with allowed domain', () => {
+ const url = new URL(
+ `${baseUrl}https%3A%2F%2Fexample.com%2Fimage.jpg&w=640`
+ );
+ const req = new Request(url);
+
+ const result = getResizingProperties(req, baseConfig);
+ expect(result).toEqual({
+ imageUrl: new URL('https://example.com/image.jpg'),
+ options: { format: undefined, width: 640, quality: 75 },
+ });
+ });
+
+ test('external image suceeds with allowed remote pattern', () => {
+ const url = new URL(
+ `${baseUrl}https%3A%2F%2Fvia.placeholder.com%2Fimage.jpg&w=640`
+ );
+ const req = new Request(url);
+
+ const result = getResizingProperties(req, baseConfig);
+ expect(result).toEqual({
+ imageUrl: new URL('https://via.placeholder.com/image.jpg'),
+ options: { format: undefined, width: 640, quality: 75 },
+ });
+ });
+ });
+
+ describe('request headers', () => {
+ test('return correct format for `accept` header (webp)', () => {
+ const url = new URL(`${baseValidUrl}&w=640`);
+ const req = new Request(url, { headers: { Accept: 'image/webp' } });
+
+ const result = getResizingProperties(req, baseConfig);
+ expect(result).toEqual({
+ imageUrl: new URL('https://localhost/images/1.jpg'),
+ options: { format: 'webp', width: 640, quality: 75 },
+ });
+ });
+
+ test('return correct format for `accept` header (avif)', () => {
+ const url = new URL(`${baseValidUrl}&w=640`);
+ const req = new Request(url, {
+ headers: { Accept: 'image/avif,image/webp' },
+ });
+
+ const result = getResizingProperties(req, baseConfig);
+ expect(result).toEqual({
+ imageUrl: new URL('https://localhost/images/1.jpg'),
+ options: { format: 'avif', width: 640, quality: 75 },
+ });
+ });
+ });
+});
+
+describe('formatResp', () => {
+ test('applies content security policy from the config', () => {
+ const config = { ...baseConfig, contentSecurityPolicy: 'default-src' };
+ const imageUrl = new URL('https://localhost/images/1.jpg');
+
+ const newResp = formatResp(new Response(), imageUrl, config);
+ expect(newResp.headers.get('Content-Security-Policy')).toEqual(
+ 'default-src'
+ );
+ });
+
+ test('applies content disposition from the config', () => {
+ const config = { ...baseConfig, contentDispositionType: 'inline' };
+ const imageUrl = new URL('https://localhost/images/1.jpg');
+
+ const newResp = formatResp(new Response(), imageUrl, config);
+ expect(newResp.headers.get('Content-Disposition')).toEqual(
+ 'inline; filename="1.jpg"'
+ );
+ });
+});
diff --git a/packages/next-on-pages/vercel.types.d.ts b/packages/next-on-pages/vercel.types.d.ts
index 0cf48d6df..4433d8ab6 100644
--- a/packages/next-on-pages/vercel.types.d.ts
+++ b/packages/next-on-pages/vercel.types.d.ts
@@ -85,11 +85,20 @@ type VercelHandler = {
};
type VercelImageFormat = 'image/avif' | 'image/webp';
+type VercelImageFormatWithoutPrefix = StripPrefix;
+type StripPrefix = T extends `${K}${infer V}` ? V : T;
+
+type VercelImageRemotePattern = {
+ protocol?: 'http' | 'https';
+ hostname: string;
+ port?: string;
+ pathname?: string;
+};
type VercelImagesConfig = {
sizes: number[];
domains: string[];
- remotePatterns?: string[];
+ remotePatterns?: VercelImageRemotePattern[];
minimumCacheTTL?: number; // seconds
formats?: VercelImageFormat[];
dangerouslyAllowSVG?: boolean;