Artikel

Formularvalidierung in Next.js: Leitfaden für Client- und Server-seitige Validierung

Moderne Methoden der Formularvalidierung in Next.js: Zod, React Hook Form, Server Actions und TypeScript für sichere Formularverarbeitung, mit Beispielen für Client- und Server-seitige Validierung.

Furkan Özay1. Juli 2025 · 12 Min. Lesezeit
czCZAY.DEV // EDITORIALDE
TYPE SYSTEM

#TypeScript · 12 MIN LESEZEIT

FURKAN ÖZAYVOL. 2025 // #82EF

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

Bash
@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

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>
	);
}

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

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;
}

Ein Formular mit Server Action verwenden

Beispiel: Formular mit Progressive Enhancement

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>
	);
}

Komplexe Formularvalidierung: Formular zum Anlegen eines Produkts

Beispiel: Beispiel für fortgeschrittene Validierung

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>;

Echtzeit-Validierung und Debouncing

Beispiel: Performante Echtzeit-Validierung

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>
	);
}

Validierung mit API Routes

Beispiel: Validierung in einer 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 }
		);
	}
}

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

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,
	};
}

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

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;
	}
}
Zu diesem Thema gibt es auch ein Video-Walkthrough.

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

Als Nächstes lesen