nuxt: Assets with dynamic names are not resolved

Environment

  • Operating System: Linux
  • Node Version: v16.14.2
  • Nuxt Version: 3.0.0-rc.8
  • Package Manager: npm@7.17.0
  • Builder: vite
  • User Config: -
  • Runtime Modules: -
  • Build Modules: -

Reproduction

https://stackblitz.com/edit/github-pq8nym?file=app.vue

Describe the bug

If you have an image referencing a dynamic asset, e.g.

<template>
  <img :src="`~/assets/${dynamic_image_name}`" alt="Discover Nuxt 3" />
</template>
<script setup lang="ts">
const dynamic_image_name = 'zero-config.svg';
</script>

then this is rendered as

<img src="~/assets/zero-config.svg" alt="Discover Nuxt 3">

without correctly resolving (and copying) the image, thus it doesn’t show in the browser.

Additional context

Refs https://github.com/nuxt/framework/pull/6635.

Logs

No response

About this issue

  • Original URL
  • State: open
  • Created 2 years ago
  • Reactions: 55
  • Comments: 76 (19 by maintainers)

Most upvoted comments

Thanks @danielroe, this works like a charm! Do you think it makes sense to add the following helper method as a built-in composable?

function useAsset(path: string): string {
  const assets = import.meta.glob('~/assets/**/*', {
    eager: true,
    import: 'default',
  })
  // @ts-expect-error: wrong type info
  return assets['/assets/' + path]
}

//Usage: <img :src="useAsset(dynamic_image_name + '.svg')" alt="Discover Nuxt 3" />

Is this the only way to get dynamic images in Nuxt 3? Was super easy in Nuxt 2 with require.

I believe this is vite and @vitejs/plugin-vue behaviour. It’s a bit complicated, but you can do this with import.meta.glob:

https://stackblitz.com/edit/github-pq8nym-aoavsh

<template>
  <img :src="images[dynamic_image_name]" alt="Discover Nuxt 3" />
</template>
<script setup lang="ts">
import { filename } from 'pathe/utils';

const glob = import.meta.glob('~/assets/*.svg', { eager: true });
const images = Object.fromEntries(
  Object.entries(glob).map(([key, value]) => [filename(key), value.default])
);

const dynamic_image_name = 'zero-config';
</script>

Wait for the official to provide a better solution.😅😅

i use this technique and it works with me just fine

new URL(`../assets/images/${props.image}`,import.meta.url).href

For dynamic image use this

<img :src="`/_nuxt/assets/images/${img.src}`" alt="image"/>

Note: define your directory properly, my image are saved in images folder.

It work for me 😃

Another way to do it is just use regular imports:

<template>
  <img :src="img.default" alt="Discover Nuxt 3" />
</template>
<script setup lang="ts">
const img = await import(`~/assets/${fileName}.svg`);
</script>

As great as Nuxt3 already, hurdles like this where Nuxt2 simply was better, are frustrating. I also liked to get img dimensions using require.

@danielroe does the core team have ideas on how to support these usecases or is this not a concern for the foreseeable future?

I suggest mentioning things missing from Nuxt2 to the migration guide, so people are aware of the current limitations: https://nuxt.com/docs/migration/overview

@Umer-Farooq10 this will break in prod

Interesting how we, with every “progress/update”, make the harder things simpler and the once simpler things harder. 🙂 Nuxt 2 was beautiful.

Finding it really frustrating to work with images, this needs to be properly addressed in the documentation: making it very clear that it is not possible to using images with dynamic names unless import.meta.glob is used.

It is very sad, that the best solution for using dynamic assets with Vite is not to use dynamic assets. After years with Webpack, optimizing images by the bundler seems like a basic task, but not with Vite

I’ve written a blog post about that topic recently, but the easiest way would be using the public folder unless you have a good reason not to. In this case, using the import.meta.glob solution from Daniel would be the best way. But loading assets that way is less performant than via public folder.

Putting “_nuxt” at the start of the image link worked for me. Like this:

<img :src="'_nuxt/assets/' + image" /> This also worked for images in public.

I managed to solve this by returning a new URL using a computed function.

Store the path for your image in imagePath ref.

<script setup>
  const imgUrl = computed(() => {
    return new URL(imagePath.value, import.meta.url).href;
  });
</script>

<template>
  <img :src="imgUrl" />
<template/>

For more details check https://vitejs.dev/guide/assets.html#new-url-url-import-meta-url

@sami-baadarani Thanks for providing the vite link for more details. Something to keep in mind with this approach: from the docs it states that:

Does not work with SSR

This pattern does not work if you are using Vite for Server-Side Rendering, because import.meta.url have different semantics in browsers vs. Node.js. The server bundle also cannot determine the client host URL ahead of time.

+1 - I use the require() method to build 500 static pages using about 600 images which are inserted dynamically, a solution to this will be very much appreciated

Thanks @danielroe, this works like a charm! Do you think it makes sense to add the following helper method as a built-in composable?

function useAsset(path: string): string {
  const assets = import.meta.glob('~/assets/**/*', {
    eager: true,
    import: 'default',
  })
  // @ts-expect-error: wrong type info
  return assets['/assets/' + path]
}

//Usage: <img :src="useAsset(dynamic_image_name + '.svg')" alt="Discover Nuxt 3" />

This work for me. I use it as a Composables. Thank you 😃

I created a temporary composable function while waiting for an official solution.

It uses the above solution with good types as much as possible (I didn’t succeed to have the correct Module returned by import.meta.glob function).

import { filename } from "pathe/utils";
import { computed } from "#build/imports";

interface ImagesComposable {
  getImageSrc: (fileName: string) => string | undefined;
}

function useImages(): ImagesComposable {
  // TODO : replace the first parameter of the glob function according to your needs, I needed to import only png and jpeg from the images directory.
  const images = computed(() => import.meta.glob("~/assets/images/*.(png|jpeg)", { eager: true }));

  const getImageSrc = (fileName: string): string | undefined => {
    for (const path in images.value) {
      if (Object.hasOwn(images.value, path)) {
        // unknown type is required here to change the final type as typescript thinks that images.value[path] is a function, it is not.
        const image: unknown = images.value[path];
        const imagePath = (image as { default: string }).default;
        if (filename(imagePath) === filename(fileName)) {
          return imagePath;
        }
      }
    }
    return undefined;
  };
  return { getImageSrc };
}

export { useImages };

// USAGE => const src = getImageSrc("test.png")

Hi! You can use src without ‘~/assets’ if your remove image from assets to public directory <img :src="/${dynamic_image_name}" alt="Discover Nuxt 3" /> It works for me 😃 try it too image

is there any update on this? Anything official from the Nuxt team maybe? @danielroe?

When there are many images, this approach will significantly increase the initial loading size of JS files. It is urgent to find a way to import only specified resources.

@Werhww Now try to deploy 😉

Thanks @danielroe, this works like a charm! Do you think it makes sense to add the following helper method as a built-in composable?

function useAsset(path: string): string {
  const assets = import.meta.glob('~/assets/**/*', {
    eager: true,
    import: 'default',
  })
  // @ts-expect-error: wrong type info
  return assets['/assets/' + path]
}

//Usage: <img :src="useAsset(dynamic_image_name + '.svg')" alt="Discover Nuxt 3" />

Sir, I love you ❤️❤️. After literal weeks of suffering I can finally display images from the assets folder.

Someone really should put that in every Documentation out there!

  1. I would highly recommend use with file extension, as the glob with eager will end up including content of all imports within your build. Probably safe with images (includes just files) but you wouldn’t want to accidentally include anything else. (CSS files, would get included not as filenames but as actual CSS.)

  2. As for a composable, it’s an interesting idea. But I’m very cautious. I wonder if it would tree-shake out properly if not used.

can’t believe this doesn’t work

Is it fair to say that using Public folder is the best solution right now ?

Quite so. If you prefer the previous behaviour, you can of course use the Nuxt 3 webpack builder. The difference in behaviour you are referring to is the difference between webpack and vite, not between Nuxt 3 and Nuxt 2.

Thanks @danielroe, this works like a charm! Do you think it makes sense to add the following helper method as a built-in composable?

function useAsset(path: string): string {
  const assets = import.meta.glob('~/assets/**/*', {
    eager: true,
    import: 'default',
  })
  // @ts-expect-error: wrong type info
  return assets['/assets/' + path]
}

//Usage: <img :src="useAsset(dynamic_image_name + '.svg')" alt="Discover Nuxt 3" />

For client-side render this will add all imported files into head tag with prefetch mode, which will force user to load all of the assets.

Also it will put all import\resolve references into a file which can become big very quickly if you have a lot of assets.

I managed to solve this by returning a new URL using a computed function.

Store the path for your image in imagePath ref.

<script setup>
  const imgUrl = computed(() => {
    return new URL(imagePath.value, import.meta.url).href;
  });
</script>

<template>
  <img :src="imgUrl" />
<template/>

For more details check https://vitejs.dev/guide/assets.html#new-url-url-import-meta-url

Is this the only way to get dynamic images in Nuxt 3? Was super easy in Nuxt 2 with require.

I think having some way to improve DX and fetch dynamic images easier would be helpful indeed ☺️

I think i just found an easy solution for this problem. I used <NuxtImg> for dynamic images. It worked both during development and in my production build

[!NOTE] <NuxtImg> by default looks up files in public directory, in my case directory looked like this public > images

<NuxtImg :src="'images/' + yourImage" />

Thanks to alternative solutions by @danielroe and @tobiasdiez

I ended up with following solution.

  1. Create useAssets to composables which has three functions
    • useAssets(path: string) to resolve the image path
    • getImages to get all available images, svg png jpg and jpeg, in assets dir.
// composables/useAssets.ts
import { filename } from 'pathe/utils'
export default function useAssets(path: string) {
    let assets
    if (/(\.svg)$/i.exec(path)) assets = import.meta.glob('~/assets/img/**/*.svg', { eager: true })
    else if (/(\.png)$/i.exec(path)) assets = import.meta.glob('~/assets/img/**/*.png', { eager: true })
    else assets = import.meta.glob('~/assets/img/**/*.jpg', { eager: true })
    const fileName = filename(path)
    const images = Object.fromEntries(
        Object.entries(assets).map(([key, value]) => [filename(key), (value as Record<string, any>).default]),
    )
    return images[fileName]
}

export const getImages = () => {
    const assets = import.meta.glob('~/assets/img/**/*.(svg|png|jpg|jpeg)', { eager: true })
    return Object.fromEntries(
        Object.entries(assets).map(([key, value]) => [filename(key), (value as Record<string, any>).default]),
    )
}

  1. Usage
  • useAssets We can input just only image name without any relative path. The function will handle the rest for us. For example, the image.png is located in 'assets/img/image.png'. We can just put 'image.png' in the useAssets function.
<template>
    <img :src="useAssets('image.png')" alt="Item Top Tab" />
</template>
<script lang="ts" setup>
    import { useAssets } from '~/composables/useAssets'
</script>
  • getImages To get list of the image and use as variable Assume that we have image1.png and image2.png under '/assets/img' dir.
<template>
    <img :src="image1" alt=" image 1" />
    <img :src="image2" alt=" image 2" />
</template>
<script lang="ts" setup>
import { getImages } from '~/composables/useAssets'
const { image1,  image2 } = getImages()
</script>

I believe this is vite and @vitejs/plugin-vue behaviour. It’s a bit complicated, but you can do this with import.meta.glob:

https://stackblitz.com/edit/github-pq8nym-aoavsh

<template>
  <img :src="images[dynamic_image_name]" alt="Discover Nuxt 3" />
</template>
<script setup lang="ts">
import { filename } from 'pathe/utils';

const glob = import.meta.glob('~/assets/*.svg', { eager: true });
const images = Object.fromEntries(
  Object.entries(glob).map(([key, value]) => [filename(key), value.default])
);

const dynamic_image_name = 'zero-config';
</script>

Can someone explain this solution a little ? What is the point of this import ? import { filename } from 'pathe/utils';

@netopolit

I also believe this is a Vite limitation, and the issue should be raised in vitejs/vite repository. Please correct me if I’m wrong.

Yes, but the Vite team closes such issues since this limitation is “by design”. You should have significant weight in the community to be heard by Vite team

Is there a way from a module to inject build assets? I have a module that needs to inject svg icons to be resolved by vite-svg-loader in the target app, but couldn’t find anything on nuxt or nitro config

@miixel2 This works fine for me. If it’s not working for you, please raise an issue with a reproduction 🙏

So the workaround is to move the files to the assets folder and use Daniels solution?

@BorisKamp Well, you have two options (also explained here):

  • Use the public folder (recommended and easier)
  • Or use the assets folder + use the solution daniel provided if necessary

Thank you for your reply but that’s the whole point: I am using the public folder, see my comment up here

So the workaround is to move the files to the assets folder and use Daniels solution?

@BorisKamp Well, you have two options (also explained here):

  • Use the public folder (recommended and easier)
  • Or use the assets folder + use the solution daniel provided if necessary

@joelee1992 There is, as Daniel pointed out in #14766 (comment) 😊

So the workaround is to move the files to the assets folder and use Daniels solution?

I solved it by using the following code:

<template>
  <span class="svg-icon-ctr" :class="[name, cls]" v-html="icon"></span>
</template>

<script setup>
const props = defineProps({
  name: { type: String },
  cls: { type: String, default: "" },
});

// Auto-load icons
const icons = Object.fromEntries(
  Object.entries(import.meta.glob("~/assets/svg/*.svg", { as: "raw" })).map(
    ([key, value]) => {
      const filename = key.split("/").pop().split(".").shift();
      return [filename, value];
    }
  )
);

// Lazily load the icon
const icon = props.name && (await icons?.[props.name]?.());
</script>

For reference, in Nuxt 2 this component worked like this:

<template>
  <span class="svg-icon-ctr" :class="[name, cls]" v-html="require(`~/assets/svg/${name}.svg?raw`)"></span>
</template>

<script>
  export default {
    props: {
      name: { type: String },
      cls: { type: String, default: "" },
    }
  };
</script>

Also interested in how to address this.

@IJsLauw #nuxt-layer-base is an alias for my nuxt layer. If you don’t need to use layer, you can simply use ~ (alias for project root).

@szulcus Yeah, noticed that. Don’t do what I did.

Yeah, you’re right, technically, in the end, it’s a choice of default/out-of-the-box build/dev tools. Not to downplay the benefits, of course. It’s just devs wanting to avoid an “extra” build setup, however minute.

I created a temporary composable function while waiting for an official solution.

It uses the above solution with good types as much as possible (I didn’t succeed to have the correct Module returned by import.meta.glob function).

import { filename } from "pathe/utils";
import { computed } from "#build/imports";

interface ImagesComposable {
  getImageSrc: (fileName: string) => string | undefined;
}

function useImages(): ImagesComposable {
  // TODO : replace the first parameter of the glob function according to your needs, I needed to import only png and jpeg from the images directory.
  const images = computed(() => import.meta.glob("~/assets/images/*.(png|jpeg)", { eager: true }));

  const getImageSrc = (fileName: string): string | undefined => {
    for (const path in images.value) {
      if (Object.hasOwn(images.value, path)) {
        // unknown type is required here to change the final type as typescript thinks that images.value[path] is a function, it is not.
        const image: unknown = images.value[path];
        const imagePath = (image as { default: string }).default;
        if (filename(imagePath) === filename(fileName)) {
          return imagePath;
        }
      }
    }
    return undefined;
  };
  return { getImageSrc };
}

export { useImages };

// USAGE => const src = getImageSrc("test.png")

Your function unfortunately doesn’t distinguish between files with the same name that are nested in different folders. I changed it up a bit, but thanks for the inspiration!

const useAssets = () => {
	// Images
	const images = computed<Record<string, { default: string }>>(() => import.meta.glob('#nuxt-layer-base/assets/images/**/*.(png|jpeg|svg)', { eager: true }));
	const getImage = (src: string): string | undefined => {
		for (const path in images.value) {
			const image = images.value[path].default;
			if (path.endsWith(`assets/images/${src}`)) return image;
		}
		return undefined;
	};

	return {
		getImage,
	};
};

export default useAssets;

I also did autocomplete. Maybe someone will like it: In nuxt.config.ts:

import fs from 'fs';
import logSymbols from 'log-symbols';
import { createResolver } from '@nuxt/kit';

const { resolve } = createResolver(import.meta.url);

export default defineNuxtConfig({
	// ...
	hooks: {
		ready: () => {
			try {
				// Inspired by: https://stackoverflow.com/a/71166133/19957693
				const walk = (dirPath: string): string[] => {
					return fs.readdirSync(dirPath, { withFileTypes: true }).map((entry: fs.Dirent) => {
						const childPath = `${dirPath}/${entry.name}`;
						return entry.isDirectory() ? walk(childPath) : childPath;
					}).flat();
				};
				// Images hints
				const imagesPath = resolve('./assets/images');
				const pathHints = walk(imagesPath).map((path) => path.slice(imagesPath.length + 1));
				fs.writeFileSync(
					resolve(`${process.cwd()}/.nuxt/nuxt-layer-base.d.ts`),
					`export type AssetsImagePath = '${pathHints.join('\' | \'')}';\n`,
				);
				console.log(logSymbols.success, '#nuxt-layer-base types generated');
			}
			catch (err) {
				console.error(logSymbols.error, err);
			}
		},
	},
});

In *.{ts,vue}:

import { AssetsImagePath } from '#nuxt-layer-base';

Surprisingly that even in official document addressed we can use following syntax but it doesn’t work.

<template>
  <img src="~/assets/img/nuxt.png" alt="Discover Nuxt 3" />
</template>

Ref: https://nuxt.com/docs/getting-started/assets

@netopolit Indeed they will create chunks for all files in that directory. But there’s no other way to handle fully dynamic imports, because the bundler has to include everything in chunks because it doesn’t know what variable you will pass at runtime.

I can’t explain why, but this fn not working

const getImg = (img) => { return new URL('./../assets/images/useful/' + img + '.png', import.meta.url).href }

and this one is working for me O_o :

const getImg = (img) => { const link = './../assets/images/useful/' + img + '.png' return new URL(link, import.meta.url).href }

This works for me.

// ImageView.vue

<script setup lang="ts">
const props = defineProps<{
  src: string;
  alt: string;
}>();

function getImageUrl(path: string) {
  const pathArr = path.split('.');
  if (pathArr.length < 2) return undefined;

  const url = `../assets/images${pathArr[0]}.${pathArr[1]}`;  // change the path

  return new URL(url, import.meta.url).href;
}
</script>

<template>
  <img :src="getImageUrl(props.src)" :alt="props.alt" />
</template>

Use like below:

<ImageView
   :src="`/carriers/${carrier}.svg`"  // provide the path excluding initial
   :alt="carrier"
   class="h-6 w-6 flex-shrink-0 rounded-full"
/>

The solution from @antoinezanardi worked well for me (thank you!) but only in dev. With generate the path gets a hash appended to the filename so the files aren’t found anymore.

So to make it work in both dev and prod I had to change the condition from

if (filename(imagePath) === filename(fileName))

to

const regex = new RegExp('^' + filename(fileName) + '(?:\\.[a-zA-Z0-9]+)?$');
if (regex.test(filename(imagePath)))

You have to be a bit careful if your filenames contain a period though, there is a chance it’ll get mistakenly matched. And if for some reason the hash gets appended differently this’ll break as well, so it’s really just a workaround.

Also FYI, there is another caveat - when a buildAssetDir is set that is called /assets/ , the approach above will break.