Photo by Gary Bendig on Unsplash
Next.js and Bunny CDN: Complete Guide to Image Uploading with Server Actions
Hello! Today, I'm excited to guide you through the process of uploading images using Next.js server actions and Bunny CDN. We'll also integrate the shadcn UI library and React Hook Form to enhance our application. This tutorial is designed for developers looking for efficient image handling solutions in their web projects.
For those who prefer to dive straight into the code, you can find the complete project in my GitHub repository: https://github.com/50BytesOfJohn/next-bunny-cdn
Setting up
If you already have a project created, feel free to skip this section.
Since the tutorial focuses only on image uploading, server actions and using bunny CDN, I won't make a full guide on set up. You can either clone the repository or just setup everything by yourself in the way you like. Just make sure you have a Next.js with app router ready to use before diving deeper ๐
Bunny CDN
Bunny CDN is a cost-effective and high-performance content delivery network that accelerates website speed globally by optimizing the delivery of static and dynamic content. Offers an excellent developer experience, comprehensive documentation, and transparent pricing.
To start with Bunny, 1st create an account: Link
After creating account, you will be able to create new storage zone. Using the sidebar menu, go to Storage, and then use the button to create new storage. For this tutorial I will create a storage zone named: hop-hop
๐ฐ, I will use standard tier, and no storage replication. Feel free to customize options to your needs.
hop-hop-dev
and hop-hop-prod
After creating a storage zone, you should be redirected to file manager and see information about no files in storage. No worries, we will change it soon ๐
Now, on the storage page, go to FTP & API Access section and copy Password, we will need it to upload files.
Setting up env variables
Now, since I will be using typescript, I would like my env variables to be typed properly. To do this, create a file in root directory called: environment.d.ts
and let's define Bunny CDN API key there.
// environment.d.ts
namespace NodeJS {
interface ProcessEnv {
// Bunny CDN
BUNNYCDN_API_KEY: string // Our password
BUNNYCDN_STORAGE_ZONE: string // Storage zone name
}
}
For now, we will need these 2 things.
BUNNYCDN_API_KEY - The password we copied from previous section
BUNNYCDN_STORAGE_ZONE - Storage zone name, also from previous step, in my case it's
hop-hop
We have type, but we don't have actual env variables defined ๐ฎ, let's change it! Create .env.local
for local development.
# .env.local
# Bunny CDN
BUNNYCDN_API_KEY=***
BUNNYCDN_STORAGE_ZONE=hop-hop
We're ready for some coding now!
Upload Form
We'll start with creating an upload form on the homepage. For this tutorial, I'm using shadcn/ui library, but you can use whatever you prefer and make this form however you like.
Before start, I'll use shadcn CLI to add button, input and form components.
npx shadcn-ui@latest add button
npx shadcn-ui@latest add input
npx shadcn-ui@latest add form
Now, remove everything from the app/page.tsx
file and add container plus title and prepare the place for our form:
// app/page.tsx
export default function Home() {
return (
<main className="max-w-xl mx-auto mt-20">
<h1 className="text-xl">Upload image to hop-hop</h1>
<hr className="my-4" />
{/* Place for our form */}
</main>
);
}
We will now define a UploadImageForm
component. Follow your favorite directory structure pattern. For this tutorial, I'll create _components
a folder in the same directory as my page, so the final path to the new component is: app/_components/UploadImageForm.tsx
Now, let's go step by step through our component.
Start by defining a function and remember about use client
at the top, since we will be using some client only allowed stuff here.
"use client";
export default function ImageUploadForm() {}
Let's add a type to define our form values.
type FormValues = {
image?: File;
};
export default function ImageUploadForm() {
Now define a form (useForm
is imported from react-hook-form
)
export default function ImageUploadForm() {
const form = useForm<FormValues>();
Add on submit function. We will come back to this later:
const form = useForm<FormValues>();
async function onSubmit(values: FormValues) {}
And return form component
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Image</FormLabel>
<FormControl>
<Input
{...field}
required
type="file"
onChange={(event) => {
field.onChange(event.target.files?.[0]);
}}
value={field.value?.fileName}
accept="image/png, image/jpeg, image/webp"
/>
</FormControl>
<FormDescription>Select image to upload</FormDescription>
</FormItem>
)}
/>
<Button disabled={form.formState.isSubmitting} type="submit">
Upload
</Button>
</form>
</Form>
);
Much more code than before. Let's focus on most important part:
<Input
{...field}
required
type="file"
onChange={(event) => {
field.onChange(event.target.files?.[0]);
}}
value={field.value?.fileName}
accept="image/png, image/jpeg, image/webp"
/>
We're using Input
component form shadcn/ui, apart from assinging all props from field, there are few more things happening here.
require
- Use browser validation and make sure that the file is always selected before submittingtype="file"
- Obviously we want to upload a fileonChange=...
- We want to use fullFile
in values inimage
property, in this case withreact-hook-form
we need to defineonChange
, then usefield.onChange
and assignevent.target.files?.[0]
which is aFile
value={field.value?.fileName}
- Since we're assigningFile
to our value, it's not string anymore, and we need to make sure that a nice value is displayed for the user after selecting a file (and it doesn't break our app). That's why we're overwritingvalue
withvalue.fileName
. PS. Event if the file name is displayed correctly,fileName
will probably show a typescript error, thatfileName
does not exist onFile
. Unfortunately, I wasn't able to find out a solution for that. If you know something, let me know in the comments.accept="image/png, image/jpeg, image/webp"
- We will accept only following file types
That's the most important part. The rest of the code is mostly copied from shadcn/ui example, but you can customize it to your needs or use it with your UI library.
At this point, the homepage looks like this:
Server Actions
Now we have our UI ready. It's time for server actions and upload logic!
As mentioned earlier, now we will also add a validation to our data.
I'll use Valibot library, which is a pretty new and cool library for schema validation, similar to Zod.
In the root directory I'm going to create 2 new folders:
schemas
- to keep our valibot schemasactions
- for our server actions
Let's start from schema, inside schemas
create file upload-image.schema.ts
// app/schemas/upload-image.schema.ts
import * as v from "valibot";
export const UploadImageSchema = v.object({
image: v.instance(File, [
v.mimeType(["image/jpeg", "image/png", "image/webp"]),
]),
});
v.object
- We will validate all incoming valuesv.mimeType
- We will validate that file is an image. Make sure to use same mime types as inaccept
filed in form
And now server action!
File
through server actions, we will use a small hack to make it possible.// app/actions/upload-image.action.ts
"use server";
import "server-only";
import { UploadImageSchema } from "@/schemas/upload-image.schema";
import { parseAsync } from "valibot";
export const uploadImageAction = async (formData: FormData) => {
const formDataObject = Object.fromEntries(formData.entries());
const data = await parseAsync(UploadImageSchema, formDataObject);
// Upload data.image to hop-hop Storage
};
use server
- Next.js syntax, this will be run on the serverimport "server-only"
- We don't want any of this code to be included in client side bundleasync (formData: FormData)
- As you can see, we're using the hack to passFile
to server action. Using normal object will not work. We need to passFormData
with our file. You will see in the next snippet how to achieve it in our form component.const formDataObject = Object.fromEntries(formData.entries())
- Create an object from all form data entries.const data = await parseAsync(UploadImageSchema, formDataObject)
- Use created schema to validate incoming data
Now let's quickly update our form component to use the server action.
// app/_components/ImageUploadForm.tsx
...
async function onSubmit(values: FormValues) {
const formData = new FormData();
formData.append("image", values.image!);
await uploadImageAction(formData);
}
...
As mentioned, instead of passing values, we're creating a FormData
and appending image into it. Then we're calling our server action with newly create FormData
Feel free to play around with this logic right now if you want. Try to pass something else, like string, and check how validation works.
await uploadImageAction(formData)
in try-catch
and based on error returned, display some information to the user.Upload file to Bunny Storage
Now, we have our page, form, validation and server action that has access to the file. The last step is to actually upload the file to bunny. For that, we will use their API for file uploading.
For this logic, I will use lib
directory and inside will create a file called storage.ts
// lib/storage.ts
import "server-only";
const BUNNY_STORAGE_API_HOST = "storage.bunnycdn.com";
export const uploadFile = async (path: string, file: Buffer) => {
const uploadFileUrl = new URL(
`/${process.env.BUNNYCDN_STORAGE_ZONE}/${path}`,
`https://${BUNNY_STORAGE_API_HOST}`,
);
await fetch(uploadFileUrl, {
method: "PUT",
headers: {
AccessKey: process.env.BUNNYCDN_API_KEY,
"Content-Type": "application/octet-stream",
},
body: file,
});
};
import "server-only"
- We don't want this code in client codeconst BUNNY_STORAGE_API_HOST = "storage.bunnycdn.com";
- You can find your storage API host here: Link. I'm using constant at the top of the file, as I don't plan to use different values between environments, but if it's the case for you, I encourage including it in.env
as we did with other values at the beginningconst uploadFileUrl...
- We will useURL
to create an upload URL using base path and path. When uploading a file, the 1st part of the path should be storage zone name, after that you're defining a path where the file should be uploaded. If directories doesn't exist, bunny will create them automatically. Please note that if a file already exists, it won't be overwritten.At the end we are using
fetch
to upload our file.AccessKey
header is our API key from 1st step of tutorial
Last thing left is to use our new upload function in our server action. Since I like to upload images in single format and have some more control over them, I will additionally use sharp library. For file name, I'll generate some random string using nanoid:
// app/actions/upload-image.action.ts
export const uploadImageAction = async (formData: FormData) => {
const formDataObject = Object.fromEntries(formData.entries());
const data = await parseAsync(UploadImageSchema, formDataObject);
const imageArrayBuffer = await data.image.arrayBuffer();
const processedFile = await sharp(imageArrayBuffer).webp().toBuffer();
const path = `images/${nanoid()}.webp`;
await uploadFile(path, processedFile);
};
const imageArrayBuffer = await data.image.arrayBuffer()
- Convert image to array Buffer, so it's accepted by sharpconst processedFile = await sharp(imageArrayBuffer).webp().toBuffer()
- Convert to WebP format. You can also add things likeresize
or change the output quality to save on spaceawait uploadFile(images/${nanoid()}.webp, processedFile)
at the end, call our function with path and processed file
And that's it. You can now upload Your files to Bunny storage in Next.js app!
I've already uploaded one to hop-hop
As you can see, it's in .webp
format, even if I was uploading .png
through app.
Accessing uploaded files
You probably want to store images to access them later. With our upload logic and Bunny, it's super easy to set it up.
First, go to your bunny dashboard. Now go to CDN section, and use Add Pull Zone button. Set any name you want. Then select Origin type โ Storage zone, select storage zone you created previously. Customize rest of settings to your needs and submit.
Now you can access your uploaded file using the created CDN URL.
What if you want to create a URL in our APP right after uploading an image?
Easy. Remember our upload function? We passed there a path
argument in our server action. After upload is completed, you can generate the CDN URL like that:
const path = `images/${nanoid()}.webp`;
await uploadFile(path, processedFile);
const imageUrl = new URL(path, `https://${process.env.BUNNYCDN_CDN_HOST}/`);
BUNNYCDN_CDN_HOST
to keep cdn host there, as you may want to create different hosts for different environments as wellAnd you have your image URL, you can return it to user, save in DB, do whatever your app needs!
Follow up
Check Bunny optimizer: Link
Play around with Bunny CDN different settings
If you need secure access to CDN, take a look at CDN settings, there's a possibility to use URL tokens to secure access to data. I'm actually planning to prepare a guide like this one about using URL tokens with Next.js app. If you're interested in that, let me know in comments!
Thanks for reading! I really hope that this guide will help someone, as I feel it would help me a lot in the past, as I couldn't find similar articles on Internet. Anyway, let me know if you have any suggestion, or anything at all. I'll be happy to help/answer.