next.js: Image component does not work with Storybook

Bug report

Describe the bug

using next/image component in storybook throw Error.

To Reproduce

  1. Set up with-storybook example yarn create next-app --example with-storybook with-storybook-app
  2. Create new story using next/image
import Image from 'next/image'
import React from 'react'

export default { title: 'Image' }
// image url
const url = 'https://......'

export const withNextImage = () => (
  <Image src={url} width={100} height={100} />
)
  1. Start storybook

Expected behavior

show Image without Error as plane image tag do.

Screenshots

image

System information

  • OS: macOS
  • Browser Chrome
  • Version of Next.js: 10.0.0
  • Version of Node.js: v12.16.3

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 52
  • Comments: 46 (10 by maintainers)

Commits related to this issue

Most upvoted comments

By using both @ChoSeoHwan and @Erazihel’s answers, you can render the html from the real next/image by adding unoptimized

.storybook/preview.js

import * as NextImage from 'next/image';

const OriginalNextImage = NextImage.default;
Object.defineProperty(NextImage, 'default', {
  configurable: true,
  value: (props) => <OriginalNextImage {...props} unoptimized />,
});

The images still have to be served by storybook:

Using @JosBroers answer and link, I struggled a bit to make it work.

I finally succeeded with the help of the Storybook’s documentation by modifying my start-storybook script:

{
    "scripts": {
        "start-storybook": "start-storybook -s ./public -p 9001"
    }
}

Using @JosBroers answer and link, I struggled a bit to make it work.

I finally succeeded with the help of the Storybook’s documentation by modifying my start-storybook script:

{
    "scripts": {
        "start-storybook": "start-storybook -s ./public -p 9001"
    }
}

I’ve also simplified the modification of the .storybook/preview.js file:

import * as nextImage from "next/image"

Object.defineProperty(nextImage, "default", {
  configurable: true,
  value: props => <img {...props} />
})

There’s a simpler workaround which is to override next/image. It replaces this component with a responsive image in all stories. Found this solution here.

Add following code in .storybook/preview to make it work.

import * as nextImage from "next/image"

Object.defineProperty(nextImage, "default", {
  configurable: true,
  value: props => {
    const { width, height } = props
    const ratio = (height / width) * 100
    return (
      <div
        style={{
          paddingBottom: `${ratio}%`,
          position: "relative",
        }}
      >
        <img
          style={{
            objectFit: "cover",
            position: "absolute",
            minWidth: "100%",
            minHeight: "100%",
            maxWidth: "100%",
            maxHeight: "100%",
          }}
          {...props}
        />
      </div>
    )
  },
})

If you prefer to render the same output as next/image would, I’ve created the following code based on the Image Component:

Object.defineProperty(nextImage, "default", {
  configurable: true,
  value: props => {
    const height = props.height
    const width = props.width
    const quotient = height / width
    const paddingTop = isNaN(quotient) ? "100%" : `${quotient * 100}%`
    let wrapperStyle
    let sizerStyle
    let sizerSvg
    let toBase64
    let imgStyle = {
      position: "absolute",
      top: 0,
      left: 0,
      bottom: 0,
      right: 0,
      boxSizing: "border-box",
      padding: 0,
      border: "none",
      margin: "auto",
      display: "block",
      width: 0,
      height: 0,
      minWidth: "100%",
      maxWidth: "100%",
      minHeight: "100%",
      maxHeight: "100%",
      objectFit: props.objectFit ? props.objectFit : undefined,
      objectPosition: props.objectPosition ? props.objectPosition : undefined,
    }

    if (width !== undefined && height !== undefined && props.layout !== "fill") {
      if (props.layout === "responsive") {
        wrapperStyle = {
          display: "block",
          overflow: "hidden",
          position: "relative",
          boxSizing: "border-box",
          margin: 0,
        }
        sizerStyle = {
          display: "block",
          boxSizing: "border-box",
          paddingTop,
        }
      } else if (props.layout === "intrinsic" || props.layout === undefined) {
        wrapperStyle = {
          display: "inline-block",
          maxWidth: "100%",
          overflow: "hidden",
          position: "relative",
          boxSizing: "border-box",
          margin: 0,
        }
        sizerStyle = {
          boxSizing: "border-box",
          display: "block",
          maxWidth: "100%",
        }
        sizerSvg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg" version="1.1"/>`
        toBase64 = Buffer.from(sizerSvg).toString("base64")
      } else if (props.layout === "fixed") {
        wrapperStyle = {
          overflow: "hidden",
          boxSizing: "border-box",
          display: "inline-block",
          position: "relative",
          width,
          height,
        }
      }
    } else if (width === undefined && height === undefined && props.layout === "fill") {
      wrapperStyle = {
        display: "block",
        overflow: "hidden",
        position: "absolute",
        top: 0,
        left: 0,
        bottom: 0,
        right: 0,
        boxSizing: "border-box",
        margin: 0,
      }
    } else {
      throw new Error(
        `Image with src "${props.src}" must use "width" and "height" properties or "layout='fill'" property.`,
      )
    }

    return (
      <div style={wrapperStyle}>
        {sizerStyle ? (
          <div style={sizerStyle}>
            {sizerSvg ? (
              <img
                style={{ maxWidth: "100%", display: "block" }}
                alt={props.alt}
                aria-hidden={true}
                role="presentation"
                src={`data:image/svg+xml;base64,${toBase64}`}
              />
            ) : null}
          </div>
        ) : null}
        <img {...props} decoding="async" style={imgStyle} />
      </div>
    )
  },
})

Is there any workaround for this?

The import is fixed when adding @next/plugin-storybook like in PR #18367.

However, this only solves the problem for 3rd party loaders like imgix, cloudinary, etc.

For the default loader, we still need a way to expose the API at /_next/image such that it can be used from a different port.

For example, storybook is running on 6006 and next dev is running on 3000, so we need to change the src to something like http://localhost:3000/_next/image or else the images aren’t loaded by the browser when visiting http://localhost:6006.

I got an error in Storybook when trying to use next.js Image with src using another host. I have domains set properly in my next.js config and dev servers works without errors. image

I got an error in Storybook when trying to use next.js Image with src using another host. I have domains set properly in my next.js config and dev servers works without errors. image

I have resolved this problem by add this code to .storybook/preview.js:

import * as nextImage from 'next/image';

Object.defineProperty(nextImage, 'default', {
  configurable: true,
  value: props => <img {...props} />
});

I found a solution, and it works fine in my environment.

I’ll share this solution.

  1. Add below option in “.storybook/main.js”
# main.js

module.exports = {
    // another options...,
    webpackFinal: async (config) => {
        // awesome code...

        config.plugins.push(new webpack.DefinePlugin({
            'process.env.__NEXT_IMAGE_OPTS': JSON.stringify({
                deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
                imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
                domains: [],
                path: '/',
                loader: 'default',
            }),
        }));
    }
} 

This option will change “_next/image” directory to “/public” directory

  1. use “next/image” component with “unoptimized” option in storybook
# Image.stories.tsx
// awesome code ...

export const Image: Story<ImageProps> = ({ src }) => 
    <Image src={src} layout="fill" objectFit="contain" unoptimized />;

I hope this solution will solve it.

@balazsorban44 Thanks for the shoutout!

I’m the creator and maintainer of that addon. The addon mentioned attempts to create a “zero config” experience to get nextjs to “just work” with storybook, next/image being one of the features that works out of the box. It actually draws inspiration from a solution proposed here to get that working! So, thanks to everyone in this thread that helped move this forward, especially @JCQuintas who proposed the aforementioned solution.

Side note: To avoid any confusion, this is a personal project of mine and it isn’t an official addon of storybook. I’m also not affiliated with Storybook, just someone who really likes the tools and wants to make other peoples’ lives better 😃

The following will keep the default behaviour of NextImage while allowing external urls or anything in the public folder to be statically loaded.

unoptimized is optional and is used so it doesn’t console.warn on the browser, but can be removed if you don’t care about it or need the optimisation features somehow.

// preview.tsx
import * as NextImage from 'next/image'

const OriginalNextImage = NextImage.default

Object.defineProperty(NextImage, 'default', {
  configurable: true,
  value: (props) => (
    <OriginalNextImage {...props} unoptimized loader={({ src }) => src} />
  ),
})

Doesnt seem to catch it for me. Perhaps i’m doing something wrong?

Uncaught TypeError: Cannot destructure property 'deviceSizes' of 'imageData' as it is undefined.

Also seeing this same issue despite trying all of the workarounds here.

I was seeing a similar error to this so I used a combination of the webpack and mocked module implementations above to achieve compatibility in both storybook & jest.

First, create a next/image mock component in your root __mocks__ folder -> __mocks__/next/image.js. Props to @JosBroers as I simply took his code and moved it into a mock file.

export const NextImageMock = ({ objectFit, objectPosition, ...props }) => {
    const height = props.height;
    const width = props.width;
    const quotient = height / width;
    const paddingTop = isNaN(quotient) ? '100%' : `${quotient * 100}%`;
    let wrapperStyle;
    let sizerStyle;
    let sizerSvg;
    let toBase64;
    let imgStyle = {
        position: 'absolute',
        top: 0,
        left: 0,
        bottom: 0,
        right: 0,
        boxSizing: 'border-box',
        padding: 0,
        border: 'none',
        margin: 'auto',
        display: 'block',
        width: 0,
        height: 0,
        minWidth: '100%',
        maxWidth: '100%',
        minHeight: '100%',
        maxHeight: '100%',
        objectFit,
        objectPosition,
    };

    if (width !== undefined && height !== undefined && props.layout !== 'fill') {
        if (props.layout === 'responsive') {
            wrapperStyle = {
                display: 'block',
                overflow: 'hidden',
                position: 'relative',
                boxSizing: 'border-box',
                margin: 0,
            };
            sizerStyle = {
                display: 'block',
                boxSizing: 'border-box',
                paddingTop,
            };
        } else if (props.layout === 'intrinsic' || props.layout === undefined) {
            wrapperStyle = {
                display: 'inline-block',
                maxWidth: '100%',
                overflow: 'hidden',
                position: 'relative',
                boxSizing: 'border-box',
                margin: 0,
            };
            sizerStyle = {
                boxSizing: 'border-box',
                display: 'block',
                maxWidth: '100%',
            };
            sizerSvg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg" version="1.1"/>`;
            toBase64 = Buffer.from(sizerSvg).toString('base64');
        } else if (props.layout === 'fixed') {
            wrapperStyle = {
                overflow: 'hidden',
                boxSizing: 'border-box',
                display: 'inline-block',
                position: 'relative',
                width,
                height,
            };
        }
    } else if (width === undefined && height === undefined && props.layout === 'fill') {
        wrapperStyle = {
            display: 'block',
            overflow: 'hidden',
            position: 'absolute',
            top: 0,
            left: 0,
            bottom: 0,
            right: 0,
            boxSizing: 'border-box',
            margin: 0,
        };
    } else {
        throw new Error(
            `Image with src "${props.src}" must use "width" and "height" properties or "layout='fill'" property.`,
        );
    }

    return (
        <div style={wrapperStyle}>
            {sizerStyle ? (
                <div style={sizerStyle}>
                    {sizerSvg ? (
                        <img
                            style={{ maxWidth: '100%', display: 'block' }}
                            alt={props.alt}
                            aria-hidden={true}
                            role="presentation"
                            src={`data:image/svg+xml;base64,${toBase64}`}
                        />
                    ) : null}
                </div>
            ) : null}
            <img {...props} decoding="async" style={imgStyle} />
        </div>
    );
};

export default NextImageMock;

Then in .storybook/main.js, use webpackFinal to push an instance of NormalModuleReplacementPlugin to the plugins array that replaces next/image with this mock

const path = require('path');
const webpack = require('webpack');

module.exports = {
    // ...
    webpackFinal: async (config) => {
        config.plugins.push(
            new webpack.NormalModuleReplacementPlugin(
                /next\/image/,
                path.resolve(__dirname, '../__mocks__/next/image.js'),
            ),
        );

        return config;
    },
};

Because the mock path matches the import path of next/image, jest will automatically use the mock inside of unit tests.

Hope this helps!

It would be great if the current official NextJS example would be extended with the use of next/image (https://github.com/vercel/next.js/tree/canary/examples/with-storybook) I stumbled across this issue as well and looking for a solution after migration to next/image

Hi everyone, Storybook just released a no config addon that amongst other things make next/imge work!

Check it out here https://storybook.js.org/addons/storybook-addon-next#supported-features

so I believe this can be closed for now?

team member (@kn0ll) came up with a really elegant solution we use at our company.

import NextBaseImage, { ImageProps } from 'next/image'

const StorybookNextImage: React.FC<ImageProps> = props => (
  <NextBaseImage
    {...props}
    loader={({ src }) => {
      return src
    }}
  />
)
export const NextImage = !isStorybook ? NextBaseImage : (StorybookNextImage as typeof NextBaseImage)

Object.defineProperty(nextImage, "default", {
  configurable: true,
  value: props => <img {...props} />
})

Not sure why but it led to infinite loading for me…

@JosBroers Thank you for this it works exactly as expected.

However, you have a tiny error in your Image Component code.

throw new Error(
  `Image with src "${src}" must use "width" and "height" properties or "layout='fill'" property.`,
)

That should be ${props.src}

Cheers!

Building on the previous ideas here, might be possible to hack around and get a default image W/H

.storybook/main.js

require('./main/compile-image-dimension');

.storybook/preview.js

import IMAGE_DIMENSION from './cache/image-dimension.json';

const UnoptimizedNextImage = NextImage.default;
Object.defineProperty(NextImage, 'default', {
  configurable: true,
  value: (props) => (
    <UnoptimizedNextImage
      {...IMAGE_DIMENSION[props.src]}
      {...props}
      unoptimized
      blurDataURL={props.placeholder === 'blur' ? props.src : undefined}
    />
  ),
});

The hackery: statically output size for every image of /public into a json, have it imported in preview.js to provide a default W/H .storybook/main/compile-image-dimension.js

const fs = require('fs');
const sizeOf = require('image-size');
const _ = require('lodash');
const path = require('path');

const getAllFiles = function (dirPath, arrayOfFiles) {
  files = fs.readdirSync(dirPath);

  arrayOfFiles = arrayOfFiles || [];

  files.forEach(function (file) {
    if (fs.statSync(dirPath + '/' + file).isDirectory()) {
      arrayOfFiles = getAllFiles(dirPath + '/' + file, arrayOfFiles);
    } else {
      arrayOfFiles.push(path.join(dirPath, '/', file).replace(/\\/g, '/'));
    }
  });

  return arrayOfFiles;
};

const suffixes = ['.png', '.jpg'];
const suffixFilter = function (filename) {
  return _.some(suffixes, (s) => filename.endsWith(s));
};

const compiledImageSizes = _(getAllFiles('./public'))
  .filter(suffixFilter)
  .map((url) => {
    const idx = url.lastIndexOf('.');
    // Mind the url is <path/basename>(dot)(dot)<extension>
    return {
      url: `static/media/${url.slice(0, idx)}.${url.slice(idx)}`,
      ...sizeOf(url),
    };
  })
  .keyBy('url')
  .mapValues(({ width, height }) => ({ width, height }))
  .value();

const data = JSON.stringify(compiledImageSizes);
fs.mkdirSync('.storybook/cache', { recursive: true });
fs.writeFileSync('.storybook/cache/image-dimension.json', data);

@RyanClementsHax Thank you for the great summary of what you’re using!

Yes, I meant that npm package. It’s published there but it’s actually the default image loader since Next.js 11. The problem with Storybook is if you’re using Next Image without any width, height or layout property. I guess it could be fine if we just included either layout or width/height in our project. I think the layout prop is being passed in usually anyway.

next-image-loader

when you say next-image-loader do you mean this npm package?

How are you handling the image importing in the Storybook webpack configuration?

I actually don’t touch any webpack configuration regarding static imports of images. I only configure webpack to handle css/scss/css module/postcss configuration. It seems that Storybook’s default webpack config already handles statically importing images as static paths rather than static objects, even though the later is what nextjs does.

Below is my .storybook/main.js file

/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path')

/**
 * @typedef {import('next').NextConfig} NextConfig
 * @typedef {import('webpack').Configuration} WebpackConfig
 */

module.exports = {
  stories: ['../**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    'storybook-addon-next-router',
    '@storybook/addon-a11y',
    '@storybook/addon-storysource',
    'storybook-dark-mode'
  ],
  core: {
    builder: 'webpack5'
  },
  /**
   * @param {WebpackConfig} baseConfig
   * @return {Promise<WebpackConfig>}
   */
  async webpackFinal(baseConfig) {
    const nextConfig = require('../next.config.js')([], baseConfig)

    configureRootAbsoluteImport(baseConfig)
    configureCss(baseConfig, nextConfig)

    return baseConfig
  }
}

/**
 * @param {WebpackConfig} baseConfig
 * @return {void}
 */
const configureRootAbsoluteImport = baseConfig => {
  baseConfig.resolve?.modules?.push(path.resolve(__dirname, '..'))
}

/**
 * @param {WebpackConfig} baseConfig
 * @param {NextConfig} nextConfig
 * @return {void}
 */
const configureCss = (baseConfig, nextConfig) => {
  baseConfig.module?.rules?.push({
    test: /\.(s*)css$/,
    use: [
      'style-loader',
      {
        loader: 'css-loader',
        options: {
          modules: { auto: true }
        }
      },
      {
        loader: 'postcss-loader'
      },
      {
        loader: 'sass-loader',
        options: {
          additionalData: nextConfig.sassOptions?.prependData
        }
      }
    ]
  })
}

Bellow is my next.config.js file

// I'd love to convert this to .mjs but typescript doesn't suport .mjs yet https://github.com/microsoft/TypeScript/issues/15416
/* eslint-disable @typescript-eslint/no-var-requires */

// @ts-expect-error: there are no typings for this module
const withPlugins = require('next-compose-plugins')
// @ts-expect-error: there are no typings for this module
const withBundleAnalyzer = require('@next/bundle-analyzer')

/**
 * @type {import('next').NextConfig}
 **/
const config = {
  experimental: { esmExternals: true }
}

module.exports = withPlugins(
  [
    withBundleAnalyzer({
      enabled: process.env.ANALYZE === 'true'
    })
  ],
  config
)

This is the page that statically imports the image

import { Layout } from 'components/landing/Layout'
import Head from 'next/head'
import Image from 'next/image'
import banner from 'public/banner.jpg'

export const Index: React.FC = () => {
  return (
    <Layout>
      <Head>
        <title>Ryan Clements</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <section className="h-screen w-100 p-8">
        <div className="h-full grid gap-4 content-center md:container md:mx-auto md:grid-cols-2 lg:grid-cols-5">
          <div className="grid content-center lg:col-span-2">
            <h1 className="text-4xl font-bold mb-4">
              Hiya 👋
              <br />
              I’m Ryan Clements
            </h1>
            <h2 className="text-2xl text-gray-600">
              I 💖 God, my wife and daughter&nbsp;👨‍👩‍👧, and making dope
              software&nbsp;👨‍💻
            </h2>
          </div>
          <div className="relative h-[500px] hidden md:block shadow-md lg:col-span-3">
            <Image
              src={banner}
              layout="fill"
              objectFit="cover"
              objectPosition="center"
              placeholder="blur"
              priority
              alt="My wife, me, and our wedding party being silly"
            />
          </div>
        </div>
      </section>
    </Layout>
  )
}

export default Index

It should be noted that I’m using Storybook at version 6.x.x and webpack 5 for the manger/builder. I’m also on nextjs 12, but my next/image solution with storybook was also working on nextjs 11 (although I wasn’t using placeholder='blur' yet when I was on 11).

All of this code is from my personal website if you want to go dig around more.

  • This commit is when I was using nextjs 11
  • This commit contains all of the upgrades I did (including upgrading to nextjs 12)
  • This a commit on the branch I’m working on that has the placeholder='blur' code

I am running into similar problems trying to use storybook with next/image. Not only did I run into the Failed to parse src "static/media/public/banner..jpg" on 'next/image', if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://) which was fixed for me by defining unoptimized on the next/image default export, but I also ran into the Image with src "static/media/public/banner..jpg" has "placeholder='blur'" property but is missing the "blurDataURL" property. when I tried to use next/image with placeholder='blur'.

For me, I was able to solve this by putting the following code in .storybook/preview.js.

// other code omitted for brevity
import * as NextImage from 'next/image'

const OriginalNextImage = NextImage.default

Object.defineProperty(NextImage, 'default', {
  configurable: true,
  value: props => (
    <OriginalNextImage {...props} unoptimized blurDataURL={props.src} />
  )
})

If you are looking for a more type safe solution (but probably not necessary):

  1. put .storybook/preview.js in your tsconfig.json’s include array since glob patterns automatically ignore dotfiles https://github.com/microsoft/TypeScript/issues/13399 (eslint ignores .storybook too btw https://github.com/storybookjs/storybook/issues/295)
  2. handle the case that props.src is of the StaticImport type (the type that next js constructs for static imports at build time)
// other code omitted for brevity
import * as NextImage from 'next/image'

const OriginalNextImage = NextImage.default

// eslint-disable-next-line no-import-assign
Object.defineProperty(NextImage, 'default', {
  configurable: true,
  value: (/** @type {import('next/image').ImageProps} */ props) => {
    if (typeof props.src === 'string') {
      return (
        <OriginalNextImage {...props} unoptimized blurDataURL={props.src} />
      )
    } else {
      // don't need blurDataURL here since it is already defined on the StaticImport type
      return <OriginalNextImage {...props} unoptimized />
    }
  }
})

I hope this helps

we set it at runtime in the environment. something like ENV=storybook yarn storybook then const isStorybook = process.env.ENV === 'storybook'. the idea here is basically just “if you are in storybook, use a loader that does not transform the original URL”. it could be useful outside of storybook though, for something like a static build where you’re not running the Next image proxy.

Doesnt seem to catch it for me. Perhaps i’m doing something wrong?

Uncaught TypeError: Cannot destructure property 'deviceSizes' of 'imageData' as it is undefined.

Also seeing this same issue despite trying all of the workarounds here.

@JosBroers Worked for me! Thanks so much. I just had to change props.layout === "intrinsic" to props.layout === "intrinsic" || props.layout === undefined since layout is not required and defaults to “intrinsic”.

@aippili-asp I believe I have a workaround.

Is there any workaround for this?

What I did was the following:

  1. Add a file .storybook/middleware.js. (Mind you, they don’t support a .ts version)
const { createProxyMiddleware } = require('http-proxy-middleware');

const expressMiddleWare = (router) => {
  router.use('/_next/image', createProxyMiddleware({ target: 'http://localhost:{ NEXTJS_PORT}', changeOrigin: true }));
};

module.exports = expressMiddleWare;

  1. Don’t forget to update the port.
  2. I believe you could also do something for production values if needed.

@SZharkov yeah im seeing that too sadly.