Next.js and Bunny CDN: Complete Guide to Image Uploading with Server Actions

Photo by Gary Bendig on Unsplash

Next.js and Bunny CDN: Complete Guide to Image Uploading with Server Actions

ยท

11 min read

Featured on Hashnode

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.

๐Ÿ’ก
For real apps, I suggest creating separate storage zones for different environments like 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

๐Ÿ’ก
Remember about point about different storage zones from previous step? That's why we are using environment variables for storage name and API key, as they will differ between different environments.

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() {}
๐Ÿ’ก
In case you need to pass some additional data from server component for example, you can do this by using props

Let's add a type to define our form values.

๐Ÿ’ก
I'm not using any schema validation on client side, we will use it only on server side. Personally, I'm in favor of the opinion that built-in browser validation is often very good, and there's no need to bother with it if there's no special case for that. Feel free to use schema and resolver in your project, if you prefer your own validation on client side as well.
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 submitting

  • type="file" - Obviously we want to upload a file

  • onChange=... - We want to use full File in values in image property, in this case with react-hook-form we need to define onChange, then use field.onChange and assign event.target.files?.[0] which is a File

  • value={field.value?.fileName} - Since we're assigning File 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 overwriting value with value.fileName. PS. Event if the file name is displayed correctly, fileName will probably show a typescript error, that fileName does not exist on File. 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:

Our hop-hop homepage, simple, but pretty cool. Right?

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.

๐Ÿ’ก
Always validate data on the server received from the client.

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 schemas

  • actions - 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 values

  • v.mimeType - We will validate that file is an image. Make sure to use same mime types as in accept filed in form

And now server action!

๐Ÿ’ก
Currently, Next.js does not support passing values like 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 server

  • import "server-only" - We don't want any of this code to be included in client side bundle

  • async (formData: FormData) - As you can see, we're using the hack to pass File to server action. Using normal object will not work. We need to pass FormData 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.

๐Ÿ’ก
We're not implementing any displaying of server errors in this tutorial, but I recommend doing this for real app. You can, for example, wrap 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 code

  • const 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 beginning

  • const uploadFileUrl... - We will use URL 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 sharp

  • const processedFile = await sharp(imageArrayBuffer).webp().toBuffer() - Convert to WebP format. You can also add things like resize or change the output quality to save on space

  • await 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}/`);
๐Ÿ’ก
I've added additional env variable called BUNNYCDN_CDN_HOST to keep cdn host there, as you may want to create different hosts for different environments as well

And 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.

ย