Storing Images and Markdown Files in One Folder in 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:
- Set up an
npm
script to run your code - Clear existing blog post images inside
public
- 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.
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:
- If your target directory doesn’t exist, it will create the folder structure of the given path
- 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:
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:
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:
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.