Skip to content

Storing Images and Markdown Files in One Folder in Next.js

Next.js/

As you may know, to serve assets such as images in Next.js, you have to place them inside the public directory. This is not ideal if you have a blog that takes its content from local Markdown files.

Because it means that you can’t store images and Markdown files in one folder like this:

posts/
├─ post-one/
│  ├─ image-one.png
│  ├─ image-two.png
│  ├─ index.md
├─ post-two/
│  ├─ image.png
│  ├─ index.md

There is a solution though. In this is a guide you will learn how you can keep content and its assets in one folder.

The Solution

To store images and Markdown files in the same folder, you need to create a script that moves images to public during build.

You will still need to reference images inside Markdown with an absolute path /path/to-image/inside-public.

Start by creating a file for your script in src/bin/copy-images.mjs.

I like to keep standalone executable scripts inside a bin folder, but you can store yours wherever you want.

The .mjs file extension allows you to use ECMAScript modules and top-level await in your code.

Now let’s take the following steps towards the goal:

  1. Set up an npm script to run your code
  2. Clear existing blog post images inside public
  3. Copy all blog post images to public

Step 1 — Setting up the Script

You need to add two scripts to your package.json.

{
  "scripts": {
    "copyimages": "node ./src/bin/copy-images.mjs",
    "prebuild": "npm run copyimages"
  }
}

The prebuild script will run before the build script every time. You can also use postbuild if you want to copy images after Next.js has finished building. It’s a matter of preference here.

You want to separate copyimages into its own script because then you can call it manually during development without running the entire build process.

You also want to add public/images to .gitignore so that your repository doesn’t contain duplicates of images after running copyimages script.

/public/images

Step 2 — Clearing Existing Images

When you run your script, first thing you want to do is delete existing blog post images from public. This way you won’t have any old images from blog posts that you have deleted.

Start by installing fs-extra package. It has a useful method to easily clean up folders.

npm i fs-extra

Next, open your copy-images.mjs file and add the following code.

src/bin/copy-images.mjs
import fs from 'fs';
import path from 'path';
import fsExtra from 'fs-extra';

const fsPromises = fs.promises;
const targetDir = './public/images/posts';
const postsDir = './content/posts';

await fsExtra.emptyDir(targetDir);

The targetDir variable specifies where you want to copy your post images, and the postsDir variable contains the location of your blog posts.

The emptyDir method ensures that the target directory is completely empty. It handles two scenarios:

  1. If your target directory doesn’t exist, it will create the folder structure of the given path
  2. If the directory exists, it will delete everything it contains

Step 3 — Creating a Folder for Each Post

If you want to avoid two images with the same name clashing, you need to store them in their own folders. Just like each blog post goes into its own folder, the images inside public should too.

You can achieve that with the following code:

src/bin/copy-images.mjs
async function createPostImageFoldersForCopy() {
  // Get every post folder: post-one, post-two etc.
  const postSlugs = await fsPromises.readdir(postsDir);

  for (const slug of postSlugs) {
    const allowedImageFileExtensions = ['.png', '.jpg', '.jpeg', '.gif'];

    // Read all files inside current post folder
    const postDirFiles = await fsPromises.readdir(`${postsDir}/${slug}`);

    // Filter out files with allowed file extension (images)
    const images = postDirFiles.filter(file =>
      allowedImageFileExtensions.includes(path.extname(file)),
    );

    if (images.length) {
      // Create a folder for images of this post inside public
      await fsPromises.mkdir(`${targetDir}/${slug}`);

      await copyImagesToPublic(images, slug); // TODO
    }
  }
}

Last thing left is to actually copy the images from their blog post folders to public with copyImagesToPublic function.

Final Step — Copying Images to Public

Add the following code to your copy-images.mjs file:

src/bin/copy-images.mjs
async function copyImagesToPublic(images, slug) {
  for (const image of images) {
    await fsPromises.copyFile(
      `${postsDir}/${slug}/${image}`,
      `${targetDir}/${slug}/${image}`
    );
  }
}

It goes over all given images from a specific blog post and copies them into their own folder inside public.

Lastly, call the findAndCopyPostImages function in your script.

The final code should look like this:

src/bin/copy-images.mjs
import fs from 'fs';
import path from 'path';
import fsExtra from 'fs-extra';

const fsPromises = fs.promises;
const targetDir = './public/images';
const postsDir = './posts';

async function copyImagesToPublic(images, slug) {
  for (const image of images) {
    await fsPromises.copyFile(
      `${postsDir}/${slug}/${image}`,
      `${targetDir}/${slug}/${image}`
    );
  }
}

async function createPostImageFoldersForCopy() {
  // Get every post folder: post-one, post-two etc.
  const postSlugs = await fsPromises.readdir(postsDir);

  for (const slug of postSlugs) {
    const allowedImageFileExtensions = ['.png', '.jpg', '.jpeg', '.gif'];

    // Read all files inside current post folder
    const postDirFiles = await fsPromises.readdir(`${postsDir}/${slug}`);

    // Filter out files with allowed file extension (images)
    const images = postDirFiles.filter(file =>
      allowedImageFileExtensions.includes(path.extname(file)),
    );

    if (images.length) {
      // Create a folder for images of this post inside public
      await fsPromises.mkdir(`${targetDir}/${slug}`);

      await copyImagesToPublic(images, slug);
    }
  }
}

await fsExtra.emptyDir(targetDir);
await createPostImageFoldersForCopy();

Running npm run copyimages or npm run build should create the following file structure in your project:

content/
├─ posts/
│  ├─ post-one/
│  │  ├─ image-one.png
│  │  ├─ image-two.png
│  │  ├─ index.md
│  ├─ post-two/
│  │  ├─ image.png
│  │  ├─ index.md
public/
├─ images/
│  ├─ post-one/
│  │  ├─ image-one.png
│  │  ├─ image-two.png
│  ├─ post-two/
│  │  ├─ image.png

And that’s how you can keep images right next to Markdown files in Next.js.