Escritos

Métodos de validación de formularios en Next.js: guía de validación en el cliente y en el servidor

Métodos modernos para validar formularios en Next.js: Zod, React Hook Form, Server Actions y técnicas seguras de procesamiento de formularios con TypeScript. Con ejemplos de validación tanto en el cliente como en el servidor.

Furkan Özay1 de julio de 2025 · 13 min de lectura
czCZAY.DEV // EDITORIALES
TYPE SYSTEM

#TypeScript · 13 MIN DE LECTURA

FURKAN ÖZAYVOL. 2025 // #82EF

Nota: Validación de formularios moderna

En este artículo descubrirás los métodos modernos de validación de formularios con Next.js 14+. Aprenderás técnicas de validación en el cliente y en el servidor, cómo lograr seguridad de tipos con TypeScript y cómo optimizar el rendimiento.

La validación de formularios es un tema crítico en el desarrollo web. Para mejorar la experiencia de usuario, evitar vulnerabilidades de seguridad y garantizar la integridad de los datos, hacen falta las estrategias de validación correctas. En este artículo explicaré los métodos modernos de validación de formularios en Next.js con ejemplos detallados.

Fundamentos de la validación de formularios

Definición: Capas de validación

En las aplicaciones web modernas se recomienda un enfoque de validación en 3 capas:

1. Cliente: Respuesta rápida y mejora de la UX

2. Servidor: Seguridad e integridad de los datos

3. Base de datos: Última capa de seguridad

Validación moderna con React Hook Form + Zod

Consejo: Instalación

Bash
@types/node ```
</Callout>
 
### Definir el schema básico
 
<Callout title="Ejemplo de schema con Zod" variant="example">
 
```typescript
// lib/schemas/auth.ts
import { z } from "zod";
 
export const loginSchema = z.object({
email: z
	.string()
	.min(1, "Email gerekli")
	.email("Geçerli bir email adresi girin"),
password: z
	.string()
	.min(8, "Şifre en az 8 karakter olmalı")
	.regex(
		/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
		"Şifre en az bir küçük harf, büyük harf ve sayı içermeli"
	),
});
 
export const registerSchema = z
.object({
	name: z
		.string()
		.min(2, "İsim en az 2 karakter olmalı")
		.max(50, "İsim en fazla 50 karakter olabilir"),
	email: z
		.string()
		.min(1, "Email gerekli")
		.email("Geçerli bir email adresi girin"),
	password: z.string().min(8, "Şifre en az 8 karakter olmalı"),
	confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
	message: "Şifreler eşleşmiyor",
	path: ["confirmPassword"],
});
 
export type LoginFormData = z.infer<typeof loginSchema>;
export type RegisterFormData = z.infer<typeof registerSchema>;

Componente de formulario en el cliente

Ejemplo: Componente de formulario con React Hook Form

typescript
// components/forms/LoginForm.tsx
"use client";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { loginSchema, type LoginFormData } from "@/lib/schemas/auth";
 
export default function LoginForm() {
	const [isSubmitting, setIsSubmitting] = useState(false);
 
	const {
		register,
		handleSubmit,
		formState: { errors, isValid },
		setError,
	} = useForm<LoginFormData>({
		resolver: zodResolver(loginSchema),
		mode: "onBlur", // Validasyon tetikleme modu
	});
 
	const onSubmit = async (data: LoginFormData) => {
		setIsSubmitting(true);
 
		try {
			const response = await fetch("/api/auth/login", {
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify(data),
			});
 
			const result = await response.json();
 
			if (!response.ok) {
				// Server-side hatalarını form'da göster
				if (result.field) {
					setError(result.field, { message: result.message });
				} else {
					setError("root", { message: result.message });
				}
				return;
			}
 
			// Başarılı login sonrası yönlendirme
			window.location.href = "/dashboard";
		} catch (error) {
			setError("root", { message: "Bir hata oluştu, tekrar deneyin" });
		} finally {
			setIsSubmitting(false);
		}
	};
 
	return (
		<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
			<div>
				<label htmlFor="email" className="block text-sm font-medium">
					Email
				</label>
				<input
					{...register("email")}
					type="email"
					className={`mt-1 block w-full px-3 py-2 border rounded-md ${
						errors.email ? "border-red-500" : "border-gray-300"
					}`}
				/>
				{errors.email && (
					<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
				)}
			</div>
 
			<div>
				<label htmlFor="password" className="block text-sm font-medium">
					Şifre
				</label>
				<input
					{...register("password")}
					type="password"
					className={`mt-1 block w-full px-3 py-2 border rounded-md ${
						errors.password ? "border-red-500" : "border-gray-300"
					}`}
				/>
				{errors.password && (
					<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
				)}
			</div>
 
			{errors.root && (
				<div className="p-3 bg-red-50 border border-red-200 rounded-md">
					<p className="text-sm text-red-700">{errors.root.message}</p>
				</div>
			)}
 
			<button
				type="submit"
				disabled={isSubmitting || !isValid}
				className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
			>
				{isSubmitting ? "Giriş yapılıyor..." : "Giriş Yap"}
			</button>
		</form>
	);
}

Validación en el servidor con Server Actions

Importante: Server Actions de Next.js 14

Las Server Actions son un método potente para procesar los datos del formulario directamente en el servidor. Este enfoque ofrece ventajas tanto de rendimiento como de seguridad.

Definir una Server Action

Ejemplo: Ejemplo de Server Action

typescript
// app/actions/auth.ts
"use server";
 
import { z } from "zod";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { loginSchema } from "@/lib/schemas/auth";
 
type ActionResult = {
	success: boolean;
	message: string;
	errors?: Record<string, string[]>;
};
 
export async function loginAction(formData: FormData): Promise<ActionResult> {
	// FormData'yı object'e çevir
	const rawData = {
		email: formData.get("email"),
		password: formData.get("password"),
	};
 
	// Server-side validasyon
	const result = loginSchema.safeParse(rawData);
 
	if (!result.success) {
		return {
			success: false,
			message: "Validasyon hatası",
			errors: result.error.flatten().fieldErrors,
		};
	}
 
	const { email, password } = result.data;
 
	try {
		// Kullanıcı doğrulama işlemi
		const user = await authenticateUser(email, password);
 
		if (!user) {
			return {
				success: false,
				message: "Email veya şifre hatalı",
			};
		}
 
		// Session oluştur
		await createSession(user.id);
 
		// Cache'i yenile
		revalidatePath("/dashboard");
	} catch (error) {
		return {
			success: false,
			message: "Giriş sırasında bir hata oluştu",
		};
	}
 
	// Başarılı giriş sonrası yönlendirme
	redirect("/dashboard");
}
 
async function authenticateUser(email: string, password: string) {
	// Veritabanı sorgusu ve şifre kontrolü
	// Bu kısım gerçek uygulamada hash karşılaştırması yapacak
	const user = await db.user.findUnique({ where: { email } });
 
	if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
		return null;
	}
 
	return user;
}

Usar un formulario con una Server Action

Ejemplo: Formulario con mejora progresiva

typescript
// components/forms/ServerLoginForm.tsx
import { loginAction } from "@/app/actions/auth";
 
export default function ServerLoginForm() {
	return (
		<form action={loginAction} className="space-y-4">
			<div>
				<label htmlFor="email" className="block text-sm font-medium">
					Email
				</label>
				<input
					name="email"
					type="email"
					required
					className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
				/>
			</div>
 
			<div>
				<label htmlFor="password" className="block text-sm font-medium">
					Şifre
				</label>
				<input
					name="password"
					type="password"
					required
					minLength={8}
					className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
				/>
			</div>
 
			<button
				type="submit"
				className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700"
			>
				Giriş Yap
			</button>
		</form>
	);
}

Validación de formularios complejos: formulario para añadir productos

Ejemplo: Ejemplo de validación avanzada

typescript
// lib/schemas/product.ts
import { z } from "zod";
 
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_IMAGE_TYPES = [
	"image/jpeg",
	"image/jpg",
	"image/png",
	"image/webp",
];
 
export const productSchema = z.object({
	name: z
		.string()
		.min(3, "Ürün adı en az 3 karakter olmalı")
		.max(100, "Ürün adı en fazla 100 karakter olabilir"),
	description: z
		.string()
		.min(10, "Açıklama en az 10 karakter olmalı")
		.max(1000, "Açıklama en fazla 1000 karakter olabilir"),
	price: z
		.number()
		.min(0.01, "Fiyat 0'dan büyük olmalı")
		.max(999999.99, "Fiyat geçerli aralıkta olmalı"),
	category: z.string().min(1, "Kategori seçilmeli"),
	tags: z
		.array(z.string())
		.min(1, "En az bir etiket eklenmeli")
		.max(10, "En fazla 10 etiket eklenebilir"),
	images: z
		.array(
			z.object({
				file: z
					.any()
					.refine(
						(file) => file?.size <= MAX_FILE_SIZE,
						"Dosya boyutu 5MB'dan küçük olmalı"
					)
					.refine(
						(file) => ACCEPTED_IMAGE_TYPES.includes(file?.type),
						"Sadece JPEG, PNG ve WebP formatları kabul edilir"
					),
				alt: z.string().min(1, "Alt text gerekli"),
			})
		)
		.min(1, "En az bir resim yüklenmeli")
		.max(5, "En fazla 5 resim yüklenebilir"),
	variants: z
		.array(
			z.object({
				size: z.string().min(1, "Beden gerekli"),
				color: z.string().min(1, "Renk gerekli"),
				stock: z.number().min(0, "Stok 0 veya pozitif olmalı"),
				sku: z.string().min(1, "SKU gerekli"),
			})
		)
		.min(1, "En az bir varyant eklenmeli"),
	isActive: z.boolean().default(true),
	publishDate: z
		.date()
		.min(new Date(), "Yayın tarihi gelecekte olmalı")
		.optional(),
});
 
export type ProductFormData = z.infer<typeof productSchema>;

Validación en tiempo real y debouncing

Ejemplo: Validación en tiempo real con buen rendimiento

typescript
// hooks/useDebounce.ts
import { useEffect, useState } from "react";
 
export function useDebounce<T>(value: T, delay: number): T {
	const [debouncedValue, setDebouncedValue] = useState<T>(value);
 
	useEffect(() => {
		const handler = setTimeout(() => {
			setDebouncedValue(value);
		}, delay);
 
		return () => {
			clearTimeout(handler);
		};
	}, [value, delay]);
 
	return debouncedValue;
}
 
// components/forms/EmailCheckInput.tsx
("use client");
 
import { useState, useEffect } from "react";
import { useDebounce } from "@/hooks/useDebounce";
 
export default function EmailCheckInput({
	onEmailChange,
}: {
	onEmailChange: (email: string, isValid: boolean) => void;
}) {
	const [email, setEmail] = useState("");
	const [isChecking, setIsChecking] = useState(false);
	const [isAvailable, setIsAvailable] = useState<boolean | null>(null);
	const [error, setError] = useState("");
 
	const debouncedEmail = useDebounce(email, 500);
 
	useEffect(() => {
		if (!debouncedEmail) {
			setIsAvailable(null);
			setError("");
			return;
		}
 
		// Email format kontrolü
		const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
		if (!emailRegex.test(debouncedEmail)) {
			setError("Geçerli bir email adresi girin");
			setIsAvailable(false);
			onEmailChange(debouncedEmail, false);
			return;
		}
 
		checkEmailAvailability(debouncedEmail);
	}, [debouncedEmail]);
 
	const checkEmailAvailability = async (emailToCheck: string) => {
		setIsChecking(true);
		setError("");
 
		try {
			const response = await fetch(
				`/api/check-email?email=${encodeURIComponent(emailToCheck)}`
			);
			const result = await response.json();
 
			if (result.available) {
				setIsAvailable(true);
				setError("");
				onEmailChange(emailToCheck, true);
			} else {
				setIsAvailable(false);
				setError("Bu email adresi zaten kullanılıyor");
				onEmailChange(emailToCheck, false);
			}
		} catch (error) {
			setError("Email kontrolü yapılamadı");
			onEmailChange(emailToCheck, false);
		} finally {
			setIsChecking(false);
		}
	};
 
	return (
		<div>
			<label htmlFor="email" className="block text-sm font-medium">
				Email
			</label>
			<div className="relative">
				<input
					type="email"
					value={email}
					onChange={(e) => setEmail(e.target.value)}
					className={`mt-1 block w-full px-3 py-2 pr-10 border rounded-md ${
						error
							? "border-red-500"
							: isAvailable === true
							? "border-green-500"
							: "border-gray-300"
					}`}
				/>
				<div className="absolute inset-y-0 right-0 flex items-center pr-3">
					{isChecking && (
						<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
					)}
					{isAvailable === true && (
						<svg
							className="w-4 h-4 text-green-600"
							fill="currentColor"
							viewBox="0 0 20 20"
						>
							<path
								fillRule="evenodd"
								d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
								clipRule="evenodd"
							/>
						</svg>
					)}
					{isAvailable === false && error && (
						<svg
							className="w-4 h-4 text-red-600"
							fill="currentColor"
							viewBox="0 0 20 20"
						>
							<path
								fillRule="evenodd"
								d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
								clipRule="evenodd"
							/>
						</svg>
					)}
				</div>
			</div>
			{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
			{isAvailable === true && (
				<p className="mt-1 text-sm text-green-600">
					Email adresi kullanılabilir
				</p>
			)}
		</div>
	);
}

Validación con API Routes

Ejemplo: Validación en una API Route

typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { registerSchema } from "@/lib/schemas/auth";
 
export async function POST(request: NextRequest) {
	try {
		const body = await request.json();
 
		// Server-side validasyon
		const validationResult = registerSchema.safeParse(body);
 
		if (!validationResult.success) {
			return NextResponse.json(
				{
					success: false,
					message: "Validasyon hatası",
					errors: validationResult.error.flatten().fieldErrors,
				},
				{ status: 400 }
			);
		}
 
		const { name, email, password } = validationResult.data;
 
		// Email benzersizliği kontrolü
		const existingUser = await db.user.findUnique({ where: { email } });
		if (existingUser) {
			return NextResponse.json(
				{
					success: false,
					message: "Bu email adresi zaten kullanılıyor",
					field: "email",
				},
				{ status: 409 }
			);
		}
 
		// Kullanıcı oluşturma
		const hashedPassword = await bcrypt.hash(password, 12);
		const user = await db.user.create({
			data: {
				name,
				email,
				passwordHash: hashedPassword,
			},
		});
 
		return NextResponse.json({
			success: true,
			message: "Kullanıcı başarıyla oluşturuldu",
			user: {
				id: user.id,
				name: user.name,
				email: user.email,
			},
		});
	} catch (error) {
		console.error("User creation error:", error);
		return NextResponse.json(
			{
				success: false,
				message: "Sunucu hatası",
			},
			{ status: 500 }
		);
	}
}

Optimizaciones de rendimiento

Consejo: Consejos de rendimiento para formularios

  • Debouncing: Usa un retardo de 300-500ms para la validación en tiempo real
  • Validación por campo: Valida solo los campos que cambian
  • Lazy loading: Carga los formularios grandes paso a paso
  • Optimistic updates: Actualiza la UI de inmediato tras una validación correcta

Hook de formulario optimizado

Ejemplo: Hook de formulario orientado al rendimiento

typescript
// hooks/useOptimizedForm.ts
import { useCallback, useMemo, useRef } from "react";
import { useForm, FieldPath, FieldValues } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
 
export function useOptimizedForm<T extends FieldValues>(
	schema: z.ZodSchema<T>,
	options?: {
		debounceMs?: number;
		validateOnMount?: boolean;
	}
) {
	const { debounceMs = 300, validateOnMount = false } = options || {};
 
	const form = useForm<T>({
		resolver: zodResolver(schema),
		mode: "onTouched",
		reValidateMode: "onChange",
		shouldFocusError: true,
	});
 
	const debounceRef = useRef<Record<string, NodeJS.Timeout>>({});
 
	const debouncedValidate = useCallback(
		(fieldName: FieldPath<T>, value: any) => {
			if (debounceRef.current[fieldName]) {
				clearTimeout(debounceRef.current[fieldName]);
			}
 
			debounceRef.current[fieldName] = setTimeout(() => {
				form.trigger(fieldName);
			}, debounceMs);
		},
		[form, debounceMs]
	);
 
	const optimizedRegister = useCallback(
		(fieldName: FieldPath<T>) => {
			const registration = form.register(fieldName);
 
			return {
				...registration,
				onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
					registration.onChange(e);
					debouncedValidate(fieldName, e.target.value);
				},
			};
		},
		[form, debouncedValidate]
	);
 
	// Memoized error helper
	const getFieldError = useCallback(
		(fieldName: FieldPath<T>) => {
			return form.formState.errors[fieldName]?.message;
		},
		[form.formState.errors]
	);
 
	// Optimized submit with loading state
	const handleSubmit = useMemo(
		() =>
			form.handleSubmit(async (data: T) => {
				// Clear all debounce timers
				Object.values(debounceRef.current).forEach(clearTimeout);
 
				// Perform final validation
				const isValid = await form.trigger();
				if (!isValid) return;
 
				return data;
			}),
		[form]
	);
 
	return {
		...form,
		register: optimizedRegister,
		handleSubmit,
		getFieldError,
	};
}

Gestión de errores y experiencia de usuario

Importante: Gestión de errores amigable para el usuario

  • Muestra los errores en un lenguaje comprensible - Separa los errores por campo de los del formulario - Añade estados de carga - No olvides el feedback de éxito - Gestiona los errores de red de forma elegante

Error Boundary global

Ejemplo: Error Boundary para formularios

typescript
// components/forms/FormErrorBoundary.tsx
"use client";
 
import { Component, ReactNode } from "react";
 
interface Props {
	children: ReactNode;
	fallback?: ReactNode;
}
 
interface State {
	hasError: boolean;
	error?: Error;
}
 
export class FormErrorBoundary extends Component<Props, State> {
	constructor(props: Props) {
		super(props);
		this.state = { hasError: false };
	}
 
	static getDerivedStateFromError(error: Error): State {
		return { hasError: true, error };
	}
 
	componentDidCatch(error: Error, errorInfo: any) {
		console.error("Form error:", error, errorInfo);
 
		// Error reporting service'e gönder
		// analytics.track('form_error', { error: error.message, ...errorInfo })
	}
 
	render() {
		if (this.state.hasError) {
			return (
				this.props.fallback || (
					<div className="p-4 bg-red-50 border border-red-200 rounded-md">
						<h3 className="text-lg font-medium text-red-800">Form Hatası</h3>
						<p className="mt-2 text-sm text-red-700">
							Form işlenirken bir hata oluştu. Sayfayı yenilemeyi deneyin.
						</p>
						<button
							onClick={() => window.location.reload()}
							className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
						>
							Sayfayı Yenile
						</button>
					</div>
				)
			);
		}
 
		return this.props.children;
	}
}
También hay un vídeo explicativo de este tema.

YouTube

También hay un vídeo explicativo de este tema.

Ver el vídeo →

Conclusión y buenas prácticas

Resumen: Buenas prácticas de validación de formularios

✅ Qué hacer:

  • Usa validación tanto en el cliente como en el servidor
  • Para una validación con seguridad de tipos, prefiere Zod o librerías similares
  • Escribe mensajes de error amigables para el usuario
  • Aplica debouncing en la validación en tiempo real
  • Añade estados de carga y feedback de éxito
  • Adopta el principio de mejora progresiva

❌ Qué no hacer:

  • No te fíes solo de la validación en el cliente
  • No hagas una validación en tiempo real demasiado agresiva
  • No uses mensajes de error genéricos
  • No guardes el estado del formulario en el estado global (salvo que sea necesario)
  • No repitas la lógica de validación

Consejo: Alternativas modernas

En este artículo he explicado la combinación React Hook Form + Zod, pero existen otras opciones populares:

  • Formik + Yup: Más antigua pero estable
  • React Final Form: Tamaño de bundle mínimo
  • TanStack Form: Agnóstico del framework
  • Conform: Orientada a la mejora progresiva

Nota: Más recursos

Para profundizar en la validación de formularios:

Hoja de ruta y herramienta interactivas

Sigue leyendo