Learn how to handle forms in your Next.js application using React Hook Form, Zod validation, and Server Actions.
We use React Hook Form for form handling due to its excellent performance and developer experience:
1"use client"23import { useForm } from "react-hook-form"4import { zodResolver } from "@hookform/resolvers/zod"5import * as z from "zod"6import { Button } from "@/components/ui/button"7import { Input } from "@/components/ui/input"8import { toast } from "sonner"910const formSchema = z.object({11 email: z.string().email("Invalid email address"),12 password: z.string().min(8, "Password must be at least 8 characters"),13})1415type FormData = z.infer<typeof formSchema>1617export function LoginForm() {18 const {19 register,20 handleSubmit,21 formState: { errors, isSubmitting },22 } = useForm<FormData>({23 resolver: zodResolver(formSchema),24 })2526 const onSubmit = async (data: FormData) => {27 try {28 // Handle form submission29 await signIn(data)30 toast.success("Successfully logged in!")31 } catch (error) {32 toast.error("Something went wrong")33 }34 }3536 return (37 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">38 <div>39 <Input40 {...register("email")}41 type="email"42 placeholder="Email"43 aria-invalid={!!errors.email}44 />45 {errors.email && (46 <p className="mt-1 text-sm text-red-500">{errors.email.message}</p>47 )}48 </div>4950 <div>51 <Input52 {...register("password")}53 type="password"54 placeholder="Password"55 aria-invalid={!!errors.password}56 />57 {errors.password && (58 <p className="mt-1 text-sm text-red-500">{errors.password.message}</p>59 )}60 </div>6162 <Button type="submit" disabled={isSubmitting}>63 {isSubmitting ? "Loading..." : "Sign In"}64 </Button>65 </form>66 )67}
Use Zod for type-safe form validation:
1import * as z from "zod"23export const userSchema = z.object({4 username: z5 .string()6 .min(3, "Username must be at least 3 characters")7 .max(20, "Username must be less than 20 characters")8 .regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),9 email: z10 .string()11 .email("Invalid email address"),12 password: z13 .string()14 .min(8, "Password must be at least 8 characters")15 .regex(16 /^(?=.*[a-z])(?=.*[A-Z])(?=.*d)(?=.*[@$!%*?&])[A-Za-zd@$!%*?&]+$/,17 "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character"18 ),19 confirmPassword: z20 .string(),21}).refine((data) => data.password === data.confirmPassword, {22 message: "Passwords don't match",23 path: ["confirmPassword"],24})2526export type UserFormData = z.infer<typeof userSchema>
Use Server Actions for form submissions in Next.js 14+:
1import { z } from "zod"2import { createServerActionClient } from "@supabase/auth-helpers-nextjs"3import { cookies } from "next/headers"4import { revalidatePath } from "next/cache"56const formSchema = z.object({7 title: z.string().min(1, "Title is required"),8 content: z.string().min(1, "Content is required"),9})1011export async function createPost(formData: FormData) {12 const supabase = createServerActionClient({ cookies })1314 const { data: { session } } = await supabase.auth.getSession()15 if (!session) {16 throw new Error("Not authenticated")17 }1819 const raw = {20 title: formData.get("title"),21 content: formData.get("content"),22 }2324 const validated = formSchema.safeParse(raw)25 if (!validated.success) {26 throw new Error("Invalid form data")27 }2829 const { data, error } = await supabase30 .from("posts")31 .insert({32 user_id: session.user.id,33 ...validated.data,34 })35 .select()36 .single()3738 if (error) {39 throw new Error("Failed to create post")40 }4142 revalidatePath("/posts")43 return data44}
Use the server action in your form:
1export function PostForm() {2 return (3 <form action={createPost}>4 <Input5 name="title"6 placeholder="Post title"7 required8 />9 <Textarea10 name="content"11 placeholder="Post content"12 required13 />14 <Button type="submit">15 Create Post16 </Button>17 </form>18 )19}
Handle file uploads with Server Actions and Supabase Storage:
1import { createServerActionClient } from "@supabase/auth-helpers-nextjs"2import { cookies } from "next/headers"34export async function uploadAvatar(formData: FormData) {5 const supabase = createServerActionClient({ cookies })67 const { data: { session } } = await supabase.auth.getSession()8 if (!session) {9 throw new Error("Not authenticated")10 }1112 const file = formData.get("avatar") as File13 if (!file) {14 throw new Error("No file provided")15 }1617 // Upload file to Supabase Storage18 const { data: uploadData, error: uploadError } = await supabase.storage19 .from("avatars")20 .upload(`${session.user.id}/avatar.png`, file, {21 upsert: true,22 })2324 if (uploadError) {25 throw new Error("Failed to upload file")26 }2728 // Get public URL29 const { data: { publicUrl } } = supabase.storage30 .from("avatars")31 .getPublicUrl(uploadData.path)3233 // Update user profile34 const { error: updateError } = await supabase35 .from("profiles")36 .update({ avatar_url: publicUrl })37 .eq("id", session.user.id)3839 if (updateError) {40 throw new Error("Failed to update profile")41 }4243 revalidatePath("/profile")44 return publicUrl45}