Hinweis: Moderne Formularvalidierung
In diesem Beitrag lernst du moderne Methoden der Formularvalidierung mit Next.js 14+ kennen. Du erfährst, wie Client- und Server-seitige Validierung funktionieren, wie du mit TypeScript Typsicherheit herstellst und wie du die Performance optimierst.
Formularvalidierung ist ein zentrales Thema in der Webentwicklung. Um die User Experience zu verbessern, Sicherheitslücken zu vermeiden und die Datenintegrität zu gewährleisten, brauchst du die richtigen Validierungsstrategien. In diesem Beitrag zeige ich dir anhand ausführlicher Beispiele moderne Methoden der Formularvalidierung in Next.js.
Grundlagen der Formularvalidierung
Definition: Validierungsebenen
Für moderne Webanwendungen empfiehlt sich ein Validierungsansatz mit drei Ebenen:
1. Client-side: Schnelles Feedback und bessere UX
2. Server-side: Sicherheit und Datenintegrität
3. Datenbankebene: Die letzte Sicherheitsschicht
Moderne Validierung mit React Hook Form + Zod
Tipp: Installation
@types/node ```
</Callout>
### Ein einfaches Schema definieren
<Callout title="Beispiel für ein Zod-Schema" 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>;Formular-Komponente auf der Client-Seite
Beispiel: Formular-Komponente mit 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>
);
}Server-seitige Validierung mit Server Actions
Wichtig: Next.js 14 Server Actions
Server Actions sind eine leistungsstarke Methode, um Formulardaten direkt auf dem Server zu verarbeiten. Dieser Ansatz bringt Vorteile bei Performance und Sicherheit.
Eine Server Action definieren
Beispiel: Beispiel für eine 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;
}Ein Formular mit Server Action verwenden
Beispiel: Formular mit Progressive Enhancement
// 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>
);
}Komplexe Formularvalidierung: Formular zum Anlegen eines Produkts
Beispiel: Beispiel für fortgeschrittene Validierung
// 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>;Echtzeit-Validierung und Debouncing
Beispiel: Performante Echtzeit-Validierung
// 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>
);
}Validierung mit API Routes
Beispiel: Validierung in einer 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 }
);
}
}Performance-Optimierungen
Tipp: Tipps für die Formular-Performance
- Debouncing: Nutze für die Echtzeit-Validierung eine Verzögerung von 300-500 ms
- Field-level validation: Validiere nur die Felder, die sich geändert haben
- Lazy loading: Lade große Formulare Schritt für Schritt
- Optimistic updates: Aktualisiere die UI direkt nach erfolgreicher Validierung
Optimierter Formular-Hook
Beispiel: Performance-orientierter Formular-Hook
// 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,
};
}Fehlerbehandlung und User Experience
Wichtig: Nutzerfreundliche Fehlerbehandlung
- Zeige Fehler in verständlicher Sprache an - Trenne Field-level- und Form-level-Fehler - Ergänze Loading States - Vergiss das Success-Feedback nicht - Behandle Netzwerkfehler graceful
Global Error Boundary
Beispiel: Form Error Boundary
// 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
Zu diesem Thema gibt es auch ein Video-Walkthrough.
Video ansehen →
Fazit und Best Practices
Zusammenfassung: Best Practices für die Formularvalidierung
✅ Das solltest du tun:
- Nutze sowohl Client- als auch Server-seitige Validierung
- Setze für typsichere Validierung auf Zod oder ähnliche Bibliotheken
- Schreibe nutzerfreundliche Fehlermeldungen
- Wende Debouncing bei der Echtzeit-Validierung an
- Ergänze Loading States und Success-Feedback
- Befolge das Prinzip des Progressive Enhancement
❌ Das solltest du lassen:
- Verlass dich nicht allein auf die Client-seitige Validierung
- Validiere in Echtzeit nicht zu aggressiv
- Verwende keine generischen Fehlermeldungen
- Halte den Formular-State nicht im globalen State (außer es ist nötig)
- Wiederhole die Validierungslogik nicht
Tipp: Moderne Alternativen
In diesem Beitrag habe ich die Kombination aus React Hook Form + Zod vorgestellt, es gibt aber auch andere beliebte Optionen:
- Formik + Yup: Älter, aber stabil
- React Final Form: Minimale Bundle Size
- TanStack Form: Framework-agnostisch
- Conform: Fokus auf Progressive Enhancement
Hinweis: Weitere Ressourcen
Wenn du tiefer in die Formularvalidierung einsteigen möchtest:
Interaktive Roadmap & Tool
Junior Frontend Developer 2026
Was du wirklich wissen musst, um 2026 einen Junior-Frontend-Job zu bekommen, und was warten kann – abgeleitet aus echten Stellenanzeigen.
SQL oder NoSQL?
Beantworte sechs Fragen, und ich sage dir, welche Art von Datenbank zu deinem Projekt passt und warum.