DEV Community

Cover image for NextJs image upload with zod validation
Oliwier965
Oliwier965

Posted on

NextJs image upload with zod validation

Hello everyone πŸ‘‹ Today I wanted to share one of my recent struggles: uploading files with validation in Next.js using useForm. There are many pitfalls for beginners, so I hope this tutorial will help 😊.

Firstly, I will assume you have a basic Next.js setup ready and you know the basics. So, let’s get right into it.

Install zod and zod-form-data. I'll be using npm for that. The command is:

npm i zod zod-form-data

Now, let's create our page. I will also be using the shadcn library for this tutorial. If you want to use it too, run these commands:

npx shadcn-ui@latest init
npx shadcn-ui@latest add input
npx shadcn-ui@latest add button
npx shadcn-ui@latest add form
Enter fullscreen mode Exit fullscreen mode

1) Create a file for your page with the .tsx extension. Add the "use client" directive at the top and import the necessary dependencies.

"use client";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { userFormSchema } from "@/types/formSchema";
import { updateUser } from "@/lib/auth";

Enter fullscreen mode Exit fullscreen mode

2) Now, we will create our default export and create the necessary components inside.

export default function Page() {
  const form = useForm<z.infer<typeof userFormSchema>>({
    resolver: zodResolver(userFormSchema),
    defaultValues: {
      login: "",
      email: "",
      password: "",
    },
  });

  function onSubmit(values: z.infer<typeof userFormSchema>) {
    const formData = new FormData();
    values.login && formData.append("login", values.login);
    values.email && formData.append("email", values.email);
    values.password && formData.append("password", values.password);
    values.profileImage && formData.append("profileImage", values.profileImage);

    updateUser(formData);
  }

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="grid gap-4 lg:grid-cols-2 p-4 bg-black rounded-lg"
      >
        <FormField
          control={form.control}
          name="profileImage"
          render={({ field: { value, onChange, ...fieldProps } }) => (
            <FormItem>
              <FormLabel>Profile picture</FormLabel>
              <FormControl>
                <Input
                  className="bg-neutral-900"
                  type="file"
                  {...fieldProps}
                  accept="image/png, image/jpeg, image/jpg"
                  onChange={(event) =>
                    onChange(event.target.files && event.target.files[0])
                  }
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="login"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Login</FormLabel>
              <FormControl>
                <Input
                  className="bg-neutral-900"
                  placeholder="Enter login"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input
                  className="bg-neutral-900"
                  placeholder="Enter email"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input
                  className="bg-neutral-900"
                  placeholder="Enter password"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" className=" text-black w-full lg:col-span-2">
          Submit
        </Button>
      </form>
    </Form>
  );
}

Enter fullscreen mode Exit fullscreen mode

Now, I want to explain some of this code. Look at the first FormField. We are destructuring from render because we need to parse the file.

Now, look at the onSubmit function. We are creating formData and appending values because we can't parse non-plain objects to the server action, and the file is making it non-plain.

Form is just a React useForm() that we are assigning userFormSchema to. We are using zodResolver as the resolver for client validation and setting default values.

3) Now we will create userFormSchema. The name can be anything, but remember to use it correctly. You can create it in the same file or, like me, import it.

import { z } from "zod";
import { zfd } from "zod-form-data";

const userFormSchema = zfd.formData({
  login: z
      .string()
      .min(1, {
        message: "Login can't be empty.",
      })

  password: z
      .string()
      .min(8, {
        message: "Password must be mix 8 characters long.",
      })
      .max(20, {
        message: "Password must be max 20 characters long.",
      })
      .regex(
        /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/,
        "Password must include one small letter, one uppercase letter and one number"
      )

  email: z
      .string()
      .email({
        message: "Email is not in the correct format",
      })
      .min(1, {
        message: "Email can't be empty.",
      })

  profileImage: zfd
    .file()
    .refine((file) => file.size < 5000000, {
      message: "File can't be bigger than 5MB.",
    })
    .refine(
      (file) => ["image/jpeg", "image/png", "image/jpg"].includes(file.type),
      {
        message: "File format must be either jpg, jpeg lub png.",
      }
    )
});

export { userFormSchema };

Enter fullscreen mode Exit fullscreen mode

We are using zfd from zod-form-data for the file field and setting our validation. We are using refine for file validation (size and format).

4) Now, let's create our server action. The file must have the .ts extension and the 'use server' directive at the top.

'use server';

import { userFormSchema } from "@/types/formSchema";
import fs from "node:fs/promises";

const updateUser = async (data: FormData) => {
  const safeData = userFormSchema.safeParse(data);
  if (!safeData.success) throw new Error("Invalid data");
  const { login, email, password, profileImage } = safeData.data;

  try {
    const arrayBuffer = await profileImage.arrayBuffer();
    const buffer = new Uint8Array(arrayBuffer);
    const filePath = `./public/uploads/${Date.now()}_${profileImage.name}`;

    await fs.writeFile(filePath, buffer);

    // Also here you cand do something with the rest of the data
  } catch (error: any) {
    throw new Error("Something went wrong");
  }
};

export { updateUser };
Enter fullscreen mode Exit fullscreen mode

I haven't done anything with the rest of the data; this was only to show you how to combine both zod and zfd. We are using fs to write the file to our chosen location.

That is all for this guide. I hope you enjoyed it 😊 and learned a lot 🧠. Have a nice day β˜€οΈ. Love you all πŸ’–. If you have any feedback, feel free to leave it in the comments and please share this tutorial.

Top comments (0)