How to Use Next.js Image Optimization in React Native Project
15 April 2023 / 9 min read
React Native (or Expo) doesn’t have image optimization, so developers should come up with their own solutions to scale, resize and optimize remote images. Next.js is a no-brainer solution for lots of projects which ships image optimization by default. What is more important that you can use the same image loader to optimize any images on any platform including React Native as far as a platform implements a correct URL for the loader.
Implementing the image optimization loader in React Native
If we take a look in the source code of the image loader, we’ll find that it requires to know three parameters: image source, quality and width. The width
param is the most trickiest one because sometimes developers don’t define width intentionally, e.g. the simplest example is to fill the container:
<View style={{ height: 200, borderRadius: 4, overflow: 'hidden' }}>
<Image source={{ uri: 'https://remote.origin/image.jpg' }} style={{ flex: 1 }} />
</View>
You can either define static width or implement dynamic calculation using the onLayout
function. But let’s discuss the width problem later and implement the loader:
interface ImageLoaderConfig {
src: string;
width: number;
quality?: number;
}
export function imageLoader({ src, width, quality }: ImageLoaderConfig) {
const url = new URL('/_next/image', process.env.NX_WEB_URL); // replace the env variable with the base URL of your Next.js frontend, e.g. 'https://example.com'
url.searchParams.append('url', src);
url.searchParams.append('w', width.toString());
url.searchParams.append('q', String(quality || 75));
return url.toString();
}
That’s actually it for the loader, but let’s circle back to the width problem.
Using static width
The most obvious solution is to mirror functionality from Next.js and define static width every time when we use Image
component. You could also extract width from styles as well which solves a lot of cases by default:
import { StyleSheet, ImageProps, Image as RNImage } from 'react-native';
function extractStyleWidth(style?: ImageProps['style']) {
if (style) {
const { width } = StyleSheet.flatten(style);
if (typeof width === 'number') {
return width;
}
}
}
interface ImageLoaderConfig {
src: string;
width: number;
quality?: number;
}
export function imageLoader({ src, width, quality }: ImageLoaderConfig) {
const url = new URL('/_next/image', process.env.NX_WEB_URL); // replace the env variable with the base URL of your Next.js frontend, e.g. 'https://example.com'
url.searchParams.append('url', src);
url.searchParams.append('w', width.toString());
url.searchParams.append('q', String(quality || 75));
return url.toString();
}
export interface Props extends ImageProps {
unoptimized?: boolean;
width?: number;
quality?: number;
}
export function Image({ unoptimized, source, style, width, quality, ...props }: Props) {
const imageWidth = extractStyleWidth(style) || width;
return (
<RNImage
{...props}
source={
!unoptimized && typeof source === 'object' && 'uri' in source && source.uri && imageWidth
? {
...source,
uri: imageLoader({
quality,
width: imageWidth,
src: source.uri,
}),
}
: source
}
style={style}
/>
);
}
However, if you test the code, it won’t work as is. The one crucial part to mention that the image optimization endpoint from Next.js requires width
to be equal to one of the values from the config of Next.js (next.config.js
):
images.deviceSizes
images.imageSizes
You can find default values fordeviceSizes
here and forimageSizes
here or check yournext.config.js
file if it contains a similar configuration:
module.exports = {
images: {
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
},
};
So in order to make the loader work we have to extract a matching value from the arrays above:
const config = {
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
};
const SIZES = [...config.imageSizes, ...config.deviceSizes];
function normalizeWidth(width: number) {
const calculatedSize = PixelRatio.getPixelSizeForLayoutSize(width);
const matchingIndex = SIZES.findIndex((size) => size >= calculatedSize);
if (matchingIndex === -1) {
return SIZES[SIZES.length - 1];
} else if (matchingIndex === 0) {
return SIZES[0];
} else {
const left = SIZES[matchingIndex - 1];
const right = SIZES[matchingIndex];
if ((left + right) / 2 > width) {
return left;
}
return right;
}
}
Since mobile applications define its size in points, we have to convert logical points to pixels using the built-in utility PixelRatio.getPixelSizeForLayoutSize
. You could also redefine the way how the function takes the first matching value from the array to avoid downscaling. The function above “scales down” images which fall below the average of left and right boundaries, and because of that images could have visible artifacts. If you want to avoid this issue use a first matching value:
const config = {
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
};
const SIZES = [...config.imageSizes, ...config.deviceSizes];
function normalizeWidth(width: number) {
const calculatedSize = PixelRatio.getPixelSizeForLayoutSize(width);
const matchingSize = SIZES.find((size) => size >= calculatedSize);
// width is larger than the image loader can handle
if (matchingSize === -1) {
return SIZES[SIZES.length - 1];
}
return matchingSize;
}
As a result, you’ll get an optimized image component:
import { StyleSheet, ImageProps, Image as RNImage, PixelRatio } from 'react-native';
function extractStyleWidth(style?: ImageProps['style']) {
if (style) {
const { width } = StyleSheet.flatten(style);
if (typeof width === 'number') {
return width;
}
}
}
const config = {
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
};
const SIZES = [...config.imageSizes, ...config.deviceSizes];
function normalizeWidth(width: number) {
const calculatedSize = PixelRatio.getPixelSizeForLayoutSize(width);
const matchingIndex = SIZES.findIndex((size) => size >= calculatedSize);
if (matchingIndex === -1) {
return SIZES[SIZES.length - 1];
} else if (matchingIndex === 0) {
return SIZES[0];
} else {
const left = SIZES[matchingIndex - 1];
const right = SIZES[matchingIndex];
if ((left + right) / 2 > width) {
return left;
}
return right;
}
}
interface ImageLoaderConfig {
src: string;
width: number;
quality?: number;
}
export function imageLoader({ src, width, quality }: ImageLoaderConfig) {
const url = new URL('/_next/image', process.env.NX_WEB_URL); // replace the env variable with the base URL of your Next.js frontend, e.g. 'https://example.com'
url.searchParams.append('url', src);
url.searchParams.append('w', width.toString());
url.searchParams.append('q', String(quality || 75));
return url.toString();
}
export interface Props extends ImageProps {
unoptimized?: boolean;
width?: number;
quality?: number;
}
export function Image({ unoptimized, source, style, width, quality, ...props }: Props) {
const imageWidth = extractStyleWidth(style) || width;
return (
<RNImage
{...props}
source={
!unoptimized && typeof source === 'object' && 'uri' in source && source.uri && imageWidth
? {
...source,
uri: imageLoader({
quality,
width: imageWidth,
src: source.uri,
}),
}
: source
}
style={style}
/>
);
}
Using dynamic width
Dynamic width will add a slightly noticeable delay because React Native has to measure layout firstly using the onLayout
function and then rerender:
import { useState } from 'react';
import { StyleSheet, ImageProps, Image as RNImage, PixelRatio } from 'react-native';
function extractStyleWidth(style?: ImageProps['style']) {
if (style) {
const { width } = StyleSheet.flatten(style);
if (typeof width === 'number') {
return width;
}
}
}
const config = {
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
};
const SIZES = [...config.imageSizes, ...config.deviceSizes];
function normalizeWidth(width: number) {
const calculatedSize = PixelRatio.getPixelSizeForLayoutSize(width);
const matchingIndex = SIZES.findIndex((size) => size >= calculatedSize);
if (matchingIndex === -1) {
return SIZES[SIZES.length - 1];
} else if (matchingIndex === 0) {
return SIZES[0];
} else {
const left = SIZES[matchingIndex - 1];
const right = SIZES[matchingIndex];
if ((left + right) / 2 > width) {
return left;
}
return right;
}
}
interface ImageLoaderConfig {
src: string;
width: number;
quality?: number;
}
export function imageLoader({ src, width, quality }: ImageLoaderConfig) {
const url = new URL('/_next/image', process.env.NX_WEB_URL); // replace the env variable with the base URL of your Next.js frontend, e.g. 'https://example.com'
url.searchParams.append('url', src);
url.searchParams.append('w', width.toString());
url.searchParams.append('q', String(quality || 75));
return url.toString();
}
export interface Props extends ImageProps {
unoptimized?: boolean;
width?: number;
quality?: number;
}
export function Image({ unoptimized, source, style, width, quality, onLayout, ...props }: Props) {
const staticWidth = extractStyleWidth(style) || width;
const [imageWidth, setImageWidth] = useState(staticWidth);
return (
<RNImage
{...props}
onLayout={(event) => {
if (onLayout) {
onLayout(event);
}
if (!staticWidth) {
setImageWidth(event.nativeEvent.layout.width);
}
}}
source={
!unoptimized && typeof source === 'object' && 'uri' in source && source.uri
? {
...source,
uri: imageWidth
? imageLoader({
width: normalizeWidth(imageWidth),
src: source.uri,
})
: undefined,
}
: source
}
style={style}
/>
);
}
That’s it! Your image component uses the image optimization from Next.js now.
Bonus tip: making things work with react-native-fast-image
react-native-fast-image is a perfect addition to your React Native toolset. It allows to cache, prioritize loading, and preload images. The only thing that we need to adapt in our custom image component is static methods of FastImage:
import { useState } from 'react';
import { PixelRatio, StyleSheet } from 'react-native';
import FastImage, { FastImageProps, FastImageStaticProperties, Source } from 'react-native-fast-image';
function extractStyleWidth(style?: FastImageProps['style']) {
if (style) {
const { width } = StyleSheet.flatten(style);
if (typeof width === 'number') {
return width;
}
}
}
const config = {
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
};
const SIZES = [...config.imageSizes, ...config.deviceSizes];
function normalizeWidth(width: number) {
const calculatedSize = PixelRatio.getPixelSizeForLayoutSize(width);
const matchingIndex = SIZES.findIndex((size) => size >= calculatedSize);
if (matchingIndex === -1) {
return SIZES[SIZES.length - 1];
} else if (matchingIndex === 0) {
return SIZES[0];
} else {
const left = SIZES[matchingIndex - 1];
const right = SIZES[matchingIndex];
if ((left + right) / 2 > width) {
return left;
}
return right;
}
}
interface ImageLoaderConfig {
src: string;
width: number;
quality?: number;
}
export function imageLoader({ src, width, quality }: ImageLoaderConfig) {
const url = new URL('/_next/image', process.env.NX_WEB_URL); // replace the env variable with the base URL of your Next.js frontend, e.g. 'https://example.com'
url.searchParams.append('url', src);
url.searchParams.append('w', width.toString());
url.searchParams.append('q', String(quality || 75));
return url.toString();
}
export interface Props extends FastImageProps {
unoptimized?: boolean;
width?: number;
quality?: number;
}
function OptimizedImage({ unoptimized, source, style, width, quality, onLayout, ...props }: Props) {
const staticWidth = extractStyleWidth(style) || width;
const [imageWidth, setImageWidth] = useState(staticWidth);
return (
<RNImage
{...props}
onLayout={(event) => {
if (onLayout) {
onLayout(event);
}
if (!staticWidth) {
setImageWidth(event.nativeEvent.layout.width);
}
}}
source={
!unoptimized && typeof source === 'object' && 'uri' in source && source.uri
? {
...source,
uri: imageWidth
? imageLoader({
width: normalizeWidth(imageWidth),
src: source.uri,
})
: undefined,
}
: source
}
style={style}
/>
);
}
Object.getOwnPropertyNames(FastImage).forEach((prop) => {
if (!['$$typeof', 'render', 'displayName'].includes(prop)) {
// @ts-ignore
UntypedImage[prop] = FastImage[prop];
}
});
type PreloadOptimized = (sources: (Source & { width?: number; uri: string })[]) => void;
// Preload images using our loader when width is defined
const preload: PreloadOptimized = (sources) => {
FastImage.preload(
sources.map(({ uri, width, ...rest }) => ({
...rest,
uri:
uri && width
? imageLoader({
width,
src: uri,
})
: uri,
}))
);
};
OptimizedImage.preload = preload;
export const Image = OptimizedImage as React.FunctionComponent<Props> &
FastImageStaticProperties & {
preloadOptimized: PreloadOptimized;
};