"توقّف عن إنشاء API routes لكل عملية تعديل. ميزة Server Actions في Next.js 16 تغيّر قواعد اللعبة. فهي ليست مجرد ميزة جديدة، بل طريقة مختلفة تمامًا لبناء التطبيقات التفاعلية."
المشكلة اللي كنا بنعيشها
لسنين، كنا بنعمل نفس الحاجة. عايز تحدث بيانات؟ اعمل API route. عايز ترسل form؟ اعمل API route. عايز تحذف حاجة؟ اعمل API route.
// الطريقة القديمة - API Route
// app/api/posts/route.ts
export async function POST(request: Request) {
const data = await request.json()
const session = await getSession()
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const post = await db.posts.create({ data })
return Response.json(post)
}
// المكون من جهة العميل
'use client'
export function CreatePostForm() {
async function handleSubmit(e: FormEvent) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData)),
})
if (!res.ok) {
alert('خطأ!')
}
}
return <form onSubmit={handleSubmit}>...</form>
}
شوف كل الكود ده؟ API route منفصل، جلب من جهة العميل، معالجة أخطاء يدوية، مفيش progressive enhancement. لو JavaScript ما اشتغل؟ الفورم ما بيشتغل.
بعدين Next.js 16 قدم Server Actions. وفجأة، كل ده بقى غير ضروري.
الطريقة الجديدة: Server Actions
شوف نفس المثال مع Server Actions:
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const session = await getSession();
if (!session) {
throw new Error("Unauthorized");
}
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db.posts.create({
data: { title, content, userId: session.user.id },
});
revalidatePath("/posts");
}
والفورم:
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">إنشاء</button>
</form>
)
}
خلاص. ما في API route. مفيش جلب من جهة العميل. مفيش معالجة أخطاء معقدة. بس فورم بسيط بيشتغل حتى لو JavaScript مش موجود.
ده قوة Server Actions.
إيه هي Server Actions؟
الـ Server Action هي دالة async بتشتغل على السيرفر فقط. وهي نوع خاص من Server Functions مصممة تحديدًا لعمليات التعديل (mutations) وإرسال النماذج (form submissions).
Server Actions:
بتشتغل على السيرفر بس (ما بتتعرض client أبداً)
بتستخدم POST method بس
تقدر تتنادى مباشرة من Client Components
بتدعم progressive enhancement (بتشتغل بدون JavaScript)
بترجع UI محدث وبيانات في roundtrip واحد
إنشاء Server Actions
الطريقة الأولى: ملف منفصل (موصى به)
اعمل ملف مخصص مع موجه 'use server' في الأول:
// app/actions/posts.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
// تعريف schema للتحقق
const PostSchema = z.object({
title: z
.string()
.min(3, { message: "العنوان لازم يكون 3 أحرف على الأقل" })
.trim(),
content: z
.string()
.min(10, { message: "المحتوى لازم يكون 10 أحرف على الأقل" })
.trim(),
});
export async function createPost(formData: FormData) {
// 1. تحقق من الصلاحيات
const session = await getSession();
if (!session?.user) {
throw new Error("غير مصرح");
}
// 2. تحقق من البيانات
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: "فشل التحقق. راجع المدخلات.",
};
}
// 3. نفذ العملية
const post = await db.posts.create({
data: {
...validatedFields.data,
userId: session.user.id,
},
});
// 4. ألغِ التخزين المؤقت
revalidatePath("/posts");
// 5. وجه للمقالة الجديدة
redirect(`/posts/${post.id}`);
}
export async function updatePost(id: string, formData: FormData) {
"use server";
const session = await getSession();
if (!session?.user) {
throw new Error("غير مصرح");
}
// تحقق من الملكية
const post = await db.posts.findUnique({ where: { id } });
if (post.userId !== session.user.id) {
throw new Error("ما عندك صلاحية");
}
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
await db.posts.update({
where: { id },
data: validatedFields.data,
});
revalidatePath(`/posts/${id}`);
revalidatePath("/posts");
}
export async function deletePost(id: string) {
"use server";
const session = await getSession();
if (!session?.user) {
throw new Error("غير مصرح");
}
const post = await db.posts.findUnique({ where: { id } });
if (post.userId !== session.user.id) {
throw new Error("ما عندك صلاحية");
}
await db.posts.delete({ where: { id } });
revalidatePath("/posts");
redirect("/posts");
}
الطريقة الثانية: Inline في Server Components
تقدر تعرف Server Actions جوا Server Components:
// app/posts/new/page.tsx
export default function NewPostPage() {
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title') as string
await db.posts.create({ data: { title } })
revalidatePath('/posts')
}
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">إنشاء</button>
</form>
)
}
Pro Tip!
مهم: متقدرش تعرف Server Actions جوا Client Components. لازم تستوردها من ملف منفصل فيه 'use server'.
استدعاء Server Actions
1. الفورمات (موصى به)
الطريقة الأكثر شيوعاً والموصى بها:
// app/ui/create-post-form.tsx
import { createPost } from '@/app/actions/posts'
export function CreatePostForm() {
return (
<form action={createPost}>
<input type="text" name="title" placeholder="العنوان" required />
<textarea name="content" placeholder="المحتوى" required />
<button type="submit">إنشاء مقالة</button>
</form>
)
}
Server Action بتستلم FormData تلقائياً:
export async function createPost(formData: FormData) {
const title = formData.get("title"); // استخراج البيانات
const content = formData.get("content");
// معالجة البيانات...
}
2. Event Handlers في Client Components
تقدر تنادي Server Actions من event handlers:
// app/ui/like-button.tsx
'use client'
import { incrementLike } from '@/app/actions/posts'
import { useState } from 'react'
export default function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes)
return (
<button
onClick={async () => {
const newLikes = await incrementLike(postId)
setLikes(newLikes)
}}
>
إعجاب ({likes})
</button>
)
}
3. تمرير كـ Props
تقدر تمرر Server Action كـ prop لـ Client Component:
// app/page.tsx (Server Component)
import { updateItem } from '@/app/actions'
import ClientForm from './client-form'
export default function Page() {
return <ClientForm updateAction={updateItem} />
}
// app/client-form.tsx
'use client'
export default function ClientForm({ updateAction }) {
return (
<form action={updateAction}>
<input name="item" />
<button type="submit">تحديث</button>
</form>
)
}
استخدام bind لتمرير معاملات إضافية
لما تحتاج تمرر معاملات إضافية زي IDs، استخدم .bind():
// app/posts/[id]/edit/page.tsx
import { updatePost } from '@/app/actions/posts'
import EditForm from './edit-form'
export default async function EditPage({ params }) {
const { id } = await params
const post = await getPost(id)
return <EditForm post={post} />
}
// app/posts/[id]/edit/edit-form.tsx
'use client'
import { useActionState } from 'react'
import { updatePost } from '@/app/actions/posts'
export default function EditForm({ post }) {
// استخدم bind عشان تمرر الـ ID
const updatePostWithId = updatePost.bind(null, post.id)
const [state, formAction] = useActionState(updatePostWithId, null)
return (
<form action={formAction}>
<input
name="title"
defaultValue={post.title}
required
/>
<textarea
name="content"
defaultValue={post.content}
required
/>
{state?.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
<button type="submit">تحديث</button>
</form>
)
}
Server Action بتستلم الـ ID كأول معامل:
// app/actions/posts.ts
"use server";
export async function updatePost(
id: string, // من bind
formData: FormData, // من الفورم
) {
// استخدم الـ ID والبيانات...
}
عرض حالة الانتظار
استخدم useActionState عشان تعرض مؤشر تحميل:
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions/posts'
export function CreateButton() {
const [state, action, pending] = useActionState(createPost, null)
return (
<button onClick={() => action()} disabled={pending}>
{pending ? 'جاري الإنشاء...' : 'إنشاء مقالة'}
</button>
)
}
أو استخدم useFormStatus لزر منفصل:
// app/ui/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'جاري الإرسال...' : 'إرسال'}
</button>
)
}
التحقق من البيانات مع Zod
استخدم Zod للتحقق من بيانات الفورم على السيرفر:
// app/lib/validations.ts
import { z } from "zod";
export const PostSchema = z.object({
title: z
.string()
.min(3, { message: "العنوان لازم يكون 3 أحرف على الأقل" })
.max(100, { message: "العنوان ما يزيد عن 100 حرف" })
.trim(),
content: z
.string()
.min(10, { message: "المحتوى لازم يكون 10 أحرف على الأقل" })
.trim(),
published: z.boolean().default(false),
});
export type FormState = {
errors?: {
title?: string[];
content?: string[];
published?: string[];
};
message?: string;
success?: boolean;
};
// app/actions/posts.ts
"use server";
import { PostSchema, FormState } from "@/lib/validations";
export async function createPost(
state: FormState,
formData: FormData,
): Promise<FormState> {
// تحقق من البيانات
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "true",
});
// ارجع لو فشل التحقق
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: "فشل التحقق. راجع المدخلات.",
success: false,
};
}
// استخدم البيانات المتحقق منها
const { title, content, published } = validatedFields.data;
try {
await db.posts.create({
data: { title, content, published },
});
revalidatePath("/posts");
return { success: true, message: "تم إنشاء المقالة بنجاح" };
} catch (error) {
return {
success: false,
message: "خطأ في قاعدة البيانات",
};
}
}
Pro Tip!
ملاحظة: استخدم safeParse() بدل parse() في Server Actions. safeParse() بترجع نتيجة (object) بدل ما ترمي أخطاء (errors)..
إعادة التحقق من البيانات
بعد تحديث البيانات، لازم تحدث الـ cache:
revalidatePath - إلغاء صفحات محددة
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
await db.posts.create({
data: {
/* ... */
},
});
// ألغِ صفحة القائمة
revalidatePath("/posts");
// ألغِ كل الصفحات المطابقة
revalidatePath("/blog/[slug]", "page");
}
revalidateTag - إلغاء بواسطة tag
"use server";
import { revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
await db.posts.create({
data: {
/* ... */
},
});
// ألغِ كل البيانات المعلمة بـ 'posts'
revalidateTag("posts", "max");
}
علّم بياناتك:
// جلب مع cache tag
const posts = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
refresh - تحديث الصفحة الحالية
"use server";
import { refresh } from "next/cache";
export async function updatePost(formData: FormData) {
await db.posts.update({
/* ... */
});
// حدث الصفحة الحالية
refresh();
}
التوجيه بعد التحديث
وجه المستخدم لصفحة مختلفة بعد التحديث:
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: {
/* ... */
},
});
// ألغِ قبل التوجيه
revalidatePath("/posts");
// وجه للمقالة الجديدة
redirect(`/posts/${post.id}`);
}
Pro Tip!
مهم: redirect() بترمي exception. نادي revalidatePath() أو revalidateTag() قبل redirect().
العمل مع Cookies
Server Actions تقدر تقرأ وتكتب وتحذف cookies:
"use server";
import { cookies } from "next/headers";
export async function updatePreferences(formData: FormData) {
const cookieStore = await cookies();
// اقرأ cookie
const theme = cookieStore.get("theme")?.value;
// اكتب cookie
cookieStore.set("theme", "dark");
// احذف cookie
cookieStore.delete("old-preference");
}
لما تكتب أو تحذف cookie في Server Action، Next.js بيعيد عرض الصفحة تلقائياً عشان تعكس القيمة الجديدة.
أفضل ممارسات الأمان
تحقق دائماً من الصلاحيات
"use server";
export async function deletePost(id: string) {
// 1. تحقق من المصادقة
const session = await getSession();
if (!session?.user) {
throw new Error("غير مصرح");
}
// 2. تحقق من الملكية
const post = await db.posts.findUnique({ where: { id } });
if (post.userId !== session.user.id) {
throw new Error("ما عندك صلاحية");
}
// 3. نفذ العملية
await db.posts.delete({ where: { id } });
}
Pro Tip!
حرج: Server Actions قابلة للوصول عبر POST requests مباشرة. تحقق دائماً من المصادقة والصلاحيات جوا كل Server Action.
تحقق من المدخلات
تحقق دائماً من مدخلات المستخدم:
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string().email({ message: "بريد إلكتروني غير صالح" }),
age: z.number().min(18).max(120),
});
export async function updateProfile(formData: FormData) {
const result = schema.safeParse({
email: formData.get("email"),
age: Number(formData.get("age")),
});
if (!result.success) {
return { error: "مدخلات غير صالحة" };
}
// آمن للاستخدام
const { email, age } = result.data;
}
متى تستخدم Server Actions مقابل Route Handlers
استخدم Server Actions لما | استخدم Route Handlers لما |
|---|---|
التعامل مع إرسال النماذج | بناء REST APIs للعملاء الخارجيين |
تنفيذ mutations من الـ UI | معالجة webhooks من أطراف ثالثة |
تحتاج progressive enhancement | تحتاج GET endpoints مع HTTP caching |
تريد type safety من طرف لطرف | تحتاج OpenAPI/Swagger documentation |
مثال كامل: نظام CRUD للمقالات
هنا مثال كامل بيجمع كل حاجة:
// lib/validations.ts
import { z } from "zod";
export const PostSchema = z.object({
title: z.string().min(3).max(100).trim(),
content: z.string().min(10).trim(),
published: z.boolean().default(false),
});
export type FormState = {
errors?: {
title?: string[];
content?: string[];
};
message?: string;
success?: boolean;
};
// app/actions/posts.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { PostSchema, FormState } from "@/lib/validations";
// CREATE
export async function createPost(
state: FormState,
formData: FormData,
): Promise<FormState> {
const session = await getSession();
if (!session?.user) {
return { message: "غير مصرح", success: false };
}
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "true",
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: "فشل التحقق",
success: false,
};
}
try {
const post = await db.posts.create({
data: {
...validatedFields.data,
userId: session.user.id,
},
});
revalidatePath("/posts");
redirect(`/posts/${post.id}`);
} catch (error) {
return { message: "خطأ في قاعدة البيانات", success: false };
}
}
// UPDATE
export async function updatePost(
id: string,
state: FormState,
formData: FormData,
): Promise<FormState> {
const session = await getSession();
if (!session?.user) {
return { message: "غير مصرح", success: false };
}
const post = await db.posts.findUnique({ where: { id } });
if (!post || post.userId !== session.user.id) {
return { message: "ما عندك صلاحية", success: false };
}
const validatedFields = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "true",
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
success: false,
};
}
try {
await db.posts.update({
where: { id },
data: validatedFields.data,
});
revalidatePath(`/posts/${id}`);
revalidatePath("/posts");
return { message: "تم التحديث بنجاح", success: true };
} catch (error) {
return { message: "خطأ في قاعدة البيانات", success: false };
}
}
// DELETE
export async function deletePost(id: string) {
const session = await getSession();
if (!session?.user) {
throw new Error("غير مصرح");
}
const post = await db.posts.findUnique({ where: { id } });
if (!post || post.userId !== session.user.id) {
throw new Error("ما عندك صلاحية");
}
await db.posts.delete({ where: { id } });
revalidatePath("/posts");
redirect("/posts");
}
// app/posts/new/page.tsx
import { CreatePostForm } from '@/app/ui/create-post-form'
export default function NewPostPage() {
return (
<div>
<h1>إنشاء مقالة جديدة</h1>
<CreatePostForm />
</div>
)
}
// app/ui/create-post-form.tsx
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions/posts'
import { SubmitButton } from './submit-button'
export function CreatePostForm() {
const [state, formAction] = useActionState(createPost, null)
return (
<form action={formAction}>
<div>
<label htmlFor="title">العنوان</label>
<input
type="text"
id="title"
name="title"
required
/>
{state?.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">المحتوى</label>
<textarea
id="content"
name="content"
rows={10}
required
/>
{state?.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
</div>
<div>
<label>
<input type="checkbox" name="published" value="true" />
نشر فوراً
</label>
</div>
{state?.message && !state.success && (
<p className="error">{state.message}</p>
)}
<SubmitButton />
</form>
)
}
// app/ui/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'جاري المعالجة...' : 'إرسال'}
</button>
)
}
الخلاصة
Server Actions في Next.js 16 بتوفر طريقة قوية وآمنة لمعالجة mutations وإرسال الفورمات. النقاط الرئيسية:
استخدم موجه
'use server'لإنشاء Server Actionsفضل الفورمات للـ progressive enhancement
تحقق دائماً من المدخلات مع
safeParse()من Zodاستخدم
.bind()لتمرير معاملات إضافية زي IDsتحقق دائماً من المصادقة والصلاحيات
استخدم
revalidatePath()أوrevalidateTag()لتحديث البيانات المخزنةعالج الأخطاء بشكل جيد ووفر feedback للمستخدم
اختر Server Actions للـ mutations الداخلية، Route Handlers للـ APIs الخارجية
Server Actions بتبسط mutations البيانات مع الحفاظ على الأمان والأداء وتجربة المستخدم.
المصادر:
ترميز سعيد! — Ahmed Fahmy