Documentation
Documentation

Forms Guide

Learn how to handle forms in your Next.js application using React Hook Form, Zod validation, and Server Actions.

React Hook Form

We use React Hook Form for form handling due to its excellent performance and developer experience:

components/forms/login-form.tsxtypescript
1"use client"
2
3import { 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"
9
10const 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})
14
15type FormData = z.infer<typeof formSchema>
16
17export function LoginForm() {
18 const {
19 register,
20 handleSubmit,
21 formState: { errors, isSubmitting },
22 } = useForm<FormData>({
23 resolver: zodResolver(formSchema),
24 })
25
26 const onSubmit = async (data: FormData) => {
27 try {
28 // Handle form submission
29 await signIn(data)
30 toast.success("Successfully logged in!")
31 } catch (error) {
32 toast.error("Something went wrong")
33 }
34 }
35
36 return (
37 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
38 <div>
39 <Input
40 {...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>
49
50 <div>
51 <Input
52 {...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>
61
62 <Button type="submit" disabled={isSubmitting}>
63 {isSubmitting ? "Loading..." : "Sign In"}
64 </Button>
65 </form>
66 )
67}

Form Validation

Use Zod for type-safe form validation:

lib/validations/user.tstypescript
1import * as z from "zod"
2
3export const userSchema = z.object({
4 username: z
5 .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: z
10 .string()
11 .email("Invalid email address"),
12 password: z
13 .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: z
20 .string(),
21}).refine((data) => data.password === data.confirmPassword, {
22 message: "Passwords don't match",
23 path: ["confirmPassword"],
24})
25
26export type UserFormData = z.infer<typeof userSchema>

Server Actions

Use Server Actions for form submissions in Next.js 14+:

app/actions.tstypescript
1import { z } from "zod"
2import { createServerActionClient } from "@supabase/auth-helpers-nextjs"
3import { cookies } from "next/headers"
4import { revalidatePath } from "next/cache"
5
6const formSchema = z.object({
7 title: z.string().min(1, "Title is required"),
8 content: z.string().min(1, "Content is required"),
9})
10
11export async function createPost(formData: FormData) {
12 const supabase = createServerActionClient({ cookies })
13
14 const { data: { session } } = await supabase.auth.getSession()
15 if (!session) {
16 throw new Error("Not authenticated")
17 }
18
19 const raw = {
20 title: formData.get("title"),
21 content: formData.get("content"),
22 }
23
24 const validated = formSchema.safeParse(raw)
25 if (!validated.success) {
26 throw new Error("Invalid form data")
27 }
28
29 const { data, error } = await supabase
30 .from("posts")
31 .insert({
32 user_id: session.user.id,
33 ...validated.data,
34 })
35 .select()
36 .single()
37
38 if (error) {
39 throw new Error("Failed to create post")
40 }
41
42 revalidatePath("/posts")
43 return data
44}

Use the server action in your form:

components/forms/post-form.tsxtypescript
1export function PostForm() {
2 return (
3 <form action={createPost}>
4 <Input
5 name="title"
6 placeholder="Post title"
7 required
8 />
9 <Textarea
10 name="content"
11 placeholder="Post content"
12 required
13 />
14 <Button type="submit">
15 Create Post
16 </Button>
17 </form>
18 )
19}

File Upload

Handle file uploads with Server Actions and Supabase Storage:

app/actions/upload.tstypescript
1import { createServerActionClient } from "@supabase/auth-helpers-nextjs"
2import { cookies } from "next/headers"
3
4export async function uploadAvatar(formData: FormData) {
5 const supabase = createServerActionClient({ cookies })
6
7 const { data: { session } } = await supabase.auth.getSession()
8 if (!session) {
9 throw new Error("Not authenticated")
10 }
11
12 const file = formData.get("avatar") as File
13 if (!file) {
14 throw new Error("No file provided")
15 }
16
17 // Upload file to Supabase Storage
18 const { data: uploadData, error: uploadError } = await supabase.storage
19 .from("avatars")
20 .upload(`${session.user.id}/avatar.png`, file, {
21 upsert: true,
22 })
23
24 if (uploadError) {
25 throw new Error("Failed to upload file")
26 }
27
28 // Get public URL
29 const { data: { publicUrl } } = supabase.storage
30 .from("avatars")
31 .getPublicUrl(uploadData.path)
32
33 // Update user profile
34 const { error: updateError } = await supabase
35 .from("profiles")
36 .update({ avatar_url: publicUrl })
37 .eq("id", session.user.id)
38
39 if (updateError) {
40 throw new Error("Failed to update profile")
41 }
42
43 revalidatePath("/profile")
44 return publicUrl
45}