How to Upload an Image to Supabase Storage and Store the Public URL in a Form Using Zod and React Hook Form in Next.js?

I am working on a Next.js application where users can add books using a form. Each book should have an uploaded cover image that gets stored in Supabase Storage, and its public URL should be saved in my book database table under the column bookImageUrl.

What I Have So Far:

  • A React Hook Form (react-hook-form) handling the book details.

  • Supabase Storage setup to store book images

  • A separate component (UploadBookImage.tsx) to handle image uploads.

  • I need the uploaded image URL to be stored in the form state and submitted when saving
    the book.

Expected Behavior:

  • The user selects an image file.

  • The image is uploaded to Supabase Storage.

  • The public URL of the uploaded image is retrieved and set in the form
    state

  • The form is submitted, and the bookImageUrl is saved in the book
    database.

Current Implementation
UploadBookImages.tsx Handle Images Upload

import { createClient } from "../../../../../utils/supabase/client";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export default function UploadBookImage({
  onUpload,
}: {
  size: number;
  url: string | null;
  onUpload: (url: string) => void;
}) {
  const supabase = createClient();
  const [uploading, setUploading] = useState(false);

  const uploadAvatar: React.ChangeEventHandler<HTMLInputElement> = async (
    event
  ) => {
    try {
      setUploading(true);

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error("You must select an image to upload.");
      }

      const file = event.target.files[0];
      const fileExt = file.name.split(".").pop();
      const filePath = `books/${Date.now()}.${fileExt}`;

      const { error: uploadError } = await supabase.storage
        .from("avatars")
        .upload(filePath, file);

      if (uploadError) {
        throw uploadError;
      }

      onUpload(filePath);
    } catch (error) {
      alert(`Error uploading avatar! ${error}`);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <div className="grid w-full max-w-sm items-center gap-1.5">
        <Label htmlFor="picture">
          {uploading ? "Uploading ..." : "Upload"}
        </Label>
        <Input
          id="picture"
          type="file"
          accept="image/**"
          onChange={uploadAvatar}
          disabled={uploading}
          name="bookImageUrl"
        />
      </div>
    </div>
  );
}

Form

 const BookForm: React.FC<BookFormProps> = ({ authors }) => {
      const [state, action, pending] = useActionState(addBook, undefined);
      const [bookImageUrl, setBookImageUrl] = useState<string | null>(null);
    
  // React Hook Form with default values
  const form = useForm<BookInferSchema>({
    resolver: zodResolver(BookSchema),
    defaultValues: {
      //rest of the values
      bookImageUrl: "",
    },
  });

  //submitting the forms
  async function onSubmit(data: BookInferSchema) {
    try {
      const formData = new FormData();
      if (bookImageUrl) {
        data.bookImageUrl = bookImageUrl; // Attach uploaded image URL
      }

      Object.entries(data).forEach(([key, value]) => {
        formData.append(
          key,
          value instanceof Date ? value.toISOString() : value.toString()
        );
      });

      //sending the formData to the action.ts for submitting the forms
      const response = (await action(formData)) as {
        error?: string;
        message?: string;
      } | void;

      //Error or success messages for any submissions and any errors/success from the server
      if (response?.error) {
        toast({
          title: "Error",
          description: `An error occurred: ${response.error}`,
        });
      } else {
        form.reset();
      }
    } catch {
      toast({
        title: "Error",
        description: "An unexpected error occured.",
      });
    }
  }

  //Error or success messages for any submissions and any errors/success from the server


  return (
        <Form {...form}>
          <form
            className="space-y-8"
            onSubmit={(e) => {
              e.preventDefault();
              startTransition(() => {
                form.handleSubmit(onSubmit)(e);
              });
            }}
          >
            <UploadBookImage
              size={150}
              url={bookImageUrl}
              onUpload={(url) => setBookImageUrl(url)}
            />

           //rest of the input fields
  );
};

export default BookForm;

action.ts For saving the data in the database

"use server"


export async function addBook(state: BookFormState, formData: FormData) {
  // Validate form fields
  // Log all form data to debug
  for (const pair of formData.entries()) {
    console.log(`${pair[0]}: ${pair[1]}`);
  }

  const validatedFields = BookSchema.safeParse({
    //rest of the values
    bookImageUrl: formData.get("bookImageUrl"),
  });

   // Check if validation failed
   if (!validatedFields.success) {
    console.error("Validation Errors:", validatedFields.error.format()); // Log errors
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }

 // Prepare for insertion into the new database
 const {..rest of the values, bookImageUrl} = validatedFields.data

  // Insert the new author into the database
  const supabase = createClient();
  const {data, error} = await (await supabase).from('books').insert({ ...rest of the values, bookImageUrl});

  if(data){
    console.log(data,"data in the addBook function")
  }

  
  if (error) {
    return {
      error: true,
      message: error.message,
    };
  }

  return {
    error: false,
    message: 'Book updated successfully',
  };

}

Data definition from Supabase and RLS policy

create table
  public.books (
   //rest of the columns
    "bookImageUrl" text null,
    constraint books_pkey primary key (isbn),
    constraint books_author_id_fkey foreign key (author_id) references authors (id) on delete cascade
  ) tablespace pg_default;

RLS policy for now:

alter policy "Enable insert for authenticated users only"

on "public"."books"

to authenticated
with check (

  true

);

Storage bucket:
enter image description here

My schema

import { z } from "zod";

export const BookSchema = z.object({
  //rest of the values
  bookImageUrl :z.string().optional()
});

// TypeScript Type for Book
export type BookInferSchema = z.infer<typeof BookSchema>;

//Form state for adding and editing books
export type BookFormState =
  | {
      errors?: {
        //rest of the values
        bookImageUrl?: string[];
      };
      message?: string;
    }
  | undefined;

Issues I’m facing:

  • Unable to upload in the storage bucket book-pics. Hence, I am unable to save the bookImageURL when I submit the form.