Next.js Server Actions Rehberi: Güvenlik, Zod Doğrulama ve İleri Düzey Kalıplar

Next.js Server Actions ile güvenli ve performanslı full-stack React uygulamaları geliştirmek için kapsamlı rehber. Zod doğrulama, useActionState, önbellek yönetimi, Prisma CRUD ve optimistik güncellemeler gibi ileri düzey kalıpları öğrenin.

Giriş

Next.js, React ekosisteminin en güçlü framework'lerinden biri ve sürekli evrim geçiriyor. Next.js 13 ile gelen App Router mimarisi, sunucu tarafı rendering ile istemci tarafı etkileşimi aynı çatı altında yönetmeyi mümkün kıldı. Ama bence asıl devrim, Server Actions ile geldi.

Next.js 14'te kararlı hale gelen ve 15/16 sürümleriyle iyice olgunlaşan Server Actions, full-stack React uygulamaları geliştirme biçimimizi kökten değiştirdi.

Bir düşünün: geleneksel yaklaşımda basit bir form gönderimi için bile API route oluşturmak, istemcide fetch yazmak, loading state'ini yönetmek, hata işleme mantığı kurmak ve önbellek stratejisi belirlemek gerekiyordu. Server Actions tüm bu katmanları ortadan kaldırarak sunucu tarafı mutasyonlarını doğrudan React bileşenlerinin içinden çağırmanıza olanak tanıyor. Bu sadece daha az kod yazmak değil; daha güvenli, daha performanslı ve bakımı daha kolay uygulamalar inşa etmek demek.

Bu rehberde, Server Actions'ın temellerinden ileri düzey kalıplara kadar her şeyi ele alacağız. Güvenlik best practice'leri, Zod ile doğrulama, önbellek yönetimi, veritabanı işlemleri ve optimistik güncellemeler gibi konulara derinlemesine dalacağız. İster Next.js'e yeni başlıyor olun ister mevcut projelerinizi modernize etmek istiyor olun, bu rehber size sağlam bir temel sunacak.

Server Actions Nedir?

Server Actions, React'in sunucu tarafında çalışan asenkron fonksiyonlarıdır. "use server" direktifi ile işaretlenen bu fonksiyonlar istemci bileşenlerinden doğrudan çağrılabilir, ama her zaman sunucuda yürütülür. Geleneksel API endpoint'lerine olan ihtiyacı büyük ölçüde ortadan kaldıran bir mekanizma.

Perde Arkasında Ne Oluyor?

Bir Server Action tanımladığınızda, Next.js derleme aşamasında birkaç kritik işlem gerçekleştirir:

  • Benzersiz kimlik atama: Her server action'a non-deterministic benzersiz bir kimlik atanır. İstemcinin hangi sunucu fonksiyonunu çağıracağını belirlemek için bu kimlik kullanılır.
  • HTTP POST endpoint oluşturma: Her server action otomatik olarak bir HTTP POST endpoint'ine dönüşür. İstemci tarafından yapılan çağrılar bu endpoint'e POST istekleri olarak gider.
  • Serileştirme: Argümanlar ve dönüş değerleri React'in serileştirme protokolü aracılığıyla aktarılır. Yani yalnızca serileştirilebilir değerler (string, number, boolean, Date, FormData, plain object, array vb.) taşınabilir.
  • Güvenlik katmanı: Next.js CSRF korumasını otomatik olarak sağlar. Her server action çağrısı geçerli bir origin başlığı kontrolünden geçer.

"use server" Direktifi

"use server" direktifi iki farklı şekilde kullanılabiliyor. Birincisi, dosyanın en üstüne yerleştirerek o dosyadaki tüm export edilen fonksiyonları server action olarak işaretleyebilirsiniz. İkincisi ise bir asenkron fonksiyonun gövdesinin başına koyarak sadece o fonksiyonu server action yapabilirsiniz.

// Dosya düzeyinde kullanım: app/actions.ts
"use server";

// Bu dosyadaki tüm export edilen fonksiyonlar server action'dır
export async function kullaniciyiKaydet(formData: FormData) {
  // Sunucuda çalışır
  const isim = formData.get("isim") as string;
  // Veritabanı işlemi...
}

export async function kullaniciyiSil(id: string) {
  // Bu da sunucuda çalışır
  // Veritabanı işlemi...
}

Burada çok önemli bir nokta var: "use server" direktifi fonksiyonun sunucuda çalışacağını garanti eder ama güvenli olduğunu garanti etmez. Her server action, potansiyel olarak herhangi bir istemci tarafından çağrılabilecek açık bir API endpoint'idir. Bu yüzden her zaman giriş doğrulaması ve yetkilendirme kontrolü yapmanız şart.

Server Actions vs API Routes

Server Actions ve Route Handlers (API Routes) aslında farklı kullanım senaryoları için tasarlanmış. Server Actions form gönderimleri ve veri mutasyonları için ideal: doğrudan bileşenlerden çağrılabiliyor, progressive enhancement'ı destekliyor ve React'in önbellek mekanizmasıyla sorunsuz çalışıyor. Route Handlers ise harici API'ler oluşturmak, webhook'ları işlemek veya üçüncü taraf entegrasyonları için daha uygun.

İlk Server Action'ınızı Oluşturma

Haydi, adım adım ilk server action'ımızı oluşturalım. İki farklı yaklaşımı inceleyeceğiz: ayrı dosyada tanımlama ve satır içi (inline) tanımlama.

Yaklaşım 1: Ayrı Dosyada Tanımlama (Önerilen)

En yaygın ve önerilen yaklaşım, server action'larınızı ayrı bir dosyada tanımlamak. Kodunuzu temiz tutar, yeniden kullanılabilirliği artırır.

// app/actions/mesaj-actions.ts
"use server";

import { revalidatePath } from "next/cache";

interface MesajSonucu {
  basarili: boolean;
  mesaj: string;
}

export async function mesajGonder(formData: FormData): Promise<MesajSonucu> {
  // Sunucu tarafında çalışır
  const icerik = formData.get("icerik") as string;
  const gonderenId = formData.get("gonderenId") as string;

  if (!icerik || icerik.trim().length === 0) {
    return {
      basarili: false,
      mesaj: "Mesaj içeriği boş olamaz.",
    };
  }

  try {
    // Veritabanına kaydetme simülasyonu
    await new Promise((resolve) => setTimeout(resolve, 1000));

    console.log(`Yeni mesaj kaydedildi: ${icerik} (Gönderen: ${gonderenId})`);

    // İlgili sayfanın önbelleğini geçersiz kıl
    revalidatePath("/mesajlar");

    return {
      basarili: true,
      mesaj: "Mesajınız başarıyla gönderildi!",
    };
  } catch (hata) {
    return {
      basarili: false,
      mesaj: "Mesaj gönderilirken bir hata oluştu.",
    };
  }
}

Şimdi bu action'ı bir bileşende kullanalım:

// app/mesajlar/page.tsx
import { mesajGonder } from "@/app/actions/mesaj-actions";

export default function MesajlarSayfasi() {
  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-4">Mesaj Gönder</h1>
      <form action={mesajGonder} className="space-y-4">
        <input type="hidden" name="gonderenId" value="kullanici-123" />
        <div>
          <label htmlFor="icerik" className="block text-sm font-medium">
            Mesajınız
          </label>
          <textarea
            id="icerik"
            name="icerik"
            rows={4}
            className="mt-1 block w-full rounded-md border p-2"
            placeholder="Mesajınızı yazın..."
            required
          />
        </div>
        <button
          type="submit"
          className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
        >
          Gönder
        </button>
      </form>
    </div>
  );
}

Yaklaşım 2: Sunucu Bileşeninde Satır İçi Tanımlama

Server Component'larda fonksiyonun gövdesine "use server" direktifini ekleyerek satır içi server action tanımlayabilirsiniz:

// app/abone/page.tsx
import { redirect } from "next/navigation";

export default function AboneSayfasi() {
  async function aboneOl(formData: FormData) {
    "use server";

    const email = formData.get("email") as string;

    // E-posta doğrulama
    if (!email || !email.includes("@")) {
      throw new Error("Geçerli bir e-posta adresi giriniz.");
    }

    // Veritabanına kaydet
    // await db.abone.create({ data: { email } });

    redirect("/abone/tesekkurler");
  }

  return (
    <form action={aboneOl} className="flex gap-2">
      <input
        type="email"
        name="email"
        placeholder="E-posta adresiniz"
        className="border rounded px-3 py-2"
        required
      />
      <button type="submit" className="bg-green-600 text-white px-4 py-2 rounded">
        Abone Ol
      </button>
    </form>
  );
}

Satır içi tanımlama küçük ve tek kullanımlık action'lar için gayet pratik. Ama karmaşık iş mantığı veya birden fazla bileşende yeniden kullanım gerektiren durumlarda ayrı dosya yaklaşımını tercih etmenizi öneririm.

Form İşleme ve useActionState Hook'u

Gerçek dünya uygulamalarında form gönderiminin sonucunu kullanıcıya göstermek, loading state'ini yönetmek ve hata mesajlarını görüntülemek gerekiyor. İşte tam bu noktada React 19 ile gelen useActionState hook'u (eski adıyla useFormState) devreye giriyor.

useActionState Hook'u

useActionState, bir server action'ı sarmalayarak üç önemli değer döndürür: mevcut state, sarmalanmış action fonksiyonu ve pending durumu.

// app/actions/iletisim-actions.ts
"use server";

export interface IletisimFormDurumu {
  basarili: boolean;
  mesaj: string;
  hatalar?: {
    isim?: string[];
    email?: string[];
    konu?: string[];
    icerik?: string[];
  };
}

export async function iletisimFormuGonder(
  oncekiDurum: IletisimFormDurumu,
  formData: FormData
): Promise<IletisimFormDurumu> {
  const isim = formData.get("isim") as string;
  const email = formData.get("email") as string;
  const konu = formData.get("konu") as string;
  const icerik = formData.get("icerik") as string;

  // Basit doğrulama
  const hatalar: IletisimFormDurumu["hatalar"] = {};

  if (!isim || isim.trim().length < 2) {
    hatalar.isim = ["İsim en az 2 karakter olmalıdır."];
  }
  if (!email || !email.includes("@")) {
    hatalar.email = ["Geçerli bir e-posta adresi giriniz."];
  }
  if (!konu || konu.trim().length < 5) {
    hatalar.konu = ["Konu en az 5 karakter olmalıdır."];
  }
  if (!icerik || icerik.trim().length < 20) {
    hatalar.icerik = ["Mesaj en az 20 karakter olmalıdır."];
  }

  if (Object.keys(hatalar).length > 0) {
    return {
      basarili: false,
      mesaj: "Lütfen formdaki hataları düzeltin.",
      hatalar,
    };
  }

  try {
    // E-posta gönderme veya veritabanı kaydı simülasyonu
    await new Promise((resolve) => setTimeout(resolve, 2000));

    return {
      basarili: true,
      mesaj: "Mesajınız başarıyla gönderildi! En kısa sürede dönüş yapacağız.",
    };
  } catch (hata) {
    return {
      basarili: false,
      mesaj: "Bir hata oluştu. Lütfen daha sonra tekrar deneyin.",
    };
  }
}

Şimdi bu action'ı useActionState ile kullanan istemci bileşenini oluşturalım:

// app/iletisim/iletisim-formu.tsx
"use client";

import { useActionState } from "react";
import { iletisimFormuGonder, type IletisimFormDurumu } from "@/app/actions/iletisim-actions";

const baslangicDurumu: IletisimFormDurumu = {
  basarili: false,
  mesaj: "",
};

export function IletisimFormu() {
  const [durum, formAction, bekleniyor] = useActionState(
    iletisimFormuGonder,
    baslangicDurumu
  );

  return (
    <form action={formAction} className="space-y-6 max-w-lg">
      {/* Durum mesajı */}
      {durum.mesaj && (
        <div
          className={`p-4 rounded-md ${
            durum.basarili
              ? "bg-green-50 text-green-800 border border-green-200"
              : "bg-red-50 text-red-800 border border-red-200"
          }`}
        >
          {durum.mesaj}
        </div>
      )}

      {/* İsim alanı */}
      <div>
        <label htmlFor="isim" className="block text-sm font-medium">
          Adınız Soyadınız
        </label>
        <input
          type="text"
          id="isim"
          name="isim"
          className="mt-1 block w-full rounded-md border p-2"
          disabled={bekleniyor}
        />
        {durum.hatalar?.isim && (
          <p className="mt-1 text-sm text-red-600">{durum.hatalar.isim[0]}</p>
        )}
      </div>

      {/* E-posta alanı */}
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          E-posta Adresi
        </label>
        <input
          type="email"
          id="email"
          name="email"
          className="mt-1 block w-full rounded-md border p-2"
          disabled={bekleniyor}
        />
        {durum.hatalar?.email && (
          <p className="mt-1 text-sm text-red-600">{durum.hatalar.email[0]}</p>
        )}
      </div>

      {/* Konu alanı */}
      <div>
        <label htmlFor="konu" className="block text-sm font-medium">
          Konu
        </label>
        <input
          type="text"
          id="konu"
          name="konu"
          className="mt-1 block w-full rounded-md border p-2"
          disabled={bekleniyor}
        />
        {durum.hatalar?.konu && (
          <p className="mt-1 text-sm text-red-600">{durum.hatalar.konu[0]}</p>
        )}
      </div>

      {/* Mesaj alanı */}
      <div>
        <label htmlFor="icerik" className="block text-sm font-medium">
          Mesajınız
        </label>
        <textarea
          id="icerik"
          name="icerik"
          rows={5}
          className="mt-1 block w-full rounded-md border p-2"
          disabled={bekleniyor}
        />
        {durum.hatalar?.icerik && (
          <p className="mt-1 text-sm text-red-600">{durum.hatalar.icerik[0]}</p>
        )}
      </div>

      {/* Gönder düğmesi */}
      <button
        type="submit"
        disabled={bekleniyor}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md
                   hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
                   transition-colors"
      >
        {bekleniyor ? "Gönderiliyor..." : "Mesajı Gönder"}
      </button>
    </form>
  );
}

useActionState hook'unun çalışma mantığı şöyle: ilk parametre olarak server action fonksiyonunu alır (bu fonksiyonun ilk parametresi önceki durum, ikincisi FormData olmalı). İkinci parametre başlangıç durumu. Döndürdüğü üçlüdeki bekleniyor değeri formun gönderim sürecinde olup olmadığını bildirir — böylece loading göstergesi veya form alanlarını devre dışı bırakma gibi UX iyileştirmelerini kolayca uygulayabilirsiniz.

Zod ile Veri Doğrulama

Üretim ortamında veri doğrulama en kritik güvenlik katmanlarından biri. Açıkçası, bu konuda Zod gerçekten harika bir iş çıkarıyor. TypeScript ile mükemmel uyum sağlayan bu şema doğrulama kütüphanesi, Server Actions ile birlikte kullanıldığında hem tip güvenliği hem de runtime doğrulaması sunuyor.

Zod Şeması Oluşturma

// lib/validasyon/kullanici-semasi.ts
import { z } from "zod";

export const kullaniciOlusturmaSemasi = z.object({
  isim: z
    .string()
    .min(2, "İsim en az 2 karakter olmalıdır.")
    .max(50, "İsim en fazla 50 karakter olabilir.")
    .trim(),
  email: z
    .string()
    .email("Geçerli bir e-posta adresi giriniz.")
    .toLowerCase(),
  sifre: z
    .string()
    .min(8, "Şifre en az 8 karakter olmalıdır.")
    .max(128, "Şifre en fazla 128 karakter olabilir.")
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "Şifre en az bir küçük harf, bir büyük harf ve bir rakam içermelidir."
    ),
  sifreOnay: z.string(),
  yas: z
    .number()
    .int("Yaş tam sayı olmalıdır.")
    .min(18, "En az 18 yaşında olmalısınız.")
    .max(120, "Geçerli bir yaş giriniz."),
  telefon: z
    .string()
    .regex(
      /^(\+90|0)?[0-9]{10}$/,
      "Geçerli bir Türkiye telefon numarası giriniz."
    )
    .optional(),
}).refine((veri) => veri.sifre === veri.sifreOnay, {
  message: "Şifreler eşleşmiyor.",
  path: ["sifreOnay"],
});

// Tip çıkarımı
export type KullaniciOlusturmaGirdisi = z.infer<typeof kullaniciOlusturmaSemasi>;

Server Action'da Zod Kullanımı

// app/actions/kullanici-actions.ts
"use server";

import { kullaniciOlusturmaSemasi } from "@/lib/validasyon/kullanici-semasi";
import { revalidatePath } from "next/cache";

export interface KullaniciFormDurumu {
  basarili: boolean;
  mesaj: string;
  hatalar?: Record<string, string[]>;
}

export async function kullaniciOlustur(
  oncekiDurum: KullaniciFormDurumu,
  formData: FormData
): Promise<KullaniciFormDurumu> {
  // FormData'dan ham verileri çıkar
  const hamVeri = {
    isim: formData.get("isim") as string,
    email: formData.get("email") as string,
    sifre: formData.get("sifre") as string,
    sifreOnay: formData.get("sifreOnay") as string,
    yas: Number(formData.get("yas")),
    telefon: (formData.get("telefon") as string) || undefined,
  };

  // Zod ile doğrulama - safeParse hata fırlatmaz
  const dogrulamaSonucu = kullaniciOlusturmaSemasi.safeParse(hamVeri);

  if (!dogrulamaSonucu.success) {
    // Alan düzeyinde hataları düzenle
    const alanHatalari = dogrulamaSonucu.error.flatten().fieldErrors;

    return {
      basarili: false,
      mesaj: "Lütfen formdaki hataları düzeltin.",
      hatalar: alanHatalari as Record<string, string[]>,
    };
  }

  // Doğrulanmış veriyi kullan - artık tip güvenli
  const dogrulanmisVeri = dogrulamaSonucu.data;

  try {
    // Veritabanına kaydet
    // await prisma.kullanici.create({
    //   data: {
    //     isim: dogrulanmisVeri.isim,
    //     email: dogrulanmisVeri.email,
    //     sifre: await hashle(dogrulanmisVeri.sifre),
    //     yas: dogrulanmisVeri.yas,
    //     telefon: dogrulanmisVeri.telefon,
    //   },
    // });

    // Simülasyon
    await new Promise((resolve) => setTimeout(resolve, 1500));

    revalidatePath("/kullanicilar");

    return {
      basarili: true,
      mesaj: "Hesabınız başarıyla oluşturuldu!",
    };
  } catch (hata: unknown) {
    // Benzersizlik hatası kontrolü
    if (
      hata instanceof Error &&
      hata.message.includes("Unique constraint")
    ) {
      return {
        basarili: false,
        mesaj: "Bu e-posta adresi zaten kayıtlı.",
        hatalar: {
          email: ["Bu e-posta adresi zaten kullanımda."],
        },
      };
    }

    return {
      basarili: false,
      mesaj: "Bir hata oluştu. Lütfen daha sonra tekrar deneyin.",
    };
  }
}

Zod'un safeParse yöntemi, doğrulama başarısız olduğunda hata fırlatmak yerine bir sonuç nesnesi döndürüyor. Bu da hata işleme mantığını temiz tutuyor ve alan bazında hataları kolayca elde etmenizi sağlıyor. flatten() yöntemi ise hataları { alanAdi: string[] } formatına dönüştürüyor — form bileşenlerinde kolayca kullanılabilir bir yapı.

Yeniden Kullanılabilir Doğrulama Yardımcısı

Birden fazla server action'da aynı doğrulama mantığını tekrar tekrar yazmak istemezsiniz (ben de istemem, açıkçası). Bunun için genel amaçlı bir yardımcı fonksiyon oluşturabilirsiniz:

// lib/validasyon/dogrulama-yardimcisi.ts
import { z } from "zod";

export interface DogrulamaSonucu<T> {
  basarili: true;
  veri: T;
} | {
  basarili: false;
  hatalar: Record<string, string[]>;
}

export function formVerisiniDogrula<T>(
  sema: z.ZodSchema<T>,
  formData: FormData,
  alanlar: Record<string, "string" | "number" | "boolean">
): DogrulamaSonucu<T> {
  // FormData'dan ham nesne oluştur
  const hamVeri: Record<string, unknown> = {};

  for (const [alan, tip] of Object.entries(alanlar)) {
    const deger = formData.get(alan);
    if (tip === "number") {
      hamVeri[alan] = deger ? Number(deger) : undefined;
    } else if (tip === "boolean") {
      hamVeri[alan] = deger === "on" || deger === "true";
    } else {
      hamVeri[alan] = deger;
    }
  }

  const sonuc = sema.safeParse(hamVeri);

  if (!sonuc.success) {
    return {
      basarili: false,
      hatalar: sonuc.error.flatten().fieldErrors as Record<string, string[]>,
    };
  }

  return {
    basarili: true,
    veri: sonuc.data,
  };
}

Güvenlik En İyi Uygulamaları

Server Actions sunucu tarafında çalışsa da otomatik olarak güvenli değildir. Her server action'ı dışarıya açık bir API endpoint'i gibi düşünmeniz gerekiyor. İşte dikkat etmeniz gereken kritik güvenlik uygulamaları:

1. Her Zaman Sunucu Tarafında Doğrulama Yapın

İstemci tarafındaki doğrulama sadece kullanıcı deneyimi içindir; güvenlik açısından asla yeterli olmaz. Bir saldırgan istemci tarafı doğrulamasını kolayca atlayabilir.

// YANLIŞ: Sadece istemci doğrulamasına güvenmek
// Saldırgan doğrudan POST isteği gönderebilir!

// DOGRU: Her zaman sunucuda doğrulama yapın
"use server";

import { z } from "zod";

const yorumSemasi = z.object({
  icerik: z.string().min(1).max(5000),
  yaziId: z.string().uuid(),
});

export async function yorumEkle(formData: FormData) {
  // Sunucu tarafı doğrulama ZORUNLU
  const sonuc = yorumSemasi.safeParse({
    icerik: formData.get("icerik"),
    yaziId: formData.get("yaziId"),
  });

  if (!sonuc.success) {
    return { hata: "Geçersiz veri." };
  }

  // Doğrulanmış veriyle devam et
  const { icerik, yaziId } = sonuc.data;
  // ...
}

2. Kimlik Doğrulama ve Yetkilendirme Kontrolü

Her server action kullanıcının kimliğini ve yetkisini doğrulamalıdır. Oturum bilgisine asla istemciden gelen veriye güvenerek ulaşmayın; her zaman sunucu tarafında oturum kontrolü yapın.

// app/actions/yazi-actions.ts
"use server";

import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export async function yaziSil(yaziId: string) {
  // 1. Kimlik doğrulama
  const oturum = await auth();
  if (!oturum?.user) {
    redirect("/giris");
  }

  // 2. Yetkilendirme - kullanıcı bu yazının sahibi mi?
  const yazi = await prisma.yazi.findUnique({
    where: { id: yaziId },
    select: { yazarId: true },
  });

  if (!yazi) {
    return { hata: "Yazı bulunamadı." };
  }

  if (yazi.yazarId !== oturum.user.id && oturum.user.rol !== "ADMIN") {
    return { hata: "Bu işlem için yetkiniz yok." };
  }

  // 3. Güvenli işlem
  await prisma.yazi.delete({ where: { id: yaziId } });

  revalidatePath("/yazilar");
  return { basarili: true };
}

3. CSRF Koruması

Next.js, Server Actions için yerleşik CSRF koruması sağlıyor. Her server action çağrısı Origin başlığını kontrol eder ve yalnızca aynı kaynaktan gelen istekleri kabul eder. Ayrıca server action'ların benzersiz kimlikleri tahmin edilemez olduğundan, kör CSRF saldırılarına karşı ek bir koruma katmanı da var. Yine de next.config.js dosyasında serverActions.allowedOrigins yapılandırmasını doğru ayarladığınızdan emin olun:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverActions: {
      allowedOrigins: [
        "sizin-alaniniz.com",
        "localhost:3000", // Geliştirme ortamı
      ],
      bodySizeLimit: "2mb", // Gövde boyutu sınırı
    },
  },
};

module.exports = nextConfig;

4. Girdi Temizleme (Input Sanitization)

Kullanıcıdan gelen tüm verileri güvenilmez olarak ele alın. SQL injection, XSS ve diğer injection saldırılarına karşı girdileri temizlemek şart.

"use server";

import DOMPurify from "isomorphic-dompurify";

export async function profilGuncelle(formData: FormData) {
  const oturum = await auth();
  if (!oturum?.user) return { hata: "Yetkisiz erişim." };

  const biyografi = formData.get("biyografi") as string;

  // HTML içeriğini temizle - XSS önleme
  const temizBiyografi = DOMPurify.sanitize(biyografi, {
    ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
    ALLOWED_ATTR: ["href", "target"],
  });

  await prisma.profil.update({
    where: { kullaniciId: oturum.user.id },
    data: { biyografi: temizBiyografi },
  });

  revalidatePath(`/profil/${oturum.user.id}`);
  return { basarili: true };
}

5. Hız Sınırlama (Rate Limiting)

Server Actions kötü niyetli kullanıcılar tarafından suistimal edilebilir. Özellikle form gönderimi veya kaynak yoğun işlemler için hız sınırlama uygulamak önemli:

// lib/hiz-sinir.ts
import { headers } from "next/headers";

const istekSayaci = new Map<string, { sayi: number; sonSifirlama: number }>();

export async function hizSiniriKontrol(
  sinir: number = 10,
  pencereSuresiMs: number = 60000
): Promise<boolean> {
  const basliklar = await headers();
  const ip = basliklar.get("x-forwarded-for") ?? "bilinmeyen";
  const simdi = Date.now();

  const mevcut = istekSayaci.get(ip);

  if (!mevcut || simdi - mevcut.sonSifirlama > pencereSuresiMs) {
    istekSayaci.set(ip, { sayi: 1, sonSifirlama: simdi });
    return true; // İzin ver
  }

  if (mevcut.sayi >= sinir) {
    return false; // Engelle
  }

  mevcut.sayi++;
  return true; // İzin ver
}

// Kullanım:
// "use server";
// export async function hassasIslem(formData: FormData) {
//   const izinVerildi = await hizSiniriKontrol(5, 60000);
//   if (!izinVerildi) {
//     return { hata: "Çok fazla istek. Lütfen bir dakika bekleyin." };
//   }
//   // İşleme devam et...
// }

Üretim ortamında bellek içi bir sayaç yerine Redis gibi dağıtılmış bir cache kullanmanız çok daha iyi olur. @upstash/ratelimit gibi kütüphaneler bu iş için biçilmiş kaftan.

Önbellek Yönetimi: revalidatePath ve revalidateTag

Next.js'in App Router mimarisi oldukça agresif bir caching stratejisi kullanıyor. Server Actions ile veri mutasyonu yaptığınızda ilgili önbellekleri geçersiz kılmanız gerekir. Bunun için üç temel araç var: revalidatePath, revalidateTag ve daha yeni eklenen cacheTag / cacheLife API'leri.

revalidatePath

revalidatePath, belirli bir path'in önbelleğini geçersiz kılar. Bu yola yapılacak sonraki istek sayfayı yeniden oluşturur.

"use server";

import { revalidatePath } from "next/cache";

export async function urunGuncelle(urunId: string, formData: FormData) {
  // Veritabanı güncelleme...
  await prisma.urun.update({
    where: { id: urunId },
    data: { isim: formData.get("isim") as string },
  });

  // Belirli bir sayfayı geçersiz kıl
  revalidatePath(`/urunler/${urunId}`);

  // Ürün listesi sayfasını da geçersiz kıl
  revalidatePath("/urunler");

  // Layout düzeyinde geçersiz kılma
  revalidatePath("/urunler", "layout");

  // Tüm veriyi geçersiz kıl (dikkatli kullanın)
  revalidatePath("/", "layout");
}

revalidateTag

revalidateTag etiket tabanlı önbellek geçersiz kılma sağlar. Veri çekme işlemlerinize etiketler atayarak daha hassas bir cache kontrolü elde edebilirsiniz.

// Veri çekme sırasında etiket atama
async function urunleriGetir() {
  const yanit = await fetch("https://api.ornek.com/urunler", {
    next: {
      tags: ["urunler"],      // Bu veriye "urunler" etiketi ata
      revalidate: 3600,        // 1 saat sonra otomatik yenile
    },
  });
  return yanit.json();
}

async function urunDetayGetir(id: string) {
  const yanit = await fetch(`https://api.ornek.com/urunler/${id}`, {
    next: {
      tags: ["urunler", `urun-${id}`],  // Birden fazla etiket
    },
  });
  return yanit.json();
}

// Server Action'da etiket bazlı geçersiz kılma
"use server";

import { revalidateTag } from "next/cache";

export async function urunSil(urunId: string) {
  await prisma.urun.delete({ where: { id: urunId } });

  // Belirli ürünün önbelleğini temizle
  revalidateTag(`urun-${urunId}`);

  // Tüm ürün listelerinin önbelleğini temizle
  revalidateTag("urunler");
}

Ne Zaman Hangisini Kullanmalı?

  • revalidatePath: Belirli bir sayfa veya rotanın tamamını yenilemek istediğinizde. Sayfa düzeyinde düşünürken en iyi seçenek — özellikle sayfanın tüm verilerinin güncellenmesi gerektiğinde ideal.
  • revalidateTag: Birden fazla sayfada kullanılan belirli bir veri kümesini yenilemek istediğinizde. Veri düzeyinde düşünürken daha uygun. Mesela bir ürün güncellendiğinde hem detay sayfası, hem liste sayfası, hem de ana sayfadaki öne çıkan ürünler bölümü güncellenir.

Pratik Önbellek Stratejisi Örneği

// app/actions/blog-actions.ts
"use server";

import { revalidatePath, revalidateTag } from "next/cache";

export async function blogYazisiOlustur(
  oncekiDurum: unknown,
  formData: FormData
) {
  // ... doğrulama ve kaydetme ...

  // Yeni yazı oluşturulduğunda:
  // 1. Blog listesi sayfasını yenile (yeni yazı görünsün)
  revalidateTag("blog-yazilari");

  // 2. Kategori sayfasını yenile
  const kategori = formData.get("kategori") as string;
  revalidateTag(`kategori-${kategori}`);

  // 3. Ana sayfa istatistiklerini yenile
  revalidateTag("site-istatistikleri");
}

export async function blogYazisiGuncelle(
  yaziId: string,
  formData: FormData
) {
  // ... doğrulama ve güncelleme ...

  // Güncelleme durumunda:
  // 1. Spesifik yazı detay sayfasını yenile
  revalidateTag(`yazi-${yaziId}`);

  // 2. Yazı listesini yenile (başlık değişmiş olabilir)
  revalidateTag("blog-yazilari");

  // 3. İlgili yazı sayfasının path'ini yenile
  const slug = formData.get("slug") as string;
  revalidatePath(`/blog/${slug}`);
}

Veritabanı İşlemleri ile Server Actions

Server Actions, veritabanı CRUD işlemleri için mükemmel bir eşleşme. Prisma ORM ile kullanıldığında tip güvenli ve temiz bir veritabanı katmanı oluşturabilirsiniz.

Prisma Kurulumu ve Model Tanımlama

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Gorev {
  id          String    @id @default(cuid())
  baslik      String
  aciklama    String?
  durum       GorevDurumu @default(BEKLEMEDE)
  oncelik     Int       @default(0)
  olusturanId String
  olusturan   Kullanici @relation(fields: [olusturanId], references: [id])
  olusturmaZamani DateTime @default(now())
  guncellemeZamani DateTime @updatedAt
}

enum GorevDurumu {
  BEKLEMEDE
  DEVAM_EDIYOR
  TAMAMLANDI
  IPTAL_EDILDI
}

Tam CRUD Server Actions

Şimdi asıl işe geliyoruz. İşte Prisma ile tam CRUD (oluşturma, okuma, güncelleme, silme) işlemlerini kapsayan server action'lar:

// app/actions/gorev-actions.ts
"use server";

import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

// Doğrulama şemaları
const gorevOlusturmaSemasi = z.object({
  baslik: z.string().min(1, "Başlık gerekli.").max(200, "Başlık çok uzun."),
  aciklama: z.string().max(5000, "Açıklama çok uzun.").optional(),
  oncelik: z.coerce.number().int().min(0).max(5).default(0),
});

const gorevGuncellemeSemasi = z.object({
  id: z.string().cuid("Geçersiz görev kimliği."),
  baslik: z.string().min(1).max(200).optional(),
  aciklama: z.string().max(5000).optional(),
  durum: z.enum(["BEKLEMEDE", "DEVAM_EDIYOR", "TAMAMLANDI", "IPTAL_EDILDI"]).optional(),
  oncelik: z.coerce.number().int().min(0).max(5).optional(),
});

// Sonuç tipi
interface ActionSonucu {
  basarili: boolean;
  mesaj: string;
  hatalar?: Record<string, string[]>;
}

// OLUŞTURMA
export async function gorevOlustur(
  oncekiDurum: ActionSonucu,
  formData: FormData
): Promise<ActionSonucu> {
  const oturum = await auth();
  if (!oturum?.user?.id) {
    redirect("/giris");
  }

  const dogrulama = gorevOlusturmaSemasi.safeParse({
    baslik: formData.get("baslik"),
    aciklama: formData.get("aciklama"),
    oncelik: formData.get("oncelik"),
  });

  if (!dogrulama.success) {
    return {
      basarili: false,
      mesaj: "Doğrulama hatası.",
      hatalar: dogrulama.error.flatten().fieldErrors as Record<string, string[]>,
    };
  }

  try {
    const yeniGorev = await prisma.gorev.create({
      data: {
        ...dogrulama.data,
        olusturanId: oturum.user.id,
      },
    });

    revalidatePath("/gorevler");

    return {
      basarili: true,
      mesaj: `"${yeniGorev.baslik}" görevi oluşturuldu.`,
    };
  } catch (hata) {
    console.error("Görev oluşturma hatası:", hata);
    return {
      basarili: false,
      mesaj: "Görev oluşturulurken bir hata meydana geldi.",
    };
  }
}

// GÜNCELLEME
export async function gorevGuncelle(
  oncekiDurum: ActionSonucu,
  formData: FormData
): Promise<ActionSonucu> {
  const oturum = await auth();
  if (!oturum?.user?.id) {
    redirect("/giris");
  }

  const dogrulama = gorevGuncellemeSemasi.safeParse({
    id: formData.get("id"),
    baslik: formData.get("baslik") || undefined,
    aciklama: formData.get("aciklama") || undefined,
    durum: formData.get("durum") || undefined,
    oncelik: formData.get("oncelik") || undefined,
  });

  if (!dogrulama.success) {
    return {
      basarili: false,
      mesaj: "Doğrulama hatası.",
      hatalar: dogrulama.error.flatten().fieldErrors as Record<string, string[]>,
    };
  }

  try {
    // Yetkilendirme kontrolü
    const mevcutGorev = await prisma.gorev.findUnique({
      where: { id: dogrulama.data.id },
      select: { olusturanId: true },
    });

    if (!mevcutGorev) {
      return { basarili: false, mesaj: "Görev bulunamadı." };
    }

    if (mevcutGorev.olusturanId !== oturum.user.id) {
      return { basarili: false, mesaj: "Bu görevi güncelleme yetkiniz yok." };
    }

    const { id, ...guncellenecekVeri } = dogrulama.data;

    await prisma.gorev.update({
      where: { id },
      data: guncellenecekVeri,
    });

    revalidatePath("/gorevler");
    revalidatePath(`/gorevler/${id}`);

    return {
      basarili: true,
      mesaj: "Görev başarıyla güncellendi.",
    };
  } catch (hata) {
    console.error("Görev güncelleme hatası:", hata);
    return {
      basarili: false,
      mesaj: "Görev güncellenirken bir hata meydana geldi.",
    };
  }
}

// SİLME
export async function gorevSil(gorevId: string): Promise<ActionSonucu> {
  const oturum = await auth();
  if (!oturum?.user?.id) {
    redirect("/giris");
  }

  // Girdi doğrulama
  const idDogrulama = z.string().cuid().safeParse(gorevId);
  if (!idDogrulama.success) {
    return { basarili: false, mesaj: "Geçersiz görev kimliği." };
  }

  try {
    const gorev = await prisma.gorev.findUnique({
      where: { id: gorevId },
      select: { olusturanId: true },
    });

    if (!gorev) {
      return { basarili: false, mesaj: "Görev bulunamadı." };
    }

    if (gorev.olusturanId !== oturum.user.id) {
      return { basarili: false, mesaj: "Bu görevi silme yetkiniz yok." };
    }

    await prisma.gorev.delete({ where: { id: gorevId } });

    revalidatePath("/gorevler");

    return {
      basarili: true,
      mesaj: "Görev başarıyla silindi.",
    };
  } catch (hata) {
    console.error("Görev silme hatası:", hata);
    return {
      basarili: false,
      mesaj: "Görev silinirken bir hata meydana geldi.",
    };
  }
}

Silme İşlemi İçin Bileşen Örneği

// app/gorevler/gorev-sil-butonu.tsx
"use client";

import { useTransition } from "react";
import { gorevSil } from "@/app/actions/gorev-actions";

interface GorevSilButonuProps {
  gorevId: string;
  gorevBasligi: string;
}

export function GorevSilButonu({ gorevId, gorevBasligi }: GorevSilButonuProps) {
  const [bekleniyor, gecisBaslat] = useTransition();

  function silmeIslemi() {
    const onay = confirm(`"${gorevBasligi}" görevini silmek istediğinize emin misiniz?`);
    if (!onay) return;

    gecisBaslat(async () => {
      const sonuc = await gorevSil(gorevId);
      if (!sonuc.basarili) {
        alert(sonuc.mesaj);
      }
    });
  }

  return (
    <button
      onClick={silmeIslemi}
      disabled={bekleniyor}
      className="text-red-600 hover:text-red-800 disabled:opacity-50"
    >
      {bekleniyor ? "Siliniyor..." : "Sil"}
    </button>
  );
}

İleri Düzey Kalıplar

Server Actions'ın temellerini öğrendik, şimdi daha gelişmiş kullanım kalıplarına geçelim. Bu kalıplar üretim düzeyindeki uygulamalarda gerçekten karşılaşacağınız senaryoları ele alıyor.

Optimistik Güncellemeler (useOptimistic)

Optimistik güncellemeler, sunucu yanıtını beklemeden UI'ı anında günceller. Bu, uygulamanızın çok daha hızlı ve duyarlı hissettirmesini sağlıyor. React 19'un useOptimistic hook'u bu kalıbı oldukça kolaylaştırıyor.

// app/gorevler/gorev-listesi.tsx
"use client";

import { useOptimistic } from "react";
import { gorevDurumGuncelle } from "@/app/actions/gorev-actions";

interface Gorev {
  id: string;
  baslik: string;
  tamamlandi: boolean;
}

interface GorevListesiProps {
  gorevler: Gorev[];
}

export function GorevListesi({ gorevler }: GorevListesiProps) {
  const [optimistikGorevler, optimistikGuncelle] = useOptimistic(
    gorevler,
    (mevcutGorevler: Gorev[], guncellenenId: string) => {
      return mevcutGorevler.map((gorev) =>
        gorev.id === guncellenenId
          ? { ...gorev, tamamlandi: !gorev.tamamlandi }
          : gorev
      );
    }
  );

  async function durumDegistir(gorevId: string) {
    // 1. Arayüzü hemen güncelle (optimistik)
    optimistikGuncelle(gorevId);

    // 2. Sunucuya isteği gönder
    const sonuc = await gorevDurumGuncelle(gorevId);

    // Hata durumunda React otomatik olarak önceki duruma döner
    if (!sonuc.basarili) {
      console.error("Durum güncelleme hatası:", sonuc.mesaj);
    }
  }

  return (
    <ul className="space-y-2">
      {optimistikGorevler.map((gorev) => (
        <li key={gorev.id} className="flex items-center gap-3 p-3 border rounded">
          <form action={() => durumDegistir(gorev.id)}>
            <button
              type="submit"
              className={`w-5 h-5 rounded border-2 flex items-center justify-center
                ${gorev.tamamlandi
                  ? "bg-green-500 border-green-500 text-white"
                  : "border-gray-300"
                }`}
            >
              {gorev.tamamlandi && "✓"}
            </button>
          </form>
          <span className={gorev.tamamlandi ? "line-through text-gray-400" : ""}>
            {gorev.baslik}
          </span>
        </li>
      ))}
    </ul>
  );
}

Progressif Geliştirme (Progressive Enhancement)

Bence Server Actions'ın en güçlü özelliklerinden biri bu. Form'a action prop'u olarak bir server action verildiğinde, JavaScript devre dışı olsa bile form çalışır. Erişilebilirlik ve dayanıklılık açısından büyük bir avantaj.

// app/arama/page.tsx
// Bu form JavaScript olmadan da çalışır!
import { aramaYap } from "@/app/actions/arama-actions";

export default function AramaSayfasi() {
  return (
    <form action={aramaYap} className="flex gap-2">
      <input
        type="text"
        name="sorgu"
        placeholder="Aramak istediğiniz terimi yazın..."
        className="flex-1 border rounded px-3 py-2"
        required
      />
      <button
        type="submit"
        className="bg-blue-600 text-white px-6 py-2 rounded"
      >
        Ara
      </button>
    </form>
  );
}

JavaScript yüklendiğinde React formu hydrate ederek tam istemci tarafı deneyimini sağlar. JavaScript yüklenemezse form klasik HTML form gönderimi olarak çalışmaya devam eder. Oldukça zarif bir çözüm.

Dosya Yükleme

Server Actions FormData ile çalıştığından dosya yükleme işlemleri doğal olarak destekleniyor:

// app/actions/dosya-actions.ts
"use server";

import { writeFile, mkdir } from "fs/promises";
import path from "path";
import { z } from "zod";
import { auth } from "@/lib/auth";

const IZIN_VERILEN_TIPLER = [
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/avif",
];
const MAKS_BOYUT = 5 * 1024 * 1024; // 5 MB

interface DosyaYuklemeSonucu {
  basarili: boolean;
  mesaj: string;
  dosyaYolu?: string;
}

export async function avatarYukle(
  oncekiDurum: DosyaYuklemeSonucu,
  formData: FormData
): Promise<DosyaYuklemeSonucu> {
  const oturum = await auth();
  if (!oturum?.user?.id) {
    return { basarili: false, mesaj: "Oturum açmanız gerekiyor." };
  }

  const dosya = formData.get("avatar") as File | null;

  if (!dosya || dosya.size === 0) {
    return { basarili: false, mesaj: "Lütfen bir dosya seçin." };
  }

  // Dosya tipi kontrolü
  if (!IZIN_VERILEN_TIPLER.includes(dosya.type)) {
    return {
      basarili: false,
      mesaj: "Yalnızca JPEG, PNG, WebP ve AVIF dosyaları kabul edilir.",
    };
  }

  // Dosya boyutu kontrolü
  if (dosya.size > MAKS_BOYUT) {
    return {
      basarili: false,
      mesaj: "Dosya boyutu 5 MB'ı geçemez.",
    };
  }

  try {
    const tampon = Buffer.from(await dosya.arrayBuffer());
    const uzanti = dosya.type.split("/")[1];
    const dosyaAdi = `avatar-${oturum.user.id}-${Date.now()}.${uzanti}`;
    const yuklemeDizini = path.join(process.cwd(), "public", "uploads", "avatarlar");

    // Dizin yoksa oluştur
    await mkdir(yuklemeDizini, { recursive: true });

    const dosyaYolu = path.join(yuklemeDizini, dosyaAdi);
    await writeFile(dosyaYolu, tampon);

    // Veritabanında profil resmini güncelle
    // await prisma.kullanici.update({
    //   where: { id: oturum.user.id },
    //   data: { avatarUrl: `/uploads/avatarlar/${dosyaAdi}` },
    // });

    return {
      basarili: true,
      mesaj: "Avatar başarıyla güncellendi!",
      dosyaYolu: `/uploads/avatarlar/${dosyaAdi}`,
    };
  } catch (hata) {
    console.error("Dosya yükleme hatası:", hata);
    return {
      basarili: false,
      mesaj: "Dosya yüklenirken bir hata oluştu.",
    };
  }
}

Server Action'ları Birleştirme (Composing)

Karmaşık iş süreçlerinde birden fazla server action'ı bir arada kullanmanız gerekebilir. Sipariş oluşturma gibi çok adımlı bir süreci düşünün — stok kontrolü, ödeme işleme ve stok güncelleme hep bir arada olmalı. İşte bu kalıp için temiz bir yaklaşım:

// app/actions/siparis-actions.ts
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

// Alt işlem: Stok kontrolü
async function stokKontrol(urunId: string, miktar: number): Promise<boolean> {
  const urun = await prisma.urun.findUnique({
    where: { id: urunId },
    select: { stok: true },
  });
  return urun !== null && urun.stok >= miktar;
}

// Alt işlem: Ödeme işleme
async function odemeIsle(
  tutar: number,
  odemeYontemi: string
): Promise<{ basarili: boolean; islemId?: string }> {
  // Ödeme servisi entegrasyonu
  // const sonuc = await stripeClient.charges.create({ ... });
  return { basarili: true, islemId: `odm-${Date.now()}` };
}

// Alt işlem: Stok güncelleme
async function stokGuncelle(urunId: string, miktar: number) {
  await prisma.urun.update({
    where: { id: urunId },
    data: { stok: { decrement: miktar } },
  });
}

// Ana server action: Sipariş oluştur
export async function siparisOlustur(
  oncekiDurum: unknown,
  formData: FormData
) {
  const oturum = await auth();
  if (!oturum?.user) {
    return { basarili: false, mesaj: "Oturum açmanız gerekiyor." };
  }

  const urunId = formData.get("urunId") as string;
  const miktar = Number(formData.get("miktar"));
  const odemeYontemi = formData.get("odemeYontemi") as string;

  // 1. Stok kontrolü
  const stokYeterli = await stokKontrol(urunId, miktar);
  if (!stokYeterli) {
    return { basarili: false, mesaj: "Üzgünüz, yeterli stok bulunmuyor." };
  }

  // 2. Fiyat hesaplama
  const urun = await prisma.urun.findUnique({ where: { id: urunId } });
  if (!urun) {
    return { basarili: false, mesaj: "Ürün bulunamadı." };
  }

  const toplamTutar = urun.fiyat * miktar;

  // 3. Ödeme işleme
  const odemeSonucu = await odemeIsle(toplamTutar, odemeYontemi);
  if (!odemeSonucu.basarili) {
    return { basarili: false, mesaj: "Ödeme işlemi başarısız oldu." };
  }

  // 4. Veritabanı transaction ile sipariş ve stok güncelleme
  try {
    await prisma.$transaction(async (tx) => {
      // Sipariş oluştur
      await tx.siparis.create({
        data: {
          kullaniciId: oturum.user.id,
          urunId,
          miktar,
          toplamTutar,
          odemeIslemId: odemeSonucu.islemId!,
          durum: "ONAYLANDI",
        },
      });

      // Stok güncelle
      await tx.urun.update({
        where: { id: urunId },
        data: { stok: { decrement: miktar } },
      });
    });

    revalidatePath("/siparisler");
    revalidatePath(`/urunler/${urunId}`);

    return {
      basarili: true,
      mesaj: "Siparişiniz başarıyla oluşturuldu!",
    };
  } catch (hata) {
    console.error("Sipariş oluşturma hatası:", hata);
    // Ödeme iadesi gerekebilir
    return {
      basarili: false,
      mesaj: "Sipariş oluşturulurken bir hata oluştu. Ödemeniz iade edilecektir.",
    };
  }
}

Middleware Entegrasyonu

Server Actions'ı middleware benzeri bir yapıyla sarmalayarak loglama, hız sınırlama ve yetkilendirme gibi ortak mantığı tek bir noktada yönetebilirsiniz. Bunu projelerimde sıkça kullanıyorum ve gerçekten işleri temiz tutuyor:

// lib/action-sarmalayici.ts
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

type ServerActionFn<TDurum> = (
  durum: TDurum,
  formData: FormData
) => Promise<TDurum>;

interface SarmalayiciSecenekleri {
  kimlikDogrulamaGerekli?: boolean;
  izinVerilenRoller?: string[];
}

export function korunmusAction<TDurum extends { basarili: boolean; mesaj: string }>(
  action: ServerActionFn<TDurum>,
  secenekler: SarmalayiciSecenekleri = {}
): ServerActionFn<TDurum> {
  return async (durum: TDurum, formData: FormData): Promise<TDurum> => {
    const baslangic = Date.now();

    try {
      // Kimlik doğrulama kontrolü
      if (secenekler.kimlikDogrulamaGerekli) {
        const oturum = await auth();
        if (!oturum?.user) {
          redirect("/giris");
        }

        // Rol kontrolü
        if (
          secenekler.izinVerilenRoller &&
          !secenekler.izinVerilenRoller.includes(oturum.user.rol)
        ) {
          return {
            ...durum,
            basarili: false,
            mesaj: "Bu işlem için yetkiniz yok.",
          };
        }
      }

      // Action'ı çalıştır
      const sonuc = await action(durum, formData);

      // Loglama
      const sure = Date.now() - baslangic;
      console.log(`[Action] Süre: ${sure}ms, Başarılı: ${sonuc.basarili}`);

      return sonuc;
    } catch (hata) {
      const sure = Date.now() - baslangic;
      console.error(`[Action Hatası] Süre: ${sure}ms`, hata);

      return {
        ...durum,
        basarili: false,
        mesaj: "Beklenmeyen bir hata oluştu.",
      };
    }
  };
}

// Kullanım:
// export const gorevOlustur = korunmusAction(
//   async (durum, formData) => { /* ... */ },
//   { kimlikDogrulamaGerekli: true, izinVerilenRoller: ["ADMIN", "EDITOR"] }
// );

Performans ve Optimizasyon

Server Actions güçlü bir araç, ama doğru kullanılmadığında performans sorunlarına yol açabilir. Dikkat etmeniz gereken birkaç kritik nokta var.

Server Actions'ı Veri Çekme İçin Kullanmayın

Bu en yaygın hatalardan biri ve bunu söylemek zorundayım. Server Actions veri mutasyonları (oluşturma, güncelleme, silme) için tasarlandı. Veri okuma işlemleri için Server Components, fetch ile caching veya React'in use hook'unu kullanmalısınız.

// YANLIŞ: Server Action ile veri çekme
"use server";
export async function urunleriGetir() {
  return await prisma.urun.findMany();
}

// DOGRU: Server Component'te doğrudan veri çekme
// app/urunler/page.tsx
export default async function UrunlerSayfasi() {
  // Bu bir server action DEĞİL, bir Server Component
  const urunler = await prisma.urun.findMany({
    orderBy: { olusturmaZamani: "desc" },
  });

  return (
    <div>
      {urunler.map((urun) => (
        <UrunKarti key={urun.id} urun={urun} />
      ))}
    </div>
  );
}

Neden mi? Server Components'teki veri çekme işlemleri Next.js'in caching mekanizmasından tam olarak faydalanır. Server Actions ise her çağrıda yeni bir POST isteği oluşturur ve varsayılan olarak cache'lenmez.

Paket Boyutu Avantajı

Server Actions'ın gerçekten güzel bir avantajı var: sunucu tarafı kodu istemci JavaScript paketine dahil edilmiyor. Ağır bağımlılıklar kullanan action'lar için bu büyük bir kazanç.

// Bu kod ASLA istemci paketine dahil edilmez
"use server";

import { PDFDocument } from "pdf-lib";         // ~300KB
import sharp from "sharp";                       // ~25MB (native)
import { createTransport } from "nodemailer";    // ~200KB

export async function raporOlustur(formData: FormData) {
  // Tüm bu ağır kütüphaneler yalnızca sunucuda çalışır
  // İstemci tarafında sadece bu fonksiyonu çağıran küçük bir referans bulunur
  const pdf = await PDFDocument.create();
  // ...
}

Geleneksel API route'larıyla kıyaslandığında da avantajlı, çünkü istemci tarafında fetch çağrısı, hata işleme ve state yönetimi için gereken ek kod da ortadan kalkıyor.

Streaming ve Uzun Süren İşlemler

Server Actions uzun süren işlemler için doğrudan streaming desteği sunmuyor. Bu tür senaryolarda yaklaşım şöyle olmalı: server action ile işlemi başlatıp bir iş kimliği döndürün, sonra polling veya Server-Sent Events ile durumu sorgulayın.

// app/actions/rapor-actions.ts
"use server";

import { prisma } from "@/lib/prisma";

export async function raporOlusturBaslat(formData: FormData) {
  const oturum = await auth();
  if (!oturum?.user) return { basarili: false, mesaj: "Yetkisiz." };

  // Veritabanında iş kaydı oluştur
  const is = await prisma.arkaplanIsi.create({
    data: {
      tip: "RAPOR_OLUSTURMA",
      durum: "KUYRUKTA",
      kullaniciId: oturum.user.id,
      parametreler: {
        baslangicTarihi: formData.get("baslangic"),
        bitisTarihi: formData.get("bitis"),
      },
    },
  });

  // Arka plan işlemini tetikle (kuyruk sistemi)
  // await raporKuyrugu.ekle({ isId: is.id });

  return {
    basarili: true,
    isId: is.id,
    mesaj: "Rapor oluşturma işlemi başlatıldı.",
  };
}

export async function raporDurumSorgula(isId: string) {
  const is = await prisma.arkaplanIsi.findUnique({
    where: { id: isId },
    select: { durum: true, sonucUrl: true, hata: true },
  });

  return is;
}

Paralel Server Action Çağrıları

Birden fazla bağımsız server action'ı paralel çağırarak toplam bekleme süresini azaltabilirsiniz. Ama bunu yaparken useTransition içinde yönetmeye dikkat etmek önemli:

// Birden fazla bağımsız güncellemeyi paralel çalıştırma
"use server";

export async function topluGuncelleme(formData: FormData) {
  const oturum = await auth();
  if (!oturum?.user) return { basarili: false };

  // Bağımsız işlemleri paralel çalıştır
  const [profilSonuc, tercihSonuc, bildirimSonuc] = await Promise.allSettled([
    profilGuncelle(formData),
    tercihGuncelle(formData),
    bildirimAyarGuncelle(formData),
  ]);

  // Sonuçları kontrol et
  const hatalar: string[] = [];
  if (profilSonuc.status === "rejected") hatalar.push("Profil güncellenemedi.");
  if (tercihSonuc.status === "rejected") hatalar.push("Tercihler güncellenemedi.");
  if (bildirimSonuc.status === "rejected") hatalar.push("Bildirim ayarları güncellenemedi.");

  if (hatalar.length > 0) {
    return {
      basarili: false,
      mesaj: `Bazı güncellemeler başarısız: ${hatalar.join(" ")}`,
    };
  }

  revalidatePath("/ayarlar");
  return { basarili: true, mesaj: "Tüm ayarlar güncellendi." };
}

Sonuç

Next.js Server Actions, modern full-stack React uygulamalarının temel yapı taşlarından biri haline geldi. Bu rehberde ele aldığımız konuları kısaca özetleyelim:

  • Temel kavramlar: Server Actions, "use server" direktifi ile işaretlenen ve her zaman sunucuda çalışan asenkron fonksiyonlar. Arka planda HTTP POST istekleri olarak çalışıyor ve yerleşik güvenlik mekanizmalarına sahip.
  • Form yönetimi: useActionState hook'u form durumunu, loading göstergelerini ve hata mesajlarını yönetmek için güçlü ve ergonomik bir API sunuyor.
  • Veri doğrulama: Zod ile sunucu tarafı doğrulama hem tip güvenliği hem de runtime güvenliği sağlıyor. safeParse ve flatten kalıpları alan bazında hata yönetimini kolaylaştırıyor.
  • Güvenlik: Her server action bir açık API endpoint'i. Kimlik doğrulama, yetkilendirme, girdi doğrulama, sanitization ve rate limiting her zaman uygulanmalı.
  • Önbellek yönetimi: revalidatePath sayfa düzeyinde, revalidateTag veri düzeyinde cache invalidation sağlıyor. İkisini birlikte kullanarak hassas bir cache stratejisi oluşturabilirsiniz.
  • Veritabanı işlemleri: Prisma ile birlikte tip güvenli CRUD işlemleri için mükemmel bir altyapı. Transaction'lar ile atomik işlemler garanti altında.
  • İleri düzey kalıplar: Optimistik güncellemeler, progressive enhancement, dosya yükleme ve middleware benzeri sarmalayıcılar — hepsi üretim kalitesinde uygulamalar için vazgeçilmez.
  • Performans: Server Actions veri mutasyonları için kullanılmalı, veri çekme için değil. Sunucu kodunun istemci paketine dahil olmaması ciddi bir bundle size avantajı.

Server Actions; API route yazma, istemci tarafı fetch mantığı kurma ve state yönetimi katmanları oluşturma gibi tekrarlayan işleri ortadan kaldırarak geliştiricilerin asıl iş mantığına odaklanmasını sağlıyor. Next.js 15 ve sonrasıyla bu teknoloji olgunlaşmaya devam ediyor ve React ekosisteminin geleceğinde merkezi bir rol oynaması bekleniyor.

Bu rehberdeki kalıpları kendi projelerinize uygularken güvenliği ön planda tutmayı, doğrulama katmanlarını atlamadan uygulamayı ve performans etkilerini göz önünde bulundurmayı unutmayın. Server Actions'ın asıl gücü, basitliği ile güvenli ve ölçeklenebilir uygulamalar inşa etme kapasitesinin birleşiminde yatıyor.

Yazar Hakkında Editorial Team

Our team of expert writers and editors.