Εισαγωγή: Τι Είναι τα 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 Βέλτιστων Πρακτικών
- Χρησιμοποίησε ξεχωριστά αρχεία actions: Τοποθέτησε τα Server Actions σε αρχεία
"use server"κάτω απόapp/actions/. Καθαρή οργάνωση, εύκολη επαναχρησιμοποίηση. - Πάντα validation με Zod: Κάθε input πρέπει να περνάει από server-side validation. Μην εμπιστεύεσαι ποτέ τα δεδομένα του client, ακόμα κι αν έχεις client-side validation.
- Πάντα authentication και authorization: Κάθε Server Action είναι δημόσιο endpoint. Έλεγξε ότι ο χρήστης είναι συνδεδεμένος ΚΑΙ ότι έχει δικαίωμα για τη συγκεκριμένη ενέργεια.
- Χρησιμοποίησε useActionState για φόρμες: Αντί για custom state management, άφησε το React να διαχειρίζεται pending states και errors μέσω
useActionState. - Optimistic updates για καλύτερο UX: Χρησιμοποίησε
useOptimisticγια λειτουργίες που αναμένεται να πετύχουν (toggle, delete). Το React κάνει rollback αν αποτύχει. - Σωστή revalidation:
revalidateTagγια ακριβή invalidation,revalidatePathγια ανανέωση σελίδων. Ηredirectπάει πάντα εκτός try/catch. - Σκέψου next-safe-action: Σε production εφαρμογές, εξαλείφει boilerplate και δίνει middleware pipeline για auth, validation και error handling.
- Κράτα τα actions μικρά: Ένα action = μία λειτουργία. Μοιράσου κοινή λογική μέσω service functions χωρίς
"use server". - Επέστρεφε errors, μην κάνεις throw: Για αναμενόμενα σφάλματα (validation, permission denied), επέστρεψε error state. Τα throws είναι για μη αναμενόμενα σφάλματα και error boundaries.
- Πρόσεχε τα closures: Σε inline Server Actions, μην κλείνεις ευαίσθητα δεδομένα στο closure. Φέρε τα δεδομένα μέσα στη συνάρτηση.
Τα Server Actions αποτελούν θεμελιώδη αλλαγή στο μοντέλο ανάπτυξης web εφαρμογών. Εξαλείφουν ένα ολόκληρο επίπεδο boilerplate και σε αφήνουν να εστιάσεις στη business logic. Σε συνδυασμό με τα hooks του React 19 (useActionState, useFormStatus, useOptimistic), μπορείς να χτίσεις εφαρμογές γρήγορες, ασφαλείς και εύκολες στη συντήρηση.
Η συμβουλή μου; Ξεκίνα μετατρέποντας ένα απλό API route σε Server Action. Εξοικειώσου με το pattern, κι ύστερα σταδιακά υιοθέτησέ τα σε ολόκληρη την εφαρμογή. Η καμπύλη εκμάθησης είναι μικρή, αλλά τα οφέλη σε παραγωγικότητα και ποιότητα κώδικα είναι πραγματικά αξιοσημείωτα.