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
@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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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;
}
}
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
Desarrollador Frontend Junior 2026
Lo que realmente necesitas saber para conseguir un trabajo de frontend junior en 2026 y lo que puede esperar, a partir de ofertas reales.
¿SQL o NoSQL?
Responde seis preguntas y te diré qué tipo de base de datos encaja con tu proyecto, y por qué.