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
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";
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>
);
}
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 };
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 };
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 (3)
Thanks for your explanation, It was making me a headache for a few days as well. Just a quick note, using
&&
made my Eslint throw an error sayingExpected an assignment or function call and instead saw an expression.
. So I wasn't able to use thevalues.blablah
expression.Additionally, I wanted to ask whether it's necessary to use the form Schema both in the front-end and in the backend? Isn't safe parsing in the frontend enough? doesn't it make replications?
Although I cannot address the eslint error you're having. I want to dispel your doubts about replication of form schema. At the frontend it's used for signaling user what they typed wrong, but if they knew how, they could still send unexpected data to the server bypassing frontend check, so for that reason it is necessary to check both on frontend and backend. And btw, it really makes me happy π I was able to help you. Thank you for reading my article β€οΈ.
Thanks for your explanation, I will make sure to do that!