Musa Yılmaz
·5 dk okuma

Next.js Server Actions ile Form Doğrulama: Zod, Rate Limit ve Spam Koruması

nextjsreactzodform

Server Actions, form işlemek için ayrı bir API route yazma ihtiyacını ortadan kaldırır. Fonksiyonu sunucuda tanımlar, doğrudan formdan çağırırsınız. Ama pratikte birkaç ayrıntı var — özellikle doğrulama, spam koruması ve FormData okuma konusunda.

Temel kurulum

Server Action, dosyanın başına "use server" yazılarak tanımlanır:

// app/iletisim/actions.ts
"use server";
 
export async function submitContactForm(formData: FormData) {
  const name = formData.get("name");
  const email = formData.get("email");
  // ...
}

Bu fonksiyon istemci paketine dahil edilmez; sadece sunucuda çalışır. İstemci bir referans çağırır, Next.js isteği sunucuya taşır.

Sunucu tarafı doğrulama şart

HTML doğrulaması (required, type="email") kullanıcı deneyimi içindir, güvenlik için değildir. İstemciden gelen her veri şüpheli kabul edilmelidir — tarayıcı geliştirici araçlarıyla veya doğrudan HTTP isteğiyle bu kontroller atlanabilir.

Zod ile şema tanımlamak hem doğrulama hem tip güvenliği sağlar:

import { z } from "zod";
 
const contactSchema = z.object({
  name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100),
  email: z.string().trim().email("Geçerli bir e-posta girin").max(254),
  subject: z.string().trim().min(3).max(150),
  message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000),
});

Üst sınırları (max) atlamayın. Sınırsız uzunlukta bir metin, hem veritabanınızı hem e-posta servisinizi zorlar ve kaynak tüketim saldırısına açık kapı bırakır.

FormData okuma tuzağı

Burada pratikte karşılaşılan ve tanısı zor bir davranış var. React bazı durumlarda form alanlarını ön ekle kodlar; formData.get("email") beklediğiniz değeri değil null döndürür, çünkü anahtar aslında _1_email gibidir.

Belirti şudur: form gönderilir, doğrulama "e-posta zorunlu" der, ama kullanıcı alanı doldurmuştur.

Tanı koymanın en hızlı yolu gelen anahtarları loglamaktır:

console.log([...formData.keys()]);

Dayanıklı çözüm, anahtarı sondan eşleştirmektir:

function getField(formData: FormData, name: string): FormDataEntryValue | null {
  const exact = formData.get(name);
  if (exact !== null) return exact;
 
  for (const key of formData.keys()) {
    if (key === name || key.endsWith(`_${name}`)) {
      return formData.get(key);
    }
  }
  return null;
}

Bu yardımcı her iki durumda da çalışır ve kodun kırılganlığını ortadan kaldırır.

Honeypot ile bot filtreleme

Botların çoğu formdaki tüm alanları doldurur. CSS ile gizlenmiş bir alan koyup dolu gelirse isteği sessizce yok sayabilirsiniz:

<div aria-hidden="true" className="absolute left-[-9999px]">
  <label htmlFor="website">Web sitesi</label>
  <input id="website" name="website" tabIndex={-1} autoComplete="off" />
</div>
const honeypot = getField(formData, "website");
if (typeof honeypot === "string" && honeypot.trim() !== "") {
  // Bot yakalandı; başarı döndürüp sessizce yok sayıyoruz
  return { success: true };
}

İki ayrıntı önemli:

Gizleme yöntemi. display: none yerine ekran dışına taşıyın — bazı botlar display: none alanları atlayacak kadar gelişmiştir.

Erişilebilirlik. aria-hidden ve tabIndex={-1} ekran okuyucuların ve klavye kullanıcılarının bu alana takılmasını önler. Aksi halde görme engelli bir kullanıcı görünmez bir alanı doldurmaya çalışır.

Bota başarı yanıtı dönmek bilinçli bir tercihtir: hata dönerseniz bot yakalandığını anlar ve yöntemini değiştirir.

Hız sınırlama (rate limit)

Aynı IP'den arka arkaya gönderim yapılmasını engellemek, hem spam'i hem maliyeti azaltır:

const WINDOW_MS = 60_000;
const MAX_REQUESTS = 3;
const attempts = new Map<string, number[]>();
 
function isRateLimited(ip: string): boolean {
  const now = Date.now();
  const recent = (attempts.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
 
  if (recent.length >= MAX_REQUESTS) return true;
 
  recent.push(now);
  attempts.set(ip, recent);
  return false;
}

IP adresini almak için:

import { headers } from "next/headers";
 
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";

Bu bellek içi çözümün sınırı şudur: sunucusuz ortamda her örnek kendi belleğine sahiptir ve örnekler yeniden başlatılabilir. Küçük bir portföy sitesinde yeterlidir; ciddi trafik varsa Redis gibi paylaşımlı bir depo gerekir.

Yapılandırılmış sonuç döndürmek

Action'ın ne döndürdüğü, arayüzün ne kadar iyi geri bildirim verebileceğini belirler. Boolean yerine ayrıntılı bir nesne döndürün:

type ContactResult = {
  success: boolean;
  reason?: "validation" | "rate-limit" | "send-failed";
  fieldErrors?: Record<string, string[]>;
};
const parsed = contactSchema.safeParse({
  name: getField(formData, "name"),
  email: getField(formData, "email"),
  subject: getField(formData, "subject"),
  message: getField(formData, "message"),
});
 
if (!parsed.success) {
  return {
    success: false,
    reason: "validation",
    fieldErrors: parsed.error.flatten().fieldErrors,
  };
}

Böylece istemci "bir hata oluştu" yerine hangi alanın neden reddedildiğini gösterebilir.

Harici servis hatalarını yakalamak

Bazı SDK'lar hata durumunda istisna fırlatmaz, hatayı dönüş değerinde taşır. Bu durumda try/catch yeterli değildir:

const result = await emailClient.send({ ... });
 
// Hata nesnesi dönüş değerinde gelir; kontrol etmeyi unutmayın
if (result.error) {
  console.error("E-posta gönderilemedi:", result.error);
  return { success: false, reason: "send-failed" };
}

Bunu atlamak, kullanıcıya "mesajınız iletildi" derken hiçbir e-postanın gitmediği sessiz bir hataya yol açar. Kullandığınız kütüphanenin dokümanında hata davranışını mutlaka kontrol edin.

İstemci tarafı

useActionState (React 19) bekleyen durumu ve sonucu birlikte yönetir:

"use client";
 
import { useActionState } from "react";
import { submitContactForm } from "./actions";
 
export function ContactForm() {
  const [state, action, pending] = useActionState(submitContactForm, null);
 
  return (
    <form action={action}>
      <input name="name" required minLength={2} />
      {state?.fieldErrors?.name && (
        <p role="alert">{state.fieldErrors.name[0]}</p>
      )}
 
      <textarea name="message" required minLength={10} />
      <p id="message-hint">En az 10 karakter.</p>
 
      <button type="submit" disabled={pending}>
        {pending ? "Gönderiliyor..." : "Gönder"}
      </button>
 
      {state?.success && <p role="status">Mesajınız iletildi.</p>}
    </form>
  );
}

role="alert" ve role="status" ekran okuyucuların değişikliği duyurmasını sağlar. Sadece renkle hata göstermek, görme engelli kullanıcılar için erişilemez bir arayüz demektir.

İstemci tarafındaki minLength sunucudaki kuralla aynı olmalıdır. Farklı olursa kullanıcı formu gönderir, sunucu reddeder ve neden reddedildiğini anlamaz.

Kontrol listesi

Bir form yayına çıkmadan önce:

  • Sunucu tarafı doğrulama var mı, sadece HTML'e mi güveniliyor?
  • Tüm metin alanlarında üst sınır tanımlı mı?
  • Honeypot alanı erişilebilir şekilde gizlenmiş mi?
  • Hız sınırlaması var mı?
  • Harici servisin hata dönüşü kontrol ediliyor mu?
  • Hata mesajları hangi alanın sorunlu olduğunu söylüyor mu?
  • Gönderim sırasında buton devre dışı kalıyor mu?

Bu listeyi geçen bir form, hem kullanıcıya net geri bildirim verir hem de sizi gereksiz spam ve maliyetten korur.