Next.js Form Validasyon Yöntemleri: Client ve Server Side Doğrulama Rehberi
Not (Modern Form Validasyon)
Bu yazıda Next.js 14+ ile modern form validasyon yöntemlerini keşfedeceksin. Client-side ve server-side validasyon tekniklerini, TypeScript ile tip güvenliği sağlamayı ve performans optimizasyonlarını öğreneceksin.
Form validasyonu web geliştirmede kritik bir konudur. Kullanıcı deneyimini iyileştirmek, güvenlik açıklarını önlemek ve veri bütünlüğünü sağlamak için doğru validasyon stratejileri gereklidir. Bu yazıda Next.js’te modern form validasyon yöntemlerini detaylı örneklerle anlatacağım.
Form Validasyon Temelleri
Tanım (Validasyon Katmanları)
Modern web uygulamalarında 3 katmanlı validasyon yaklaşımı önerilir:
1. Client-side: Hızlı geri bildirim ve UX iyileştirmesi
2. Server-side: Güvenlik ve veri bütünlüğü
3. Database level: Son güvenlik katmanı
React Hook Form + Zod ile Modern Validasyon
Ipucu (Kurulum)
npm install react-hook-form zod @hookform/resolvers
npm install -D @types/node
Temel Schema Tanımlama
Örnek (Zod Schema Örneği)
// 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>
Client-Side Form Komponenti
Örnek (React Hook Form ile Form Komponenti)
// 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 Actions ile Server-Side Validasyon
Önemli (Next.js 14 Server Actions)
Server Actions, form verilerini doğrudan server-side’da işlemek için güçlü bir yöntemdir. Bu yaklaşım hem performans hem güvenlik açısından avantaj sağlar.
Server Action Tanımlama
Örnek (Server Action Örneği)
// 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
}
Server Action ile Form Kullanımı
Örnek (Progressive Enhancement Form)
// 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>
)
}
Kompleks Form Validasyonu: Ürün Ekleme Formu
Örnek (Gelişmiş Validasyon Örneği)
// 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>
Real-time Validasyon ve Debouncing
Örnek (Performanslı Real-time Validasyon)
// 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>
)
}
API Routes ile Validasyon
Örnek (API Route Validasyon)
// 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 }
)
}
}
Performans Optimizasyonları
Ipucu (Form Performans İpuçları)
- Debouncing: Real-time validasyon için 300-500ms gecikme kullan
- Field-level validation: Sadece değişen alanları validate et
- Lazy loading: Büyük form’ları adım adım yükle
- Optimistic updates: Başarılı validasyon sonrası UI’ı hemen güncelle
Optimized Form Hook
Örnek (Performans Odaklı Form 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,
}
}
Error Handling ve User Experience
Önemli (Kullanıcı Dostu Hata Yönetimi)
- Hataları anlaşılır dilde göster
- Field-level ve form-level hataları ayır
- Loading state’leri ekle
- Success feedback’i unutma
- Network hatalarını graceful handle et
Global Error Boundary
Örnek (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
}
}
Video Kaynakları

Furkan Özay
Sonuç ve Best Practice’ler
Özet (Form Validasyon Best Practice'leri)
- Hem client hem server-side validasyon kullan
- Type-safe validasyon için Zod veya benzer kütüphaneler tercih et
- Kullanıcı dostu hata mesajları yaz
- Real-time validasyon için debouncing uygula
- Loading state’leri ve success feedback’i ekle
- Progressive enhancement prensibini benimse
- Sadece client-side validasyon’a güvenme
- Çok agresif real-time validasyon yapma
- Genel hata mesajları kullanma
- Form state’ini global state’te tutma (gerekmedikçe)
- Validation logic’i tekrarlama
Ipucu (Modern Alternatifler)
Bu yazıda React Hook Form + Zod kombinasyonunu anlattım, ancak diğer popüler seçenekler de mevcut:
- Formik + Yup: Daha eski ama stabil
- React Final Form: Minimal bundle size
- TanStack Form: Framework agnostic
- Conform: Progressive enhancement odaklı
Not (Daha Fazla Kaynak)
Form validasyon konusunu derinlemesine öğrenmek için: