Add an image uploader to a file-based content site

by Sean C Davis

Working with a git-based content workflow can be a powerful way to manage content for a site. But sometimes you need to add an image to a blog post or other content. Managing images in a git-based workflow either means adding another service or needing to be super diligent about managing images in your repository.

#TL;DR

This guide will show you how to add an image uploader to your Astro site to make it easy to manage images in your content. The uploader is only present in local development, and enforces a repeatable structure for storing images in your repository which surfaces when you serve your site in production.

Although this is specific to Astro, the concepts could be applied to other site framework where components are supported within your markdown content.

#JFDI (just deploy it)

If you’d prefer to just clone and deploy an example to explore, click the button below and you’ll very quickly have an example Astro site when an uploader ready to use in local development.

Deploy to Netlify

#Set up the project

If you already have a project ready to go, skip to the next section.

Let’s create a new Astro project.

Be sure to choose Use blog template when prompted on where to start. The blog template will have what we need to build out the image uploader.

Terminal window
> npm create astro@latest
Where should we create your new project? [default]
How would you like to start your new project? Use blog template
...

#Configure Astro

This approach uses an API route (server endpoint) to process the uploaded file. For this to work, we have to enable server-side rendering (SSR) in Astro. We can do this by leaving the site as static and enabling SSR only for specific routes.

#Add SSR to Astro

For API endpoints to work, we need to enable SSR in Astro. Many content sites can be built with Astro without server-side rendering, or with minimal server-side rendering. We’re going to assume that is the case for your site and use hybrid rendering mode.

Hybrid rendering mode defaults to rendering all pages as static. Pages and API endpoints that should be dynamically rendered on the server can be individually configured to do so.

astro.config.mjs
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://example.com",
integrations: [mdx(), sitemap()],
output: "hybrid",
});

Using server mode

The rest of the guide assumes we’re working in hybrid mode. If you’re using server mode, you’ll have to adjust accordingly.

#Add uploader component

Add an uploader component to your Astro project. This will be the upload form that will post to an API endpoint to handle the file upload.

Place this file in your src/components directory.

src/components/Uploader.astro
---
interface Props {
id: string;
}
const { id } = Astro.props;
const url = Astro.url.href;
if (import.meta.env.MODE !== "development") {
throw new Error("This component can not be used in production");
}
---
<form action="/actions/upload" method="post" enctype="multipart/form-data">
<input type="hidden" value="{url}" name="url" required />
<input type="hidden" value="{id}" name="id" required />
<div>
<label for="{`file-input-${id}`}">Image file</label>
<input type="file" name="file" required id="{`file-input-${id}`}" accept="image/png, image/jpeg, image/gif" />
</div>
<div>
<label for="{`filename-input-${id}`}">Filename</label>
<input type="text" name="filename" id="{`filename-input-${id}`}" required />
</div>
<div>
<label for="{`alt-input-${id}`}">Alt text</label>
<input type="text" name="alt" id="{`alt-input-${id}`}" />
</div>
<input type="submit" value="Upload image" />
</form>
<script>
const form = document.querySelector("form")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const formData = new FormData(form);
const response = await fetch(form.action, {
method: form.method,
body: formData,
});
if (!response.ok) {
console.error("Failed to upload image");
}
});
</script>

There are a few things to note about this component:

  • The component is designed to be used as a utility during development and does not function in production. After using it to upload an image, it replaces itself in the content with the appropriate markup. Unused upload components left in the code will fail a production build to prevent the form from being included in the final build and ensure you’ve uploaded all necessary images.
  • The form posts to /actions/upload, which we’ll set up in the next step.
  • We include both the url (from Astro) and id (prop) in the form to help the API endpoint know where to save the file and which content to replace.
  • The form is submitted asynchronously so that the browser will update the content when the image is added without refreshing the page.

#Add component to a blog post

As long as MDX is enabled, you can now add the component to any blog post on your site.

src/components/Uploader.astro
---
# ...
---
import Uploader from "../components/Uploader.astro";
Nisi nulla officia Lorem ad dolore consectetur cupidatat. Est veniam ut ipsum et. Aute ut excepteur occaecat reprehenderit pariatur in ea voluptate tempor commodo dolor laboris esse. Dolor pariatur dolore veniam sunt.
<Uploader id="1" />
Non ad eiusmod do exercitation esse voluptate amet exercitation do eiusmod duis ullamco velit. Irure ipsum qui labore laboris voluptate dolor nulla duis dolore pariatur duis id duis reprehenderit. Fugiat consequat elit veniam sit anim in do excepteur ipsum.

Check import path

Note that the path to the component may need to be adjusted based on your project structure.

View that post in your browser and you should now see the unstyle image upload form.

Inline image uploader form embedded in blog post

#Add the API endpoint

Now we need to set up the API endpoint to handle the file upload. This will be a simple function that saves the file to the repository, then replaces the uploader component with the image in the blog post.

Create a new file at src/actions/upload.ts.

src/actions/upload.ts
import fs from "fs";
import type { APIRoute } from "astro";
export const prerender = false;
const FILE_TYPE_MAP: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
};
export const POST: APIRoute = async ({ request, url, redirect }) => {
if (import.meta.env.MODE !== "development") {
return new Response("Uploader not available", { status: 404 });
}
const formData = await request.formData();
const data = {
file: formData.get("file") as File,
url: formData.get("url") as string,
id: formData.get("id") as string,
alt: formData.get("alt") as string,
filename: formData.get("filename") as string,
};
if (!data.file) throw new Error("No file provided");
if (!data.url) throw new Error("No URL provided");
if (!data.id) throw new Error("No ID provided");
if (!data.filename) throw new Error("No filename provided");
const slug = new URL(data.url).pathname.split("/").filter(Boolean).pop();
// Identify the source file
const sourceFilePath = `./src/content/blog/${slug}.mdx`;
const sourceFile = fs.readFileSync(sourceFilePath, "utf-8");
const componentString = sourceFile.match(/<Uploader\s+id="(\d+)"\s*\/>/)?.[0];
if (!componentString) {
throw new Error("No Uploader component found in source file");
}
// Create folder for the post if it doesn't exist
const imageDirname = `./public/images/${slug}`;
if (!fs.existsSync(imageDirname)) {
fs.mkdirSync(imageDirname, { recursive: true });
}
// Save the file to the folder
const filename = `${data.filename}.${FILE_TYPE_MAP[data.file.type]}`;
const file = Buffer.from(await data.file.arrayBuffer());
const imageFilePath = `${imageDirname}/${filename}`;
fs.writeFileSync(imageFilePath, file);
// Replace the Uploader component with the image
const newComponentString = `![${data.alt || ""}](${imageFilePath.replace("./public", "")})`;
const newSourceFile = sourceFile.replace(componentString, newComponentString);
fs.writeFileSync(sourceFilePath, newSourceFile);
return new Response(JSON.stringify({ success: true }), {
headers: { "Content-Type": "application/json" },
});
};

Note that this creates a specific structure for storing your images:

  • Images are stored in a folder named for the blog post slug within the public/images directory.
  • The component is replaced in the source content file with the markdown image syntax. This is using a simple regex to find a specific pattern, <Uploader id="x" />, where x is the ID of the uploader component.

#Upload images!

That’s it! Now you can drop an image into the uploader form. The image will be saved to your repository in the proper directory, and then the uploader component will be replaced with markdown image syntax in your content file.

Where you had the component in your content file:

<Uploader id="1" />

You’ll now have the markdown image syntax:

![alt-text](/images/<content-slug>/<image-filename>)

And the content will be automatically updated in your browser.

Image after being uploaded

#Next steps

I hope this gave you some inspiration for introducing tools that can augment your content workflow to make it more efficient, enjoyable, and maintainable.

Here are a few ideas for consideration as you bring this functionality into production.

#Add a platform adapter

You’ll also need an adapter when deploying the project. For deploying to Netlify, you can use Astro’s Netify adapter. To install and configure this automatically run the following command:

Terminal window
> npx astro add netlify

#Auto-import the component

Pro-tip! You can use astro-auto-import to avoid having to manually import the component into each blog post. I like doing this to keep my content files clean and focused on the content.

Terminal window
> yarn add astro-auto-import

Then add the integration to your astro.config.mjs file.

astro.config.mjs
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import { defineConfig } from "astro/config";
import AutoImport from "astro-auto-import";
export default defineConfig({
site: "https://example.com",
integrations: [
AutoImport({
imports: ["./src/components/Uploader.astro"],
}),
mdx(),
sitemap(),
],
output: "hybrid",
});