├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __mocks__ └── nanoid.ts ├── eslint.config.js ├── jest.config.ts ├── package.config.ts ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── src ├── index.ts ├── types.ts └── useNextSanityImage.ts ├── test └── useNextSanityImage.test.ts └── tsconfig.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | run-name: ${{inputs.release && 'CI ➤ Release' || ''}} 5 | 6 | on: 7 | pull_request: 8 | types: [opened, synchronize] 9 | push: 10 | branches: [main] 11 | workflow_dispatch: 12 | inputs: 13 | release: 14 | description: Release 15 | required: true 16 | default: false 17 | type: boolean 18 | 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | build: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: pnpm/action-setup@v4.0.0 29 | - uses: actions/setup-node@v3 30 | with: 31 | cache: pnpm 32 | node-version: lts/* 33 | - run: corepack enable && pnpm --version 34 | - run: pnpm install 35 | - run: pnpm lint 36 | - run: pnpm build 37 | 38 | test: 39 | needs: build 40 | strategy: 41 | # A test failing on windows doesn't mean it'll fail on macos. It's useful to let all tests run to its completion to get the full picture 42 | fail-fast: false 43 | matrix: 44 | # https://nodejs.org/en/about/releases/ 45 | # https://pnpm.io/installation#compatibility 46 | # Includes previous LTS release, the latest and the upcoming version in development 47 | node: [lts/-1, lts/*, current] 48 | os: [ubuntu-latest] 49 | next-version: [13, 14, 15] 50 | sanity-client-version: ["5", "6", "7"] 51 | # Also test the LTS on mac and windows 52 | include: 53 | - os: macos-latest 54 | node: lts/* 55 | - os: windows-latest 56 | node: lts/* 57 | 58 | runs-on: ubuntu-latest 59 | steps: 60 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF 61 | - if: matrix.os == 'windows-latest' 62 | run: | 63 | git config --global core.autocrlf false 64 | git config --global core.eol lf 65 | - uses: actions/checkout@v3 66 | - uses: pnpm/action-setup@v4.0.0 67 | - uses: actions/setup-node@v3 68 | with: 69 | cache: pnpm 70 | node-version: ${{ matrix.node }} 71 | - run: pnpm add next@${{ matrix.next-version }} @sanity/client@${{ matrix.sanity-client-version }} 72 | - if: matrix.next-version < 15 73 | run: pnpm add react@18 react-dom@18 74 | - run: corepack enable && pnpm --version 75 | - run: pnpm install --loglevel=error 76 | - run: pnpm test 77 | 78 | release: 79 | needs: [build, test] 80 | # only run if opt-in during workflow_dispatch 81 | if: github.event.inputs.release == 'true' 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v3 85 | with: 86 | # Need to fetch entire commit history to 87 | # analyze every commit since last release 88 | fetch-depth: 0 89 | - uses: pnpm/action-setup@v4.0.0 90 | - uses: actions/setup-node@v3 91 | with: 92 | cache: pnpm 93 | node-version: lts/* 94 | - run: corepack enable && pnpm --version 95 | - run: pnpm install --loglevel=error 96 | - run: pnpm release 97 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 98 | # e.g. git tags were pushed but it exited before `npm publish` 99 | if: always() 100 | env: 101 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 102 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # mac files 2 | .DS_Store 3 | 4 | # dependency directories 5 | /node_modules 6 | package-lock.json 7 | 8 | # Build directory 9 | /dist 10 | 11 | # Cache files 12 | .eslintcache 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "printWidth": 100, 4 | "quoteProps": "consistent", 5 | "singleQuote": true, 6 | "tabWidth": 4, 7 | "trailingComma": "none", 8 | "useTabs": true 9 | } 10 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [6.2.0](https://github.com/lorenzodejong/next-sanity-image/compare/v6.1.1...v6.2.0) (2025-05-22) 9 | 10 | ### Features 11 | 12 | - support Next 15 ([#72](https://github.com/lorenzodejong/next-sanity-image/issues/72)) ([83f587a](https://github.com/lorenzodejong/next-sanity-image/commit/83f587a0d83369b99e94df787f929242d976d33d)) 13 | 14 | ## [6.1.1](https://github.com/lorenzodejong/next-sanity-image/compare/v6.1.0...v6.1.1) (2023-11-13) 15 | 16 | ### Bug Fixes 17 | 18 | - export SanityClientOrProjectDetails to prevent the build from erroring. ([1179d90](https://github.com/lorenzodejong/next-sanity-image/commit/1179d9090ea0dc7aa4ff9dc1ea4af3b96c1a24f3)) 19 | 20 | ## [6.1.0](https://github.com/lorenzodejong/next-sanity-image/compare/v6.0.0...v6.1.0) (2023-06-17) 21 | 22 | ### Features 23 | 24 | - allow @sanity/client ^6.0.0 as peer dependency. ([553b5c6](https://github.com/lorenzodejong/next-sanity-image/commit/553b5c6ce04d7e627b5cbff33d28ea5a673ecda3)) 25 | 26 | ### Bug Fixes 27 | 28 | - remove dry-run from the release CI script. ([446e076](https://github.com/lorenzodejong/next-sanity-image/commit/446e0761afa9292c7834dc035800045552ab8309)) 29 | - remove duplicate conditional statement ([e1eb37f](https://github.com/lorenzodejong/next-sanity-image/commit/e1eb37fbcccf8bcf5f083dd0a4e2b945139f5c6b)) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Bundles and Batches 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-sanity-image 2 | 3 | Utility for using images hosted on the [Sanity.io CDN](https://sanity.io) with the [Next.js image component](https://nextjs.org/docs/api-reference/next/image). This library: 4 | 5 | - Supports all [layout options](https://nextjs.org/docs/api-reference/next/image#layout) from the `next/image` component. 6 | - Implements the [loader callback](https://nextjs.org/docs/api-reference/next/image#loader) to resolve the corresponding Sanity CDN URL's. 7 | - Respects the [image sizes](https://nextjs.org/docs/basic-features/image-optimization#image-sizes) and [device sizes](https://nextjs.org/docs/basic-features/image-optimization#device-sizes) as specified in your Next config. 8 | - Respects the [quality](https://nextjs.org/docs/api-reference/next/image#quality) as specified in the `next/image` props. 9 | - Allows transforming the image using the [@sanity/image-url builder](https://www.npmjs.com/package/@sanity/image-url). 10 | - Automatically sets the width and the height of the Next image component to the corresponding aspect ratio. 11 | - Supports Webp formats using automatic content negotation. 12 | - Is fully typed and exposes [relevant types](#types). 13 | 14 | ## Installation 15 | 16 | ``` 17 | npm install --save next-sanity-image 18 | ``` 19 | 20 | This library also expects you to pass in a [SanityClient instance](https://www.npmjs.com/package/@sanity/client), if you haven't installed this already: 21 | 22 | ``` 23 | npm install --save @sanity/client 24 | ``` 25 | 26 | ## Upgrading 27 | 28 | ### Upgrading from 4.x.x to 5.x.x 29 | 30 | Version 5.0.0 of this library has removed support for the blur options. The reason for this is that this could not be correctly standardised from the library, the only way to support blur up was to request a low quality placeholder image from the Sanity CDN. Sanity already provides a base 64 lqip from the asset's metadata (https://www.sanity.io/docs/image-metadata#74bfd1db9b97). 31 | 32 | Checkout the [Responsive layout](#responsive-layout) example on how to use the lqip in your Image component. 33 | 34 | ## Usage 35 | 36 | All `next/image` component layouts are supported. Below you can find a usage example for each of the supported layouts. 37 | 38 | ### Responsive layout 39 | 40 | It's recommended to use the responsive layout for the best compatibility with different devices and resolutions. It's required to set the `sizes` attribute using this layout (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-sizes). 41 | 42 | ```jsx 43 | import { createClient } from '@sanity/client'; 44 | import Img from 'next/image'; 45 | import { useNextSanityImage } from 'next-sanity-image'; 46 | 47 | // If you're using a private dataset you probably have to configure a separate write/read client. 48 | // https://www.sanity.io/help/js-client-usecdn-token 49 | const configuredSanityClient = createClient({ 50 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, 51 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET, 52 | useCdn: true 53 | }); 54 | 55 | const Page = ({ mySanityData }) => { 56 | const imageProps = useNextSanityImage(configuredSanityClient, mySanityData.image); 57 | 58 | return ( 59 | 66 | ); 67 | }; 68 | 69 | // Replace this with your logic for fetching data from the Sanity API. 70 | export const getServerSideProps = async function (context) { 71 | const { slug = '' } = context.query; 72 | 73 | const data = await configuredSanityClient.fetch( 74 | `{ 75 | "mySanityData": *[_type == "mySanityType" && slug.current == $slug][0] { 76 | image { 77 | asset->{ 78 | ..., 79 | metadata 80 | } 81 | } 82 | } 83 | }`, 84 | { slug } 85 | ); 86 | 87 | return { props: data }; 88 | }; 89 | 90 | export default Page; 91 | ``` 92 | 93 | ### Intrinsic layout 94 | 95 | ```jsx 96 | // ... see "Responsive layout" 97 | 98 | const Page = ({ mySanityData }) => { 99 | const imageProps = useNextSanityImage(configuredSanityClient, mySanityData.image); 100 | 101 | return ( 102 | 108 | ); 109 | }; 110 | 111 | // ... see "Responsive layout" 112 | ``` 113 | 114 | ### Fixed layout 115 | 116 | ```jsx 117 | // ... see "Responsive layout" 118 | 119 | const Page = ({ mySanityData }) => { 120 | const imageProps = useNextSanityImage(configuredSanityClient, mySanityData.image); 121 | 122 | return ( 123 | 128 | ); 129 | }; 130 | 131 | // ... see "Responsive layout" 132 | ``` 133 | 134 | ### Fill layout 135 | 136 | Omit the `width` and `height` props returned from `useNextSanityImage` when using a fill layout, as this fills the available space of the parent container. You probably also want to set the `objectFit` prop to specify how the object resizes inside the container. 137 | 138 | ```jsx 139 | // ... see "Responsive layout" 140 | 141 | const Page = ({ mySanityData }) => { 142 | const imageProps = useNextSanityImage(configuredSanityClient, mySanityData.image); 143 | 144 | return ( 145 | 151 | ); 152 | }; 153 | 154 | // ... see "Responsive layout" 155 | ``` 156 | 157 | ## API 158 | 159 | ### useNextSanityImage 160 | 161 | React hook which handles generating a URL for each of the defined sizes in the [image sizes](https://nextjs.org/docs/basic-features/image-optimization#image-sizes) and [device sizes](https://nextjs.org/docs/basic-features/image-optimization#device-sizes) Next.js options. 162 | 163 | #### sanityClient: [`SanityClient`](https://www.npmjs.com/package/@sanity/client) 164 | 165 | Pass in a configured instance of the SanityClient, used for building the URL using the [@sanity/image-url builder](https://www.npmjs.com/package/@sanity/image-url). 166 | 167 | #### image: [`SanityImageSource` | `null`](https://www.npmjs.com/package/@sanity/image-url#imagesource) 168 | 169 | A reference to a Sanity image asset, can be retrieved by using the Sanity API. You can pass in any asset that is also supported by the [image() method of @sanity/image-url](https://www.npmjs.com/package/@sanity/image-url#imagesource). This parameter can be set to `null` in order to not load any image. 170 | 171 | #### options: UseNextSanityImageOptions 172 | 173 | ##### imageBuilder?: `function(/* see below */)` 174 | 175 | | property | type | description | 176 | | --------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | 177 | | `imageUrlBuilder` | [`ImageUrlBuilder`](https://www.npmjs.com/package/@sanity/image-url#usage) | @sanity/image-url builder to apply image transformations. | 178 | | `options` | `UseNextSanityImageBuilderOptions` | Options object with relevant context passed to the callback, see properties below. | 179 | | `options.width` | number | null | The width for the current `srcSet` entry, if set to `null` this is the entry for the `src` fallback attribute. | 180 | | `options.originalImageDimensions` | `{ width: number, height: number, aspectRatio: number } : UseNextSanityImageDimensions` | Object containing dimensions of the original image passed to the `image` parameter. | 181 | | `options.croppedImageDimensions` | `{ width: number, height: number, aspectRatio: number } : UseNextSanityImageDimensions` | The cropped dimensions of the image, if a crop is supplied. Otherwise, the same as `originalImageDimensions`. | 182 | | `options.quality` | number | null | The quality of the image as passed to the `quality` prop of the `next/image` component. | 183 | 184 | An optional function callback which allows you to customize the image using the [`ImageUrlBuilder`](https://www.npmjs.com/package/@sanity/image-url#usage). This function is called for every entry in the [image sizes](https://nextjs.org/docs/basic-features/image-optimization#image-sizes) and [device sizes](https://nextjs.org/docs/basic-features/image-optimization#device-sizes), and is used to define the URL's outputted in the `srcSet` attribute of the image. 185 | 186 | Defaults to: 187 | 188 | ```javascript 189 | (imageUrlBuilder, options) => { 190 | return imageUrlBuilder 191 | .width(options.width || Math.min(options.originalImageDimensions.width, 1920)) 192 | .quality(options.quality || 75) 193 | .fit('clip'); 194 | }; 195 | ``` 196 | 197 | For an example on how to use this, read the chapter on [Image transformations](#image-transformations). 198 | 199 | #### Return value: UseNextSanityImageProps | null 200 | 201 | If the `image` parameter is set to `null`, the return value of this hook will also be `null`. This allows you to handle any conditional rendering when no image is loaded. If an `image` is set, to following result (`UseNextSanityImageProps`) will be returned: 202 | 203 | ```javascript 204 | { 205 | src: string, 206 | width: number, 207 | height: number, 208 | // https://nextjs.org/docs/api-reference/next/image#loader 209 | loader: ImageLoader 210 | } 211 | ``` 212 | 213 | ## Image transformations 214 | 215 | Custom transformations to the resulting image can be made by implementing the `imageBuilder` callback function. Note that it's recommended to implement a memoized callback, either by implementing the function outside of the component function scope or by making use of [`useCallback`](https://reactjs.org/docs/hooks-reference.html#usecallback). Otherwise the props will be recomputed for every render. 216 | 217 | ```jsx 218 | //... 219 | 220 | const myCustomImageBuilder = (imageUrlBuilder, options) => { 221 | return imageUrlBuilder 222 | .width(options.width || Math.min(options.originalImageDimensions.width, 800)) 223 | .blur(20) 224 | .flipHorizontal() 225 | .saturation(-100) 226 | .fit('clip'); 227 | }; 228 | 229 | const Page = ({ mySanityData }) => { 230 | const imageProps = useNextSanityImage(configuredSanityClient, mySanityData.image, { 231 | imageBuilder: myCustomImageBuilder 232 | }); 233 | 234 | return ; 235 | }; 236 | 237 | //... 238 | ``` 239 | 240 | ### Gotchas 241 | 242 | - Because [next/image](https://nextjs.org/docs/api-reference/next/image) only renders a single `` element with a `srcSet` attribute, the `width` and `height` prop being returned by the React hook is uniform for each size. Cropping an image is possible using the [`ImageUrlBuilder`](https://www.npmjs.com/package/@sanity/image-url#usage), however you have to return an image with the same aspect ratio for each of the defined sizes. Art direction is currently not supported (both by [next/image](https://nextjs.org/docs/api-reference/next/image) and this library). 243 | 244 | If the functionality mentioned above is desired, please file an issue stating your specific use case so we can look at the desired behavior and possibilities. 245 | 246 | ## Types 247 | 248 | The following types are exposed from the library: 249 | 250 | - [`ImageUrlBuilder`](https://www.npmjs.com/package/@sanity/image-url#usage) 251 | - `UseNextSanityImageProps` 252 | - `UseNextSanityImageOptions` 253 | - `UseNextSanityImageBuilder` 254 | - `UseNextSanityImageBuilderOptions` 255 | - `UseNextSanityImageDimensions` 256 | -------------------------------------------------------------------------------- /__mocks__/nanoid.ts: -------------------------------------------------------------------------------- 1 | export const nanoid = () => 'mocked-id'; 2 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginReact from "eslint-plugin-react"; 5 | import eslintConfigPrettier from "eslint-config-prettier/flat"; 6 | import { defineConfig } from "eslint/config"; 7 | import * as reactHooks from 'eslint-plugin-react-hooks'; 8 | 9 | export default defineConfig([ 10 | { files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], plugins: { js }, extends: ["js/recommended"] }, 11 | { files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], languageOptions: { globals: {...globals.browser, ...globals.node} } }, 12 | tseslint.configs.recommended, 13 | { 14 | ...pluginReact.configs.flat.recommended, 15 | settings: { 16 | ...pluginReact.configs.flat.recommended?.settings ?? {}, 17 | react: { 18 | ...pluginReact.configs.flat.recommended?.settings?.react ?? {}, 19 | version: "detect", 20 | } 21 | } 22 | }, 23 | { 24 | plugins: { "react-hooks": reactHooks }, 25 | }, 26 | eslintConfigPrettier, 27 | { ignores: ["dist/**"]} 28 | ]); 29 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | coverageProvider: 'v8', 3 | testEnvironment: 'jsdom', 4 | transform: { 5 | '^.+\\.ts?$': 'ts-jest' 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@sanity/pkg-utils'; 2 | 3 | export default defineConfig({ 4 | extract: { 5 | rules: { 'ae-missing-release-tag': 'off' } 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-sanity-image", 3 | "version": "6.2.0", 4 | "description": "Utility for using responsive images hosted on the Sanity.io CDN with the Next.js image component.", 5 | "bugs": { 6 | "url": "https://github.com/lorenzodejong/next-sanity-image/issues" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/lorenzodejong/next-sanity-image.git" 11 | }, 12 | "license": "MIT", 13 | "author": "Lorenzo de Jong", 14 | "sideEffects": false, 15 | "type": "module", 16 | "exports": { 17 | ".": { 18 | "types": "./dist/index.d.ts", 19 | "source": "./src/index.ts", 20 | "require": "./dist/index.cjs", 21 | "node": { 22 | "import": "./dist/index.cjs.js" 23 | }, 24 | "import": "./dist/index.js", 25 | "default": "./dist/index.js" 26 | }, 27 | "./package.json": "./package.json" 28 | }, 29 | "main": "./dist/index.cjs", 30 | "module": "./dist/index.js", 31 | "source": "./src/index.ts", 32 | "types": "./dist/index.d.ts", 33 | "files": [ 34 | "dist" 35 | ], 36 | "scripts": { 37 | "prebuild": "rimraf dist", 38 | "build": "pkg build --strict && pkg --strict", 39 | "lint": "eslint --cache --max-warnings 0 .", 40 | "prepublishOnly": "pnpm run build", 41 | "release": "semantic-release", 42 | "test": "jest" 43 | }, 44 | "browserslist": [ 45 | "> 0.2% and supports es6-module and supports es6-module-dynamic-import and not dead and not IE 11", 46 | "maintained node versions" 47 | ], 48 | "dependencies": { 49 | "@sanity/image-url": "^1.1.0" 50 | }, 51 | "devDependencies": { 52 | "@eslint/js": "^9.27.0", 53 | "@sanity/client": "^5.4.2", 54 | "@sanity/pkg-utils": "^2.4.10", 55 | "@sanity/semantic-release-preset": "^4.1.8", 56 | "@testing-library/react": "^16.3.0", 57 | "@types/jest": "^28.1.8", 58 | "@types/node": "^18.19.101", 59 | "@types/react": "^18.3.21", 60 | "eslint": "^9.27.0", 61 | "eslint-config-prettier": "^10.1.5", 62 | "eslint-plugin-react": "^7.37.5", 63 | "eslint-plugin-react-hooks": "^5.2.0", 64 | "globals": "^16.1.0", 65 | "jest": "^28.1.3", 66 | "jest-environment-jsdom": "^29.7.0", 67 | "next": "^15.3.2", 68 | "prettier": "^3.5.3", 69 | "prettier-plugin-packagejson": "^2.5.14", 70 | "react": "^18.3.1", 71 | "react-dom": "^18.3.1", 72 | "react-test-renderer": "^18.2.0", 73 | "rimraf": "^4.4.1", 74 | "semantic-release": "^24.2.4", 75 | "ts-jest": "^28.0.8", 76 | "ts-node": "^10.9.2", 77 | "typescript": "^4.9.5", 78 | "typescript-eslint": "^8.32.1" 79 | }, 80 | "peerDependencies": { 81 | "@sanity/client": "^5.0.0 || ^6.0.0 || ^7.0.0", 82 | "next": "^13.0.0 || ^14.0.0 || ^15.0.0", 83 | "react": "^18.0.0 || ^19.0.0" 84 | }, 85 | "packageManager": "pnpm@8.10.4" 86 | } 87 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>sanity-io/renovate-presets//ecosystem/auto"] 4 | } 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | ImageUrlBuilder, 3 | SanityClientOrProjectDetails, 4 | UseNextSanityImageBuilderOptions, 5 | UseNextSanityImageBuilder, 6 | UseNextSanityImageDimensions, 7 | UseNextSanityImageOptions, 8 | UseNextSanityImageProps 9 | } from './types'; 10 | export { useNextSanityImage } from './useNextSanityImage'; 11 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ImageUrlBuilder } from '@sanity/image-url/lib/types/builder'; 2 | import { 3 | SanityClientLike, 4 | SanityModernClientLike, 5 | SanityProjectDetails 6 | } from '@sanity/image-url/lib/types/types'; 7 | import { ImageLoader } from 'next/image'; 8 | 9 | export { ImageUrlBuilder } from '@sanity/image-url/lib/types/builder'; 10 | 11 | export type SanityClientOrProjectDetails = 12 | | SanityClientLike 13 | | SanityProjectDetails 14 | | SanityModernClientLike; 15 | 16 | export interface UseNextSanityImageDimensions { 17 | width: number; 18 | height: number; 19 | aspectRatio: number; 20 | } 21 | 22 | export interface UseNextSanityImageBuilderOptions { 23 | width: number | null; 24 | originalImageDimensions: UseNextSanityImageDimensions; 25 | croppedImageDimensions: UseNextSanityImageDimensions; 26 | quality: number | null; 27 | } 28 | 29 | export type UseNextSanityImageBuilder = ( 30 | imageUrlBuilder: ImageUrlBuilder, 31 | options: UseNextSanityImageBuilderOptions 32 | ) => ImageUrlBuilder; 33 | 34 | export interface UseNextSanityImageOptions { 35 | imageBuilder?: UseNextSanityImageBuilder; 36 | } 37 | 38 | export interface UseNextSanityImageProps { 39 | loader: ImageLoader; 40 | src: string; 41 | width: number; 42 | height: number; 43 | } 44 | -------------------------------------------------------------------------------- /src/useNextSanityImage.ts: -------------------------------------------------------------------------------- 1 | import imageUrlBuilder from '@sanity/image-url'; 2 | import { 3 | SanityAsset, 4 | SanityImageObject, 5 | SanityImageSource, 6 | SanityReference 7 | } from '@sanity/image-url/lib/types/types'; 8 | import { ImageLoader } from 'next/image'; 9 | import { useMemo } from 'react'; 10 | 11 | import { 12 | SanityClientOrProjectDetails, 13 | UseNextSanityImageBuilder, 14 | UseNextSanityImageDimensions, 15 | UseNextSanityImageOptions, 16 | UseNextSanityImageProps 17 | } from './types'; 18 | 19 | export const DEFAULT_FALLBACK_IMAGE_QUALITY = 75; 20 | 21 | const DEFAULT_IMAGE_BUILDER: UseNextSanityImageBuilder = (imageUrlBuilder, options) => { 22 | const result = imageUrlBuilder 23 | .quality(options.quality || DEFAULT_FALLBACK_IMAGE_QUALITY) 24 | .fit('clip'); 25 | 26 | if (options.width !== null) { 27 | return result.width(options.width); 28 | } 29 | 30 | return result; 31 | }; 32 | 33 | function getSanityRefId(image: SanityImageSource): string { 34 | if (typeof image === 'string') { 35 | return image; 36 | } 37 | 38 | const obj = image as SanityImageObject; 39 | const ref = image as SanityReference; 40 | const img = image as SanityAsset; 41 | 42 | if (obj.asset) { 43 | return obj.asset._ref || (obj.asset as SanityAsset)._id; 44 | } 45 | 46 | return ref._ref || img._id || ''; 47 | } 48 | 49 | export function getImageDimensions(id: string): UseNextSanityImageDimensions { 50 | const dimensions = id.split('-')[2]; 51 | 52 | const [width, height] = dimensions.split('x').map((num: string) => parseInt(num, 10)); 53 | const aspectRatio = width / height; 54 | 55 | return { width, height, aspectRatio }; 56 | } 57 | 58 | export function getCroppedDimensions( 59 | image: SanityImageSource, 60 | baseDimensions: UseNextSanityImageDimensions 61 | ): UseNextSanityImageDimensions { 62 | const crop = (image as SanityImageObject).crop; 63 | 64 | if (!crop) { 65 | return baseDimensions; 66 | } 67 | 68 | const { width, height } = baseDimensions; 69 | const croppedWidth = width * (1 - (crop.left + crop.right)); 70 | const croppedHeight = height * (1 - (crop.top + crop.bottom)); 71 | 72 | return { 73 | width: croppedWidth, 74 | height: croppedHeight, 75 | aspectRatio: croppedWidth / croppedHeight 76 | }; 77 | } 78 | 79 | export function useNextSanityImage( 80 | sanityClient: SanityClientOrProjectDetails, 81 | image: SanityImageSource, 82 | options?: UseNextSanityImageOptions 83 | ): UseNextSanityImageProps; 84 | 85 | export function useNextSanityImage( 86 | sanityClient: SanityClientOrProjectDetails, 87 | image: null, 88 | options?: UseNextSanityImageOptions 89 | ): null; 90 | 91 | export function useNextSanityImage( 92 | sanityClient: SanityClientOrProjectDetails, 93 | image: SanityImageSource | null, 94 | options?: UseNextSanityImageOptions 95 | ): UseNextSanityImageProps | null; 96 | 97 | export function useNextSanityImage( 98 | sanityClient: SanityClientOrProjectDetails, 99 | image: SanityImageSource | null, 100 | options?: UseNextSanityImageOptions 101 | ): UseNextSanityImageProps | null { 102 | const imageBuilder = options?.imageBuilder || DEFAULT_IMAGE_BUILDER; 103 | 104 | return useMemo(() => { 105 | if (!image) { 106 | return null; 107 | } 108 | 109 | // If the image has an alt text but does not contain an actual asset, the id will be 110 | // undefined: https://github.com/bundlesandbatches/next-sanity-image/issues/14 111 | const id = image ? getSanityRefId(image) : null; 112 | if (!id) { 113 | return null; 114 | } 115 | 116 | const originalImageDimensions = getImageDimensions(id); 117 | const croppedImageDimensions = getCroppedDimensions(image, originalImageDimensions); 118 | 119 | const loader: ImageLoader = ({ width, quality }) => { 120 | return ( 121 | imageBuilder(imageUrlBuilder(sanityClient).image(image).auto('format'), { 122 | width, 123 | originalImageDimensions, 124 | croppedImageDimensions, 125 | quality: quality || null 126 | }).url() || '' 127 | ); 128 | }; 129 | 130 | const baseImgBuilderInstance = imageBuilder( 131 | imageUrlBuilder(sanityClient).image(image).auto('format'), 132 | { 133 | width: null, 134 | originalImageDimensions, 135 | croppedImageDimensions, 136 | quality: null 137 | } 138 | ); 139 | 140 | const width = 141 | baseImgBuilderInstance.options.width || 142 | (baseImgBuilderInstance.options.maxWidth 143 | ? Math.min(baseImgBuilderInstance.options.maxWidth, croppedImageDimensions.width) 144 | : croppedImageDimensions.width); 145 | 146 | const height = 147 | baseImgBuilderInstance.options.height || 148 | (baseImgBuilderInstance.options.maxHeight 149 | ? Math.min(baseImgBuilderInstance.options.maxHeight, croppedImageDimensions.height) 150 | : Math.round(width / croppedImageDimensions.aspectRatio)); 151 | 152 | return { 153 | loader, 154 | src: baseImgBuilderInstance.url() as string, 155 | width, 156 | height 157 | }; 158 | }, [imageBuilder, image, sanityClient]); 159 | } 160 | -------------------------------------------------------------------------------- /test/useNextSanityImage.test.ts: -------------------------------------------------------------------------------- 1 | // to work around nanoid ESM import issues when running tests with jsdom + ts-jest 2 | jest.mock('nanoid', () => { 3 | // eslint-disable-next-line @typescript-eslint/no-require-imports 4 | return require('nanoid/non-secure'); 5 | }); 6 | 7 | import { createClient } from '@sanity/client'; 8 | import { ImageUrlBuilder } from '@sanity/image-url/lib/types/builder'; 9 | import { renderHook } from '@testing-library/react'; 10 | 11 | import { 12 | SanityImageCrop, 13 | SanityImageHotspot, 14 | SanityImageObject 15 | } from '@sanity/image-url/lib/types/types'; 16 | import { 17 | getCroppedDimensions, 18 | getImageDimensions, 19 | useNextSanityImage 20 | } from '../src/useNextSanityImage'; 21 | 22 | const PROJECT_ID = 'projectid'; 23 | const DATASET = 'dataset'; 24 | const IMAGE_ID = 'uuid'; 25 | 26 | const DEFAULT_IMAGE_WIDTH = 1366; 27 | const DEFAULT_IMAGE_HEIGHT = 768; 28 | const DEFAULT_IMAGE_ASPECT_RATIO = DEFAULT_IMAGE_WIDTH / DEFAULT_IMAGE_HEIGHT; 29 | const DEFAULT_CROP = { 30 | left: 0.1, 31 | right: 0.1, 32 | top: 0.1, 33 | bottom: 0.1 34 | }; 35 | const DEFAULT_HOTSPOT = { 36 | x: 0.5, 37 | y: 0.5, 38 | width: 1, 39 | height: 1 40 | }; 41 | 42 | const sanityClientConfig = { 43 | projectId: PROJECT_ID, 44 | dataset: DATASET, 45 | useCdn: true, 46 | apiVersion: '2021-10-21' 47 | }; 48 | const configuredSanityClient = createClient(sanityClientConfig); 49 | 50 | const generateSanityImageSource = (width: number, height: number) => ({ 51 | asset: { 52 | _ref: `image-uuid-${width}x${height}-png`, 53 | _type: 'reference' 54 | }, 55 | _type: 'image' 56 | }); 57 | 58 | const generateSanityImageObject = ( 59 | width: number, 60 | height: number, 61 | crop: SanityImageCrop, 62 | hotspot: SanityImageHotspot 63 | ): SanityImageObject => ({ 64 | ...generateSanityImageSource(width, height), 65 | crop: crop, 66 | hotspot: hotspot 67 | }); 68 | 69 | const generateSanityImageUrl = ( 70 | queryString = '', 71 | width = DEFAULT_IMAGE_WIDTH, 72 | height = DEFAULT_IMAGE_HEIGHT 73 | ) => 74 | `https://cdn.sanity.io/images/${PROJECT_ID}/${DATASET}/${IMAGE_ID}-${width}x${height}.png${queryString}`; 75 | 76 | describe('useNextSanityImage', () => { 77 | beforeEach(() => { 78 | process.env = Object.assign(process.env, { 79 | __NEXT_IMAGE_OPTS: { 80 | deviceSizes: [640, 1080, 1920], 81 | imageSizes: [16, 23, 48, 64, 96] 82 | } 83 | }); 84 | }); 85 | 86 | test('getImageDimensions returns the correct original dimensions', () => { 87 | const image = generateSanityImageSource(DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT); 88 | const dimensions = getImageDimensions(image.asset._ref); 89 | 90 | expect(dimensions).toEqual({ 91 | width: DEFAULT_IMAGE_WIDTH, 92 | height: DEFAULT_IMAGE_HEIGHT, 93 | aspectRatio: DEFAULT_IMAGE_WIDTH / DEFAULT_IMAGE_HEIGHT 94 | }); 95 | }); 96 | 97 | test('getCroppedDimensions returns the correct cropped dimensions', () => { 98 | const image = generateSanityImageObject( 99 | DEFAULT_IMAGE_WIDTH, 100 | DEFAULT_IMAGE_HEIGHT, 101 | DEFAULT_CROP, 102 | DEFAULT_HOTSPOT 103 | ); 104 | const imageDimensions = getImageDimensions(image.asset._ref); 105 | const croppedDimensions = getCroppedDimensions(image, imageDimensions); 106 | 107 | const expectedWidth = DEFAULT_IMAGE_WIDTH * 0.8; 108 | const expectedHeight = DEFAULT_IMAGE_HEIGHT * 0.8; 109 | 110 | expect(croppedDimensions).toEqual({ 111 | width: expectedWidth, 112 | height: expectedHeight, 113 | aspectRatio: expectedWidth / expectedHeight 114 | }); 115 | }); 116 | 117 | test('useNextSanityImage returns the correct results after initialization', () => { 118 | const image = generateSanityImageSource(DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT); 119 | const { result } = renderHook(() => useNextSanityImage(configuredSanityClient, image)); 120 | 121 | const expectedWidth = DEFAULT_IMAGE_WIDTH; 122 | 123 | expect(result.current).toEqual({ 124 | loader: expect.any(Function), 125 | src: generateSanityImageUrl(`?q=75&fit=clip&auto=format`), 126 | width: expectedWidth, 127 | height: Math.round(expectedWidth / DEFAULT_IMAGE_ASPECT_RATIO) 128 | }); 129 | }); 130 | 131 | test('useNextSanityImage returns adjusted dimensions for cropped images', () => { 132 | const image = generateSanityImageObject( 133 | DEFAULT_IMAGE_WIDTH, 134 | DEFAULT_IMAGE_HEIGHT, 135 | DEFAULT_CROP, 136 | DEFAULT_HOTSPOT 137 | ); 138 | const { result } = renderHook(() => useNextSanityImage(configuredSanityClient, image)); 139 | 140 | const croppedWidth = DEFAULT_IMAGE_WIDTH * 0.8; 141 | const croppedHeight = DEFAULT_IMAGE_HEIGHT * 0.8; 142 | const croppedAspectRatio = croppedWidth / croppedHeight; 143 | 144 | expect(result.current.width).toEqual(croppedWidth); 145 | expect(result.current.height).toEqual(Math.round(croppedWidth / croppedAspectRatio)); 146 | }); 147 | 148 | test('useNextSanityImage returns the correct results after initialization with a large image', () => { 149 | const width = DEFAULT_IMAGE_WIDTH * 2; 150 | const height = DEFAULT_IMAGE_HEIGHT * 2; 151 | 152 | const image = generateSanityImageSource(width, height); 153 | const { result } = renderHook(() => useNextSanityImage(configuredSanityClient, image)); 154 | 155 | const expectedWidth = width; 156 | 157 | expect(result.current).toEqual({ 158 | loader: expect.any(Function), 159 | src: generateSanityImageUrl(`?q=75&fit=clip&auto=format`, width, height), 160 | width: expectedWidth, 161 | height: Math.round(expectedWidth / DEFAULT_IMAGE_ASPECT_RATIO) 162 | }); 163 | }); 164 | 165 | test('useNextSanityImage returns the correct results using a custom imageBuilder', () => { 166 | const image = generateSanityImageSource(DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT); 167 | const width = 813; 168 | const imageBuilder = (imageUrlBuilder: ImageUrlBuilder) => { 169 | return imageUrlBuilder.width(width).blur(20).flipHorizontal().fit('crop').quality(20); 170 | }; 171 | 172 | const { result } = renderHook(() => 173 | useNextSanityImage(configuredSanityClient, image, { imageBuilder }) 174 | ); 175 | 176 | expect(result.current).toEqual({ 177 | loader: expect.any(Function), 178 | src: generateSanityImageUrl(`?flip=h&w=813&blur=20&q=20&fit=crop&auto=format`), 179 | width: width, 180 | height: Math.round(width / DEFAULT_IMAGE_ASPECT_RATIO) 181 | }); 182 | }); 183 | 184 | test('useNextSanityImage returns expected results from the loader callback', () => { 185 | const image = generateSanityImageSource(DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT); 186 | 187 | const { result } = renderHook(() => useNextSanityImage(configuredSanityClient, image)); 188 | 189 | const width = 300; 190 | const loaderResult = result.current.loader({ src: '', width }); 191 | 192 | expect(loaderResult).toEqual( 193 | generateSanityImageUrl(`?w=${width}&q=75&fit=clip&auto=format`) 194 | ); 195 | }); 196 | 197 | test('useNextSanityImage works when the image object is initialized empty', () => { 198 | const { result } = renderHook(() => useNextSanityImage(configuredSanityClient, null)); 199 | 200 | expect(result.current).toBeNull(); 201 | }); 202 | 203 | test('useNextSanityImage can be used with a client configuration instead of an instantiated client', () => { 204 | const image = generateSanityImageSource(DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT); 205 | const { result } = renderHook(() => useNextSanityImage(sanityClientConfig, image)); 206 | 207 | const expectedWidth = DEFAULT_IMAGE_WIDTH; 208 | 209 | expect(result.current).toEqual({ 210 | loader: expect.any(Function), 211 | src: generateSanityImageUrl(`?q=75&fit=clip&auto=format`), 212 | width: expectedWidth, 213 | height: Math.round(expectedWidth / DEFAULT_IMAGE_ASPECT_RATIO) 214 | }); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "rootDir": "src", 7 | "outDir": "dist", 8 | "forceConsistentCasingInFileNames": true, 9 | "esModuleInterop": true, 10 | "jsx": "preserve", 11 | "lib": ["ESNext", "dom"], 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "noImplicitAny": true, 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "target": "esnext", 19 | "noEmit": true, 20 | "isolatedModules": true, 21 | "types": ["jest", "node"] 22 | }, 23 | "include": ["./src/*.ts", "./src/*.tsx"], 24 | "exclude": ["**/*.d.ts", "dist", "node_modules"] 25 | } 26 | --------------------------------------------------------------------------------