Server Actions στο Next.js 15: Οδηγός για Mutations, Forms και Ασφάλεια

Μάθε πώς να χρησιμοποιείς Server Actions στο Next.js 15 για mutations και φόρμες. Οδηγός με useActionState, useOptimistic, Zod validation, next-safe-action και ολοκληρωμένο CRUD παράδειγμα.

Εισαγωγή: Τι Είναι τα Server Actions;

Αν δουλεύεις με Next.js 15 και React 19, σίγουρα έχεις ακούσει για τα Server Actions. Κι αν δεν τα έχεις δοκιμάσει ακόμα, ειλικρινά χάνεις ένα από τα πιο ισχυρά εργαλεία που προσφέρει το framework. Πρόκειται για ασύγχρονες συναρτήσεις που τρέχουν αποκλειστικά στον server, αλλά μπορείς να τις καλέσεις κατευθείαν από client components ή ακόμα και από απλά HTML forms.

Αντί να φτιάχνεις API routes για κάθε mutation (δημιουργία, ενημέρωση, διαγραφή), τώρα γράφεις μια συνάρτηση με "use server" και το Next.js αναλαμβάνει τα υπόλοιπα. Απλό, σωστά;

Η φιλοσοφία πίσω από αυτό είναι ξεκάθαρη: "ο server είναι το API". Αντί να σχεδιάζεις REST endpoints, να γράφεις fetch calls και να παιδεύεσαι με serialization/deserialization, απλά καλείς μια server function σαν να ήταν τοπική. Το framework δημιουργεί αυτόματα ένα κρυφό HTTP endpoint, χειρίζεται την επικοινωνία, και εκτελεί τον κώδικα στον server με πλήρη πρόσβαση στη βάση δεδομένων, τα secrets και το filesystem.

Αυτό σημαίνει ότι τα παραδοσιακά API routes (app/api/*/route.ts) δεν εξαφανίζονται — χρησιμοποιούνται ακόμα για webhooks, third-party integrations και public APIs. Για εσωτερικά mutations της εφαρμογής σου όμως, τα Server Actions είναι η συνιστώμενη προσέγγιση.

Ορισμός Server Actions: Η Οδηγία "use server"

Standalone Αρχεία Actions

Ο πιο καθαρός τρόπος για να ορίσεις Server Actions είναι σε ξεχωριστά αρχεία με την οδηγία "use server" στην κορυφή. Έτσι, όλες οι εξαγόμενες συναρτήσεις του αρχείου γίνονται αυτόματα Server Actions:

// app/actions/tasks.ts
"use server";

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

// Αυτή η συνάρτηση εκτελείται ΜΟΝΟ στον server
export async function createTask(formData: FormData) {
  const title = formData.get("title") as string;
  const description = formData.get("description") as string;

  await db.task.create({
    data: { title, description },
  });

  revalidatePath("/tasks");
}

export async function deleteTask(taskId: string) {
  await db.task.delete({
    where: { id: taskId },
  });

  revalidatePath("/tasks");
}

Inline Server Actions σε Server Components

Μπορείς επίσης να ορίσεις Server Actions inline, μέσα σε Server Components. Αυτό βολεύει για γρήγορα prototypes ή μικρές λειτουργίες:

// app/tasks/page.tsx (Server Component)
import { db } from "@/lib/db";

export default async function TasksPage() {
  const tasks = await db.task.findMany();

  // Inline server action - χρησιμοποιεί closure
  async function addTask(formData: FormData) {
    "use server";
    const title = formData.get("title") as string;
    await db.task.create({ data: { title } });
  }

  return (
    <div>
      <form action={addTask}>
        <input name="title" placeholder="Νέα εργασία..." />
        <button type="submit">Προσθήκη</button>
      </form>
      {tasks.map((task) => (
        <p key={task.id}>{task.title}</p>
      ))}
    </div>
  );
}

Πώς Λειτουργεί Εσωτερικά

Λοιπόν, τι γίνεται "πίσω από τις κουλίσες"; Όταν το Next.js συναντά μια συνάρτηση με "use server", δεν στέλνει τον κώδικά της στον browser. Αντίθετα, δημιουργεί ένα κρυπτογραφημένο, μη-ντετερμινιστικό ID που λειτουργεί ως αναφορά. Στον client, η συνάρτηση αντικαθίσταται από ένα stub που κάνει POST request στον server με αυτό το ID.

Ο server αναγνωρίζει το ID, εκτελεί την πραγματική συνάρτηση, και επιστρέφει το αποτέλεσμα. Αυτά τα IDs αλλάζουν σε κάθε build, οπότε κανείς δεν μπορεί να τα μαντέψει.

Ωστόσο — και αυτό είναι κρίσιμο — πρέπει πάντα να αντιμετωπίζεις κάθε Server Action σαν δημόσιο API endpoint και να κάνεις validation και authorization. Θα μιλήσουμε αναλυτικά γι' αυτό παρακάτω.

Forms και Server Actions

Η Βασική Σύνδεση: form action

Η πιο φυσική χρήση των Server Actions είναι μέσω του action prop ενός HTML <form>. Και το καλύτερο; Παρέχει progressive enhancement — η φόρμα λειτουργεί ακόμα και χωρίς JavaScript στον browser!

// app/contact/page.tsx
import { submitContactForm } from "@/app/actions/contact";

export default function ContactPage() {
  return (
    <form action={submitContactForm}>
      <label htmlFor="name">Όνομα</label>
      <input id="name" name="name" type="text" required />

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="message">Μήνυμα</label>
      <textarea id="message" name="message" required />

      <button type="submit">Αποστολή</button>
    </form>
  );
}

Χειρισμός FormData

Η συνάρτηση του Server Action λαμβάνει αυτόματα ένα FormData object. Μπορείς να χρησιμοποιήσεις τις μεθόδους get(), getAll() και entries() για να πάρεις τα δεδομένα:

// app/actions/contact.ts
"use server";

export async function submitContactForm(formData: FormData) {
  // Εξαγωγή μεμονωμένων πεδίων
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const message = formData.get("message") as string;

  // Για πολλαπλές τιμές (π.χ. checkboxes)
  const selectedTags = formData.getAll("tags") as string[];

  // Μετατροπή σε object
  const rawData = Object.fromEntries(formData);

  // Αποθήκευση στη βάση ή αποστολή email
  await sendEmail({ name, email, message });
}

Πέρασμα Επιπλέον Παραμέτρων με bind

Τι γίνεται όμως αν χρειάζεσαι κάτι παραπάνω εκτός από το FormData — π.χ. ένα ID; Εδώ μπαίνει η bind():

// Πέρασμα του taskId ως πρώτο όρισμα
import { updateTask } from "@/app/actions/tasks";

export function TaskEditForm({ task }: { task: Task }) {
  const updateTaskWithId = updateTask.bind(null, task.id);

  return (
    <form action={updateTaskWithId}>
      <input name="title" defaultValue={task.title} />
      <button type="submit">Ενημέρωση</button>
    </form>
  );
}

// Στο actions αρχείο
"use server";

export async function updateTask(taskId: string, formData: FormData) {
  const title = formData.get("title") as string;
  await db.task.update({
    where: { id: taskId },
    data: { title },
  });
  revalidatePath("/tasks");
}

React 19 Hooks για Server Actions

useActionState: Διαχείριση Κατάστασης Φόρμας

Ας περάσουμε στα καλά. Το useActionState (πρώην useFormState) είναι ένα hook του React 19 που διαχειρίζεται την κατάσταση μιας φόρμας: σφάλματα, pending state, αποτελέσματα. Ειλικρινά, είναι ιδανικό για server-side validation με επιστροφή σφαλμάτων στον χρήστη:

// app/actions/auth.ts
"use server";

export type LoginState = {
  errors?: {
    email?: string[];
    password?: string[];
  };
  message?: string;
} | null;

export async function loginAction(
  prevState: LoginState,
  formData: FormData
): Promise<LoginState> {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  // Validation
  if (!email || !email.includes("@")) {
    return {
      errors: { email: ["Παρακαλώ εισάγετε ένα έγκυρο email"] },
    };
  }

  if (!password || password.length < 8) {
    return {
      errors: {
        password: ["Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες"],
      },
    };
  }

  // Προσπάθεια σύνδεσης
  try {
    await signIn(email, password);
    redirect("/dashboard");
  } catch {
    return { message: "Λάθος email ή κωδικός" };
  }
}
// app/login/login-form.tsx
"use client";

import { useActionState } from "react";
import { loginAction, type LoginState } from "@/app/actions/auth";

export function LoginForm() {
  const [state, formAction, isPending] = useActionState<LoginState, FormData>(
    loginAction,
    null
  );

  return (
    <form action={formAction}>
      <div>
        <input
          name="email"
          type="email"
          placeholder="Email"
          disabled={isPending}
        />
        {state?.errors?.email && (
          <p className="text-red-500">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <input
          name="password"
          type="password"
          placeholder="Κωδικός"
          disabled={isPending}
        />
        {state?.errors?.password && (
          <p className="text-red-500">{state.errors.password[0]}</p>
        )}
      </div>

      {state?.message && (
        <p className="text-red-500">{state.message}</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "Σύνδεση..." : "Σύνδεση"}
      </button>
    </form>
  );
}

Πρόσεξε ότι η signature του action αλλάζει: δέχεται prevState ως πρώτο όρισμα και FormData ως δεύτερο. Το τρίτο στοιχείο του tuple (isPending) σου δίνει loading state — πολύ βολικό για να κάνεις disable κουμπιά κατά την υποβολή.

useFormStatus: Loading Indicators από Child Components

Το useFormStatus είναι λίγο ιδιαίτερο. Πρέπει να χρησιμοποιηθεί σε child component μέσα σε ένα <form> — αν το καλέσεις στο ίδιο component που ορίζει τη φόρμα, δεν θα δουλέψει. Αυτό πιάνει πολλούς στην αρχή:

// components/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending, data, method, action } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? (
        <span className="flex items-center gap-2">
          <Spinner /> Υποβολή...
        </span>
      ) : (
        children
      )}
    </button>
  );
}

// Χρήση σε οποιαδήποτε φόρμα
export function TaskForm() {
  return (
    <form action={createTask}>
      <input name="title" />
      <SubmitButton>Δημιουργία Εργασίας</SubmitButton>
    </form>
  );
}

useOptimistic: Αισιόδοξες Ενημερώσεις UI

Αυτό εδώ είναι ίσως το αγαπημένο μου hook. Το useOptimistic σου επιτρέπει να ενημερώνεις το UI αμέσως, πριν ολοκληρωθεί το Server Action. Ο χρήστης βλέπει instant feedback, κι αν κάτι πάει στραβά, το React κάνει rollback αυτόματα:

// app/tasks/task-list.tsx
"use client";

import { useOptimistic } from "react";
import { toggleTaskComplete } from "@/app/actions/tasks";

type Task = {
  id: string;
  title: string;
  completed: boolean;
};

export function TaskList({ tasks }: { tasks: Task[] }) {
  const [optimisticTasks, setOptimisticTask] = useOptimistic(
    tasks,
    (currentTasks, updatedTaskId: string) =>
      currentTasks.map((task) =>
        task.id === updatedTaskId
          ? { ...task, completed: !task.completed }
          : task
      )
  );

  async function handleToggle(taskId: string) {
    // Αμέσως ενημερώνουμε το UI
    setOptimisticTask(taskId);
    // Μετά εκτελούμε το server action
    await toggleTaskComplete(taskId);
  }

  return (
    <ul>
      {optimisticTasks.map((task) => (
        <li key={task.id}>
          <form action={() => handleToggle(task.id)}>
            <button type="submit">
              {task.completed ? "✓" : "○"} {task.title}
            </button>
          </form>
        </li>
      ))}
    </ul>
  );
}

Αν το Server Action αποτύχει, το React επαναφέρει αυτόματα το UI στην προηγούμενη κατάσταση. Δηλαδή παίρνεις consistency χωρίς να γράψεις επιπλέον κώδικα — αρκετά κομψό, αν με ρωτάς.

Validation με Zod

Server-Side Validation Patterns

Εδώ δεν υπάρχει περιθώριο συμβιβασμού. Η χρήση του Zod για validation στα Server Actions είναι βέλτιστη πρακτική — και θα 'λεγα υποχρεωτική. Ποτέ μην εμπιστεύεσαι τα δεδομένα που έρχονται από τον client. Ακόμα κι αν έχεις client-side validation, πάντα κάνε validation και στον server:

// lib/schemas/task.ts
import { z } from "zod";

export const createTaskSchema = z.object({
  title: z
    .string()
    .min(1, "Ο τίτλος είναι υποχρεωτικός")
    .max(100, "Ο τίτλος δεν μπορεί να υπερβαίνει τους 100 χαρακτήρες"),
  description: z
    .string()
    .max(500, "Η περιγραφή δεν μπορεί να υπερβαίνει τους 500 χαρακτήρες")
    .optional(),
  priority: z.enum(["low", "medium", "high"], {
    errorMap: () => ({ message: "Επιλέξτε έγκυρη προτεραιότητα" }),
  }),
  dueDate: z
    .string()
    .refine((val) => !val || !isNaN(Date.parse(val)), {
      message: "Μη έγκυρη ημερομηνία",
    })
    .optional(),
});

export type CreateTaskInput = z.infer<typeof createTaskSchema>;

Επιστροφή Σφαλμάτων στον Client

Αφού κάνεις validate τα δεδομένα, πρέπει να στείλεις τα σφάλματα πίσω στον client με τρόπο που το UI μπορεί να τα εμφανίσει εύκολα. Δες πώς:

// app/actions/tasks.ts
"use server";

import { createTaskSchema } from "@/lib/schemas/task";

export type TaskActionState = {
  success?: boolean;
  errors?: Record<string, string[]>;
  message?: string;
} | null;

export async function createTask(
  prevState: TaskActionState,
  formData: FormData
): Promise<TaskActionState> {
  // Μετατροπή FormData σε object
  const rawData = {
    title: formData.get("title"),
    description: formData.get("description"),
    priority: formData.get("priority"),
    dueDate: formData.get("dueDate"),
  };

  // Validation με Zod
  const validatedFields = createTaskSchema.safeParse(rawData);

  if (!validatedFields.success) {
    return {
      success: false,
      errors: validatedFields.error.flatten().fieldErrors,
      message: "Παρακαλώ διορθώστε τα σφάλματα στη φόρμα",
    };
  }

  // Τα δεδομένα είναι πλέον type-safe
  const { title, description, priority, dueDate } = validatedFields.data;

  try {
    await db.task.create({
      data: {
        title,
        description,
        priority,
        dueDate: dueDate ? new Date(dueDate) : undefined,
      },
    });

    revalidatePath("/tasks");
    return { success: true, message: "Η εργασία δημιουργήθηκε επιτυχώς!" };
  } catch (error) {
    return {
      success: false,
      message: "Αποτυχία δημιουργίας εργασίας. Δοκιμάστε ξανά.",
    };
  }
}
// components/task-form.tsx
"use client";

import { useActionState } from "react";
import { createTask, type TaskActionState } from "@/app/actions/tasks";
import { SubmitButton } from "./submit-button";

export function TaskForm() {
  const [state, formAction] = useActionState<TaskActionState, FormData>(
    createTask,
    null
  );

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="title">Τίτλος</label>
        <input id="title" name="title" type="text" />
        {state?.errors?.title && (
          <p className="text-red-500 text-sm">{state.errors.title[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="priority">Προτεραιότητα</label>
        <select id="priority" name="priority">
          <option value="low">Χαμηλή</option>
          <option value="medium">Μεσαία</option>
          <option value="high">Υψηλή</option>
        </select>
        {state?.errors?.priority && (
          <p className="text-red-500 text-sm">{state.errors.priority[0]}</p>
        )}
      </div>

      {state?.message && (
        <div className={state.success ? "text-green-600" : "text-red-600"}>
          {state.message}
        </div>
      )}

      <SubmitButton>Δημιουργία Εργασίας</SubmitButton>
    </form>
  );
}

Authentication και Authorization

Κάθε Server Action Είναι Δημόσιο Endpoint

Αυτό είναι ίσως το πιο σημαντικό πράγμα που πρέπει να καταλάβεις σε αυτόν τον οδηγό: κάθε Server Action μπορεί να κληθεί από οποιονδήποτε, ακόμα κι εκτός του UI σου. Κάποιος μπορεί να βρει το action ID στο network tab και να κάνει POST request απευθείας.

Γι' αυτό πάντα ελέγχεις authentication και authorization μέσα σε κάθε action. Χωρίς εξαιρέσεις.

// app/actions/tasks.ts
"use server";

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

export async function deleteTask(taskId: string) {
  // 1. Έλεγχος Authentication
  const session = await auth();
  if (!session?.user) {
    throw new Error("Πρέπει να συνδεθείτε");
  }

  // 2. Έλεγχος Authorization - ο χρήστης κατέχει αυτή την εργασία;
  const task = await db.task.findUnique({
    where: { id: taskId },
  });

  if (!task) {
    throw new Error("Η εργασία δεν βρέθηκε");
  }

  if (task.userId !== session.user.id) {
    throw new Error("Δεν έχετε δικαίωμα διαγραφής αυτής της εργασίας");
  }

  // 3. Μόνο τώρα εκτελούμε τη διαγραφή
  await db.task.delete({
    where: { id: taskId },
  });

  revalidatePath("/tasks");
}

Pattern: Auth Helper για Server Actions

Δεν θέλεις να γράφεις τον ίδιο κώδικα auth σε κάθε action — σωστά; Φτιάξε ένα helper:

// lib/action-utils.ts
import { auth } from "@/lib/auth";

export async function getAuthenticatedUser() {
  const session = await auth();

  if (!session?.user?.id) {
    throw new Error("Μη εξουσιοδοτημένη πρόσβαση");
  }

  return session.user;
}

// Χρήση σε actions
"use server";

import { getAuthenticatedUser } from "@/lib/action-utils";

export async function createTask(formData: FormData) {
  const user = await getAuthenticatedUser(); // Θα κάνει throw αν δεν είναι συνδεδεμένος

  await db.task.create({
    data: {
      title: formData.get("title") as string,
      userId: user.id,
    },
  });
}

Στρατηγικές Revalidation μετά από Mutations

revalidatePath: Ανανέωση Συγκεκριμένης Σελίδας

Η revalidatePath ακυρώνει το cache για μια συγκεκριμένη διαδρομή, αναγκάζοντας τα Server Components να τρέξουν ξανά. Είναι ο πιο απλός τρόπος να ανανεώσεις τα δεδομένα:

"use server";

import { revalidatePath } from "next/cache";

export async function updateProfile(formData: FormData) {
  await db.user.update({ /* ... */ });

  // Ανανέωση μιας συγκεκριμένης σελίδας
  revalidatePath("/profile");

  // Ανανέωση ενός layout (και όλων των υποσελίδων)
  revalidatePath("/dashboard", "layout");

  // Ανανέωση όλου του site
  revalidatePath("/", "layout");
}

revalidateTag: Ανανέωση με Βάση Tags

Η revalidateTag είναι πιο ακριβής — και πιο αποδοτική σε μεγάλες εφαρμογές. Αντί να ακυρώνεις ολόκληρη σελίδα, ακυρώνεις μόνο τα δεδομένα που φέρουν συγκεκριμένο tag:

// Στο data fetching - ορίζουμε tags
async function getTasks() {
  const res = await fetch("https://api.example.com/tasks", {
    next: { tags: ["tasks"] },
  });
  return res.json();
}

async function getTask(id: string) {
  const res = await fetch(`https://api.example.com/tasks/${id}`, {
    next: { tags: ["tasks", `task-${id}`] },
  });
  return res.json();
}

// Στο Server Action - ακυρώνουμε τα αντίστοιχα tags
"use server";

import { revalidateTag } from "next/cache";

export async function updateTask(taskId: string, formData: FormData) {
  await db.task.update({ /* ... */ });

  // Ακυρώνει μόνο τη συγκεκριμένη εργασία
  revalidateTag(`task-${taskId}`);

  // Ή ακυρώνει ΟΛΑ τα tasks
  revalidateTag("tasks");
}

Πότε να Χρησιμοποιείς redirect

Η redirect() πρέπει να καλείται μετά από mutation που αλλάζει σελίδα. Ένα σημαντικό "gotcha": η redirect πρέπει να καλείται εκτός try/catch, γιατί εσωτερικά λειτουργεί μέσω throw:

"use server";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

export async function createProject(formData: FormData) {
  let projectId: string;

  try {
    const project = await db.project.create({
      data: { name: formData.get("name") as string },
    });
    projectId = project.id;
  } catch (error) {
    return { error: "Αποτυχία δημιουργίας project" };
  }

  // Η redirect πρέπει να είναι ΕΚΤΟΣ try/catch
  revalidatePath("/projects");
  redirect(`/projects/${projectId}`);
}

Η Βιβλιοθήκη next-safe-action

Γιατί next-safe-action;

Σε κάποιο σημείο, θα παρατηρήσεις ότι σε κάθε Server Action επαναλαμβάνεις τον ίδιο κώδικα: auth check, validation, error handling... Η βιβλιοθήκη next-safe-action λύνει ακριβώς αυτό το πρόβλημα, προσφέροντας ένα middleware pipeline και type-safe action clients. Για production εφαρμογές, αξίζει σίγουρα μια ματιά.

Δημιουργία Action Client με Middleware

// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { auth } from "@/lib/auth";

// Βασικός client χωρίς auth
export const actionClient = createSafeActionClient({
  handleServerError(error) {
    // Logging σε production
    console.error("Action error:", error.message);

    // Επιστροφή γενικού μηνύματος στον client
    return "Κάτι πήγε στραβά. Παρακαλώ δοκιμάστε ξανά.";
  },
});

// Client με authentication middleware
export const authActionClient = actionClient.use(async ({ next }) => {
  const session = await auth();

  if (!session?.user?.id) {
    throw new Error("Μη εξουσιοδοτημένη πρόσβαση");
  }

  // Περνάμε τα δεδομένα χρήστη στο επόμενο middleware/action
  return next({
    ctx: {
      userId: session.user.id,
      userRole: session.user.role,
    },
  });
});

// Client με auth + rate limiting
export const rateLimitedActionClient = authActionClient.use(
  async ({ ctx, next }) => {
    const { userId } = ctx;

    // Απλός rate limiting έλεγχος
    const rateLimitKey = `action:${userId}`;
    const isLimited = await checkRateLimit(rateLimitKey, {
      maxRequests: 10,
      windowMs: 60000, // 10 αιτήματα ανά λεπτό
    });

    if (isLimited) {
      throw new Error("Πολλά αιτήματα. Δοκιμάστε ξανά σε λίγο.");
    }

    return next({ ctx });
  }
);

Χρήση σε Actions

Δες πόσο πιο καθαρά γίνεται ο κώδικας με next-safe-action:

// app/actions/tasks.ts
"use server";

import { authActionClient } from "@/lib/safe-action";
import { createTaskSchema } from "@/lib/schemas/task";
import { revalidatePath } from "next/cache";

export const createTask = authActionClient
  .schema(createTaskSchema)
  .action(async ({ parsedInput, ctx }) => {
    // parsedInput είναι ήδη validated και typed!
    // ctx.userId είναι διαθέσιμο από το auth middleware
    const { title, description, priority } = parsedInput;

    const task = await db.task.create({
      data: {
        title,
        description,
        priority,
        userId: ctx.userId,
      },
    });

    revalidatePath("/tasks");
    return { task };
  });

export const deleteTask = authActionClient
  .schema(z.object({ taskId: z.string().uuid() }))
  .action(async ({ parsedInput, ctx }) => {
    const task = await db.task.findUnique({
      where: { id: parsedInput.taskId },
    });

    if (task?.userId !== ctx.userId) {
      throw new Error("Δεν επιτρέπεται");
    }

    await db.task.delete({ where: { id: parsedInput.taskId } });
    revalidatePath("/tasks");
  });

Χρήση στον Client με useAction

// components/task-form.tsx
"use client";

import { useAction } from "next-safe-action/hooks";
import { createTask } from "@/app/actions/tasks";

export function TaskForm() {
  const { execute, result, isExecuting, hasSucceeded } = useAction(createTask);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        execute({
          title: formData.get("title") as string,
          description: formData.get("description") as string,
          priority: formData.get("priority") as "low" | "medium" | "high",
        });
      }}
    >
      <input name="title" />

      {result.validationErrors?.title && (
        <p className="text-red-500">
          {result.validationErrors.title._errors[0]}
        </p>
      )}

      {result.serverError && (
        <p className="text-red-500">{result.serverError}</p>
      )}

      {hasSucceeded && (
        <p className="text-green-500">Η εργασία δημιουργήθηκε!</p>
      )}

      <button disabled={isExecuting}>
        {isExecuting ? "Δημιουργία..." : "Δημιουργία"}
      </button>
    </form>
  );
}

Βέλτιστες Πρακτικές Ασφαλείας

CSRF Protection: Μόνο POST

Τα Server Actions δέχονται αποκλειστικά POST requests. Αυτό σου δίνει ενσωματωμένη προστασία CSRF, αφού οι browsers δεν στέλνουν POST requests cross-origin χωρίς CORS headers. Το Next.js ελέγχει επιπλέον τα Origin και Host headers για να αποτρέψει cross-site request forgery. Ωραία, αλλά μην αρκείσαι μόνο σε αυτό.

Ποτέ Μην Εμπιστεύεσαι τα Δεδομένα του Client

Αυτό αξίζει να το πω ξανά, γιατί είναι η νούμερο ένα αιτία ευπαθειών:

"use server";

// ΛΑΘΟΣ: Εμπιστευόμαστε τυφλά το client
export async function unsafeAction(formData: FormData) {
  const userId = formData.get("userId") as string; // Ο χρήστης μπορεί να στείλει οποιοδήποτε ID!
  await db.task.deleteMany({ where: { userId } });
}

// ΣΩΣΤΟ: Παίρνουμε το userId από το session
export async function safeAction(formData: FormData) {
  const session = await auth();
  if (!session?.user?.id) throw new Error("Unauthorized");

  // Χρησιμοποιούμε ΜΟΝΟ το ID από το authenticated session
  await db.task.deleteMany({ where: { userId: session.user.id } });
}

Ασφάλεια Closures

Όταν χρησιμοποιείς inline Server Actions με closures, πρόσεξε τι μεταβλητές "κλείνεις" μέσα. Το Next.js κρυπτογραφεί τα closure values, αλλά μπορεί να εκθέσεις κατά λάθος ευαίσθητα δεδομένα:

// ΠΡΟΣΟΧΗ: Το secretData θα κρυπτογραφηθεί και θα σταλεί στον client
export default async function Page() {
  const secretData = await getSecretConfig();

  async function action() {
    "use server";
    // Αν χρησιμοποιήσετε secretData εδώ, θα γίνει μέρος του closure
    console.log(secretData); // Αποφύγετε αυτό!
  }

  // ΣΩΣΤΟ: Φέρτε τα ευαίσθητα δεδομένα μέσα στο action
  async function safeAction() {
    "use server";
    const secretData = await getSecretConfig(); // Ασφαλές - εκτελείται στον server
  }
}

Περιορισμοί Μεγέθους

Από προεπιλογή, το body ενός Server Action μπορεί να είναι έως 1MB. Μπορείς να το αλλάξεις στο next.config.js, αλλά να είσαι προσεκτικός — μεγαλύτερο limit σημαίνει μεγαλύτερη επιφάνεια επίθεσης:

// next.config.js
module.exports = {
  experimental: {
    serverActions: {
      bodySizeLimit: "2mb", // Αυξήστε μόνο αν χρειάζεται
    },
  },
};

Input Validation Checklist

  • Χρησιμοποίησε πάντα Zod ή αντίστοιχη βιβλιοθήκη για validation
  • Έλεγξε τον τύπο, το μέγεθος και τη μορφή κάθε input
  • Κάνε sanitize strings πριν τα αποθηκεύσεις (αποφυγή XSS)
  • Ποτέ μη χρησιμοποιείς IDs ή roles που στέλνει ο client
  • Χρησιμοποίησε parameterized queries για αποφυγή SQL injection

Performance και Patterns Αρχιτεκτονικής

Κράτα τα Actions Μικρά και Εστιασμένα

Κάθε Server Action πρέπει να κάνει ένα πράγμα. Μην φτιάχνεις monolithic actions που χειρίζονται τα πάντα — είναι δύσκολα στο testing και στη συντήρηση:

// ΛΑΘΟΣ: Ένα action κάνει τα πάντα
export async function handleTaskAction(
  type: "create" | "update" | "delete",
  formData: FormData
) {
  switch (type) { /* ... */ }
}

// ΣΩΣΤΟ: Ξεχωριστά actions για κάθε λειτουργία
export async function createTask(formData: FormData) { /* ... */ }
export async function updateTask(taskId: string, formData: FormData) { /* ... */ }
export async function deleteTask(taskId: string) { /* ... */ }

Διαχωρισμός Ευθυνών

Η σωστή οργάνωση αρχείων κάνει τεράστια διαφορά σε βάθος χρόνου. Ακολούθησε αυτή τη δομή:

app/
├── actions/
│   ├── tasks.ts       // Server Actions για εργασίες
│   ├── projects.ts    // Server Actions για projects
│   └── auth.ts        // Server Actions για authentication
├── lib/
│   ├── schemas/
│   │   ├── task.ts    // Zod schemas για εργασίες
│   │   └── project.ts // Zod schemas για projects
│   ├── db.ts          // Database client
│   └── safe-action.ts // Action client configuration
└── components/
    ├── task-form.tsx   // Client component με φόρμα
    └── task-list.tsx   // Client component με λίστα

Error Boundaries με Server Actions

Τα Server Actions που κάνουν throw στέλνουν το σφάλμα στο πλησιέστερο error.tsx boundary. Για καλύτερο UX, προτίμησε να επιστρέφεις errors αντί να κάνεις throw:

// Επιστροφή σφαλμάτων αντί throw (καλύτερο UX)
export async function createTask(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  try {
    // ... validation και logic
    return { success: true };
  } catch (error) {
    // Αν θέλεις granular error handling, επέστρεψε
    return { success: false, error: "Κάτι πήγε στραβά" };
  }
  // Κάνε throw ΜΟΝΟ για πραγματικά μη αναμενόμενα σφάλματα
  // που θέλεις να πιαστούν από το error boundary
}

Composability: Σύνθεση Actions

Μπορείς (και πρέπει) να μοιράζεσαι κοινή λογική μεταξύ actions μέσω service functions. Το κόλπο; Αυτά τα service files δεν χρειάζονται "use server":

// lib/services/task-service.ts
// (ΟΧΙ "use server" - αυτό είναι κοινός server-side κώδικας)

export async function createTaskInDB(data: CreateTaskInput, userId: string) {
  return db.task.create({
    data: { ...data, userId },
  });
}

export async function validateTaskOwnership(taskId: string, userId: string) {
  const task = await db.task.findUnique({ where: { id: taskId } });
  if (!task || task.userId !== userId) {
    throw new Error("Μη εξουσιοδοτημένη πρόσβαση");
  }
  return task;
}

// app/actions/tasks.ts
"use server";

import { createTaskInDB, validateTaskOwnership } from "@/lib/services/task-service";

export async function createTask(formData: FormData) {
  const user = await getAuthenticatedUser();
  const validated = createTaskSchema.parse(/* ... */);
  await createTaskInDB(validated, user.id);
  revalidatePath("/tasks");
}

export async function deleteTask(taskId: string) {
  const user = await getAuthenticatedUser();
  await validateTaskOwnership(taskId, user.id);
  await db.task.delete({ where: { id: taskId } });
  revalidatePath("/tasks");
}

Πρακτικό Παράδειγμα: Ολοκληρωμένο CRUD Εργασιών

Ωραία, αρκετή θεωρία. Ας φτιάξουμε ένα ολοκληρωμένο παράδειγμα εφαρμογής διαχείρισης εργασιών που συνδυάζει όλα όσα μάθαμε: Server Actions, Zod validation, optimistic UI και revalidation. Αυτό είναι που θα σε βοηθήσει περισσότερο να δεις πώς δένουν τα κομμάτια μεταξύ τους.

Βήμα 1: Schema Validation

// lib/schemas/task.ts
import { z } from "zod";

export const taskSchema = z.object({
  title: z
    .string()
    .min(1, "Ο τίτλος είναι υποχρεωτικός")
    .max(200, "Μέγιστο 200 χαρακτήρες"),
  completed: z.boolean().default(false),
});

export const updateTaskSchema = taskSchema.partial().extend({
  id: z.string().uuid("Μη έγκυρο ID εργασίας"),
});

export const deleteTaskSchema = z.object({
  id: z.string().uuid("Μη έγκυρο ID εργασίας"),
});

export type Task = {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
  userId: string;
};

export type ActionState = {
  success?: boolean;
  errors?: Record<string, string[]>;
  message?: string;
} | null;

Βήμα 2: Server Actions

// app/actions/tasks.ts
"use server";

import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import {
  taskSchema,
  updateTaskSchema,
  deleteTaskSchema,
  type ActionState,
} from "@/lib/schemas/task";

// Βοηθητική συνάρτηση authentication
async function requireAuth() {
  const session = await auth();
  if (!session?.user?.id) {
    throw new Error("Πρέπει να συνδεθείτε");
  }
  return session.user;
}

// CREATE - Δημιουργία εργασίας
export async function createTask(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const user = await requireAuth();

  const rawData = {
    title: formData.get("title"),
    completed: false,
  };

  const validated = taskSchema.safeParse(rawData);

  if (!validated.success) {
    return {
      success: false,
      errors: validated.error.flatten().fieldErrors,
    };
  }

  try {
    await db.task.create({
      data: {
        title: validated.data.title,
        completed: false,
        userId: user.id,
      },
    });

    revalidatePath("/tasks");
    return { success: true, message: "Η εργασία δημιουργήθηκε!" };
  } catch {
    return {
      success: false,
      message: "Αποτυχία δημιουργίας. Δοκιμάστε ξανά.",
    };
  }
}

// UPDATE - Ενημέρωση εργασίας
export async function updateTask(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const user = await requireAuth();

  const rawData = {
    id: formData.get("id"),
    title: formData.get("title"),
    completed: formData.get("completed") === "true",
  };

  const validated = updateTaskSchema.safeParse(rawData);

  if (!validated.success) {
    return {
      success: false,
      errors: validated.error.flatten().fieldErrors,
    };
  }

  // Έλεγχος ιδιοκτησίας
  const existingTask = await db.task.findUnique({
    where: { id: validated.data.id },
  });

  if (!existingTask || existingTask.userId !== user.id) {
    return {
      success: false,
      message: "Η εργασία δεν βρέθηκε ή δεν έχετε δικαίωμα",
    };
  }

  try {
    await db.task.update({
      where: { id: validated.data.id },
      data: {
        ...(validated.data.title && { title: validated.data.title }),
        ...(validated.data.completed !== undefined && {
          completed: validated.data.completed,
        }),
      },
    });

    revalidatePath("/tasks");
    return { success: true, message: "Η εργασία ενημερώθηκε!" };
  } catch {
    return {
      success: false,
      message: "Αποτυχία ενημέρωσης. Δοκιμάστε ξανά.",
    };
  }
}

// TOGGLE - Εναλλαγή κατάστασης ολοκλήρωσης
export async function toggleTask(taskId: string) {
  const user = await requireAuth();

  const task = await db.task.findUnique({
    where: { id: taskId },
  });

  if (!task || task.userId !== user.id) {
    throw new Error("Μη εξουσιοδοτημένη πρόσβαση");
  }

  await db.task.update({
    where: { id: taskId },
    data: { completed: !task.completed },
  });

  revalidatePath("/tasks");
}

// DELETE - Διαγραφή εργασίας
export async function deleteTask(taskId: string) {
  const user = await requireAuth();

  const validated = deleteTaskSchema.safeParse({ id: taskId });

  if (!validated.success) {
    throw new Error("Μη έγκυρο ID εργασίας");
  }

  const task = await db.task.findUnique({
    where: { id: validated.data.id },
  });

  if (!task || task.userId !== user.id) {
    throw new Error("Μη εξουσιοδοτημένη πρόσβαση");
  }

  await db.task.delete({
    where: { id: validated.data.id },
  });

  revalidatePath("/tasks");
}

Βήμα 3: Server Component (Σελίδα)

// app/tasks/page.tsx
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { TaskForm } from "./task-form";
import { TaskList } from "./task-list";

export default async function TasksPage() {
  const session = await auth();

  if (!session?.user) {
    redirect("/login");
  }

  const tasks = await db.task.findMany({
    where: { userId: session.user.id },
    orderBy: { createdAt: "desc" },
  });

  return (
    <main className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-8">
        Οι Εργασίες μου
      </h1>
      <TaskForm />
      <TaskList tasks={tasks} />
    </main>
  );
}

Βήμα 4: Client Component - Φόρμα Δημιουργίας

// app/tasks/task-form.tsx
"use client";

import { useActionState, useRef } from "react";
import { createTask, type ActionState } from "@/app/actions/tasks";
import { SubmitButton } from "@/components/submit-button";

export function TaskForm() {
  const formRef = useRef<HTMLFormElement>(null);
  const [state, formAction] = useActionState<ActionState, FormData>(
    createTask,
    null
  );

  // Καθαρισμός φόρμας μετά από επιτυχία
  if (state?.success) {
    formRef.current?.reset();
  }

  return (
    <form ref={formRef} action={formAction} className="flex gap-2 mb-6">
      <div className="flex-1">
        <input
          name="title"
          type="text"
          placeholder="Τι πρέπει να κάνετε;"
          className="w-full px-4 py-2 border rounded-lg"
          required
        />
        {state?.errors?.title && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.title[0]}
          </p>
        )}
      </div>
      <SubmitButton>Προσθήκη</SubmitButton>
    </form>
  );
}

Βήμα 5: Client Component - Λίστα με Optimistic UI

// app/tasks/task-list.tsx
"use client";

import { useOptimistic, useTransition } from "react";
import { toggleTask, deleteTask } from "@/app/actions/tasks";
import type { Task } from "@/lib/schemas/task";

export function TaskList({ tasks }: { tasks: Task[] }) {
  const [optimisticTasks, setOptimisticTasks] = useOptimistic(
    tasks,
    (
      currentTasks: Task[],
      action: { type: "toggle" | "delete"; taskId: string }
    ) => {
      switch (action.type) {
        case "toggle":
          return currentTasks.map((t) =>
            t.id === action.taskId
              ? { ...t, completed: !t.completed }
              : t
          );
        case "delete":
          return currentTasks.filter((t) => t.id !== action.taskId);
        default:
          return currentTasks;
      }
    }
  );

  const [isPending, startTransition] = useTransition();

  function handleToggle(taskId: string) {
    startTransition(async () => {
      setOptimisticTasks({ type: "toggle", taskId });
      await toggleTask(taskId);
    });
  }

  function handleDelete(taskId: string) {
    startTransition(async () => {
      setOptimisticTasks({ type: "delete", taskId });
      await deleteTask(taskId);
    });
  }

  if (optimisticTasks.length === 0) {
    return (
      <p className="text-gray-500 text-center py-8">
        Δεν υπάρχουν εργασίες. Δημιουργήστε μία!
      </p>
    );
  }

  return (
    <ul className="space-y-2">
      {optimisticTasks.map((task) => (
        <li
          key={task.id}
          className="flex items-center justify-between p-3 bg-white
                     rounded-lg border shadow-sm"
        >
          <div className="flex items-center gap-3">
            <button
              onClick={() => handleToggle(task.id)}
              className={`w-6 h-6 rounded-full border-2 flex
                items-center justify-center transition-colors
                ${
                  task.completed
                    ? "bg-green-500 border-green-500 text-white"
                    : "border-gray-300 hover:border-green-400"
                }`}
              aria-label={
                task.completed
                  ? "Επισήμανση ως μη ολοκληρωμένη"
                  : "Επισήμανση ως ολοκληρωμένη"
              }
            >
              {task.completed && "✓"}
            </button>
            <span
              className={
                task.completed
                  ? "line-through text-gray-400"
                  : "text-gray-800"
              }
            >
              {task.title}
            </span>
          </div>

          <button
            onClick={() => handleDelete(task.id)}
            className="text-red-400 hover:text-red-600 transition-colors"
            aria-label="Διαγραφή εργασίας"
          >
            Διαγραφή
          </button>
        </li>
      ))}
    </ul>
  );
}

Βήμα 6: Reusable Submit Button

// components/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

interface SubmitButtonProps {
  children: React.ReactNode;
  className?: string;
}

export function SubmitButton({ children, className }: SubmitButtonProps) {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className={`px-4 py-2 bg-blue-600 text-white rounded-lg
        hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
        transition-all ${className || ""}`}
    >
      {pending ? (
        <span className="flex items-center gap-2">
          <span className="animate-spin">⟳</span>
          Αποθήκευση...
        </span>
      ) : (
        children
      )}
    </button>
  );
}

Αυτό το ολοκληρωμένο παράδειγμα δείχνει πώς συνδυάζονται τα κομμάτια στην πράξη: ο server φέρνει τα δεδομένα, τα Client Components διαχειρίζονται την αλληλεπίδραση, τα Server Actions εκτελούν mutations, το Zod εξασφαλίζει validation, τα optimistic updates βελτιώνουν το UX, και η revalidation ανανεώνει τα δεδομένα. Κάθε action ελέγχει authentication και authorization — κανένα "shortcut" δεν επιτρέπεται εδώ.

Συμπέρασμα: Σύνοψη και Checklist

Τα Server Actions αλλάζουν ριζικά τον τρόπο που χτίζουμε mutations στο Next.js. Αντί για τον κλασικό κύκλο "API route → fetch → handle response", τώρα καλούμε κατευθείαν server functions με type safety, automatic serialization και progressive enhancement.

Ας πάρουμε τα πράγματα από την αρχή. Τι πρέπει να θυμάσαι;

Checklist Βέλτιστων Πρακτικών

  1. Χρησιμοποίησε ξεχωριστά αρχεία actions: Τοποθέτησε τα Server Actions σε αρχεία "use server" κάτω από app/actions/. Καθαρή οργάνωση, εύκολη επαναχρησιμοποίηση.
  2. Πάντα validation με Zod: Κάθε input πρέπει να περνάει από server-side validation. Μην εμπιστεύεσαι ποτέ τα δεδομένα του client, ακόμα κι αν έχεις client-side validation.
  3. Πάντα authentication και authorization: Κάθε Server Action είναι δημόσιο endpoint. Έλεγξε ότι ο χρήστης είναι συνδεδεμένος ΚΑΙ ότι έχει δικαίωμα για τη συγκεκριμένη ενέργεια.
  4. Χρησιμοποίησε useActionState για φόρμες: Αντί για custom state management, άφησε το React να διαχειρίζεται pending states και errors μέσω useActionState.
  5. Optimistic updates για καλύτερο UX: Χρησιμοποίησε useOptimistic για λειτουργίες που αναμένεται να πετύχουν (toggle, delete). Το React κάνει rollback αν αποτύχει.
  6. Σωστή revalidation: revalidateTag για ακριβή invalidation, revalidatePath για ανανέωση σελίδων. Η redirect πάει πάντα εκτός try/catch.
  7. Σκέψου next-safe-action: Σε production εφαρμογές, εξαλείφει boilerplate και δίνει middleware pipeline για auth, validation και error handling.
  8. Κράτα τα actions μικρά: Ένα action = μία λειτουργία. Μοιράσου κοινή λογική μέσω service functions χωρίς "use server".
  9. Επέστρεφε errors, μην κάνεις throw: Για αναμενόμενα σφάλματα (validation, permission denied), επέστρεψε error state. Τα throws είναι για μη αναμενόμενα σφάλματα και error boundaries.
  10. Πρόσεχε τα closures: Σε inline Server Actions, μην κλείνεις ευαίσθητα δεδομένα στο closure. Φέρε τα δεδομένα μέσα στη συνάρτηση.

Τα Server Actions αποτελούν θεμελιώδη αλλαγή στο μοντέλο ανάπτυξης web εφαρμογών. Εξαλείφουν ένα ολόκληρο επίπεδο boilerplate και σε αφήνουν να εστιάσεις στη business logic. Σε συνδυασμό με τα hooks του React 19 (useActionState, useFormStatus, useOptimistic), μπορείς να χτίσεις εφαρμογές γρήγορες, ασφαλείς και εύκολες στη συντήρηση.

Η συμβουλή μου; Ξεκίνα μετατρέποντας ένα απλό API route σε Server Action. Εξοικειώσου με το pattern, κι ύστερα σταδιακά υιοθέτησέ τα σε ολόκληρη την εφαρμογή. Η καμπύλη εκμάθησης είναι μικρή, αλλά τα οφέλη σε παραγωγικότητα και ποιότητα κώδικα είναι πραγματικά αξιοσημείωτα.

Σχετικά με τον Συγγραφέα Editorial Team

Our team of expert writers and editors.