إذا كنت تبني تطبيقات بـ Next.js App Router، فأنت غالبًا سمعت عن Server Actions — تلك الدوال غير المتزامنة التي تعمل على الخادم ويمكنك استدعاؤها مباشرة من مكونات React. أول مرة جربتها، حسيت فعلًا إن فيه سحر يحصل خلف الكواليس. لا مسارات API، ولا طلبات fetch يدوية. بس هالسحر يخبّئ وراه تفاصيل أمنية وأنماط عملية مهمة — وهذا بالضبط اللي بنغطيه هنا.
في هذا الدليل، بنمشي مع بعض خطوة بخطوة: من تعريف Server Actions وربطها بالنماذج، إلى التحقق من البيانات بـ Zod، وإدارة حالة النماذج بخطاف useActionState، وصولًا للميزات الجديدة في Next.js 16 مثل updateTag وrefresh.
ما هي Server Actions ولماذا تحتاجها؟
Server Actions هي ببساطة دوال async تشتغل حصريًا على الخادم، لكن تقدر تستدعيها من مكونات الخادم أو العميل. لمّا تستخدمها، Next.js يُنشئ لك تلقائيًا نقطة نهاية HTTP بطريقة POST ويدير كل شي نيابة عنك.
الفكرة الأساسية؟ بسيطة جدًا.
بدلًا من إنشاء مسار API منفصل (route.ts) ثم كتابة طلب fetch من المكوّن، تكتب دالة واحدة وتستدعيها مباشرة. هذا يوفر عليك كمية كبيرة من الشيفرة المتكررة ويمنحك أمان أنواع TypeScript من البداية للنهاية — وهذي نقطة ما ينتبه لها كثير من المطورين بصراحة.
إليك أبرز ما تقدمه Server Actions:
- إلغاء الحاجة لمسارات API للعمليات الداخلية: لا حاجة لإنشاء ملفات
route.tsلكل عملية تعديل بيانات. - أمان الأنواع الشامل: تحصل على أمان TypeScript كامل بين المكوّن والخادم.
- التحسين التدريجي (Progressive Enhancement): النماذج تشتغل حتى لو ما اتحمّل JavaScript بعد.
- حماية مدمجة من CSRF: يستخدم Next.js طريقة POST حصريًا ويتحقق من ترويسة Origin تلقائيًا.
- تكامل عميق مع التخزين المؤقت: تقدر تُبطل ذاكرة التخزين المؤقت مباشرة بعد التعديل.
تعريف Server Actions: توجيه use server
لتعريف Server Action، تستخدم توجيه 'use server'. عندك طريقتين:
الطريقة الأولى: ملف مخصص لجميع الإجراءات
هذي هي الطريقة المُوصى بها في المشاريع الحقيقية، وصراحة هي اللي أستخدمها دائمًا. تضع التوجيه في أعلى ملف منفصل، وكل الدوال المُصدَّرة تصير تلقائيًا إجراءات خادم:
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.post.create({
data: { title, content },
})
revalidatePath('/posts')
}
export async function deletePost(postId: string) {
await db.post.delete({
where: { id: postId },
})
revalidatePath('/posts')
}
الطريقة الثانية: دالة مضمنة داخل مكوّن خادم
تقدر تعرّف Server Action مباشرة داخل مكوّن خادم. هذا مفيد للإجراءات البسيطة المرتبطة بصفحة واحدة (مع إني شخصيًا أفضل الطريقة الأولى للوضوح):
// app/posts/page.tsx
import { db } from '@/lib/db'
export default async function PostsPage() {
const posts = await db.post.findMany()
async function deletePost(formData: FormData) {
'use server'
const postId = formData.get('postId') as string
await db.post.delete({ where: { id: postId } })
}
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title}
<form action={deletePost}>
<input type="hidden" name="postId" value={post.id} />
<button type="submit">Delete</button>
</form>
</li>
))}
</ul>
)
}
ربط Server Actions بالنماذج (Forms)
هنا يبدأ الحماس الحقيقي. React يوسّع عنصر HTML <form> للسماح باستدعاء Server Actions عبر خاصية action. لمّا يُرسَل النموذج، الدالة تتلقى كائن FormData تلقائيًا يحتوي على جميع بيانات النموذج.
// app/contact/page.tsx
import { submitContactForm } from '@/app/actions'
export default function ContactPage() {
return (
<form action={submitContactForm}>
<label htmlFor="name">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">Message</label>
<textarea id="message" name="message" required />
<button type="submit">Send</button>
</form>
)
}
// app/actions.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
// Save to database or send email
await db.contact.create({
data: { name, email, message },
})
return { success: true }
}
الميزة الكبرى هنا هي التحسين التدريجي: هالنموذج بيشتغل ويرسل البيانات حتى لو كان JavaScript معطّل بالمتصفح أو ما اتحمّل بعد. وهذي نقطة مهمة جدًا لتجربة المستخدم وإمكانية الوصول — خصوصًا لو كنت تستهدف مستخدمين على شبكات بطيئة.
إدارة حالة النماذج مع useActionState
خطاف useActionState من React 19 هو الطريقة المُوصى بها حاليًا لإدارة حالة النماذج. يعطيك حالة النموذج الحالية (نتيجة الإرسال أو رسائل الخطأ)، ودالة لتشغيل الإجراء، وحالة الانتظار (pending).
ملاحظة مهمة: لو كنت قادم من React 18، فـ useActionState يحل محل useFormState القديم اللي كان في حزمة react-dom. الحين صار في حزمة react مباشرة — تغيير بسيط بس مهم تنتبه له.
// app/actions.ts
'use server'
import { z } from 'zod'
const signupSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
export type SignupState = {
errors?: {
name?: string[]
email?: string[]
password?: string[]
}
message?: string
success?: boolean
}
export async function signupAction(
prevState: SignupState,
formData: FormData
): Promise<SignupState> {
const validatedFields = signupSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Please fix the errors below.',
}
}
try {
// Create user in database
await db.user.create({
data: validatedFields.data,
})
return { success: true, message: 'Account created successfully!' }
} catch (error) {
return { message: 'Something went wrong. Please try again.' }
}
}
// components/SignupForm.tsx
'use client'
import { useActionState } from 'react'
import { signupAction, type SignupState } from '@/app/actions'
const initialState: SignupState = {}
export default function SignupForm() {
const [state, formAction, pending] = useActionState(
signupAction,
initialState
)
return (
<form action={formAction}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" />
{state.errors?.name && (
<p className="error">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
{state.errors?.email && (
<p className="error">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" />
{state.errors?.password && (
<p className="error">{state.errors.password[0]}</p>
)}
</div>
<button type="submit" disabled={pending}>
{pending ? 'Creating account...' : 'Sign up'}
</button>
{state.message && (
<p aria-live="polite" className={state.success ? 'success' : 'error'}>
{state.message}
</p>
)}
</form>
)
}
لاحظ كيف إن توقيع دالة Server Action يتغير لمّا تستخدمها مع useActionState — تتلقى معلمة prevState كأول وسيط قبل formData. هالشي يسمح لك بتتبع الحالة السابقة وبناء تجارب مستخدم أكثر تفاعلية. وهذي من النقاط اللي تنسى بسهولة لمّا تبدأ.
التحقق من البيانات باستخدام Zod
خلني أكون صريح معك: أنواع TypeScript ما لها وجود في وقت التشغيل. يعني إن تعريف userId: string ما يمنع أحد من إرسال كائن JSON خبيث بدل نص عادي.
لذلك، التحقق من البيانات على الخادم باستخدام مكتبة مثل Zod مو اختياري — هو ضرورة أمنية.
مشاركة مخططات Zod بين العميل والخادم
من أقوى الأنماط اللي تقدر تستخدمها مع Zod هو تعريف المخطط في ملف منفصل واستخدامه على كلا الجانبين. هذا يضمن إن قواعد التحقق دائمًا متطابقة:
// lib/validations/post.ts
import { z } from 'zod'
export const createPostSchema = z.object({
title: z
.string()
.trim()
.min(1, 'Title is required')
.max(100, 'Title must be under 100 characters'),
content: z
.string()
.trim()
.min(10, 'Content must be at least 10 characters'),
categoryId: z
.string()
.uuid('Invalid category ID'),
})
export type CreatePostInput = z.infer<typeof createPostSchema>
// app/actions.ts
'use server'
import { createPostSchema } from '@/lib/validations/post'
import { db } from '@/lib/db'
import { revalidateTag } from 'next/cache'
export async function createPost(prevState: any, formData: FormData) {
const raw = Object.fromEntries(formData)
const result = createPostSchema.safeParse(raw)
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
}
}
await db.post.create({ data: result.data })
revalidateTag('posts', 'max')
return { success: true }
}
هالنمط يعطيك عدة مزايا: لا تكرار لقواعد التحقق، واتساق تام بين تجربة المستخدم على العميل (أخطاء فورية) والتحقق الأمني على الخادم. صراحة، من أفضل الممارسات اللي ممكن تتبعها.
إبطال ذاكرة التخزين المؤقت بعد التعديلات
بعد ما تنفّذ عملية تعديل عبر Server Action، غالبًا بتحتاج تحدّث البيانات المعروضة للمستخدم. Next.js يوفر لك عدة أدوات لهالشي:
revalidatePath: إعادة التحقق حسب المسار
تُبطل جميع البيانات المخزنة مؤقتًا للمسار المحدد. أبسط طريقة وأكثرها مباشرة:
'use server'
import { revalidatePath } from 'next/cache'
export async function updateProfile(formData: FormData) {
await db.user.update({ /* ... */ })
revalidatePath('/profile')
}
revalidateTag: إعادة التحقق حسب العلامة
تُبطل البيانات المرتبطة بعلامة تخزين محددة. في Next.js 16، تستخدم سلوك stale-while-revalidate — يعني تعرض المحتوى القديم فورًا بينما تحدّث في الخلفية:
'use server'
import { revalidateTag } from 'next/cache'
export async function publishPost(postId: string) {
await db.post.update({
where: { id: postId },
data: { status: 'published' },
})
// Uses stale-while-revalidate with 'max' cache profile
revalidateTag('posts', 'max')
}
updateTag: الميزة الجديدة في Next.js 16
هنا الموضوع يصير ممتع. updateTag هي واجهة برمجية جديدة خاصة بـ Server Actions فقط. الفرق الجوهري عن revalidateTag؟ إنها تُنهي صلاحية البيانات المخزنة وتُحدّثها فورًا في نفس الطلب — مما يمنحك دلالات "اقرأ ما كتبته" (read-your-writes).
هذا مثالي للنماذج وإعدادات المستخدم حيث المستخدم يتوقع يشوف تغييراته فورًا:
'use server'
import { updateTag } from 'next/cache'
export async function updateUserSettings(formData: FormData) {
const userId = formData.get('userId') as string
const theme = formData.get('theme') as string
await db.user.update({
where: { id: userId },
data: { theme },
})
// Immediately expire and refresh - user sees their change instantly
updateTag(`user-${userId}`)
}
refresh: تحديث البيانات غير المخزنة مؤقتًا
refresh واجهة برمجية جديدة ثانية في Next.js 16 خاصة بـ Server Actions. تحدّث البيانات الحية (مثل عدادات الإشعارات أو مؤشرات الحالة) بدون ما تمس ذاكرة التخزين المؤقت:
'use server'
import { refresh } from 'next/cache'
export async function markNotificationRead(notificationId: string) {
await db.notification.update({
where: { id: notificationId },
data: { read: true },
})
// Refresh live data without touching the cache
refresh()
}
أمان Server Actions: الجزء اللي ما تقدر تتجاهله
هذا هو الجزء الأهم في كل هالدليل. وخلني أقولها بوضوح: رغم إن Server Actions تبدو كدوال عادية، إلا إنها في الحقيقة نقاط نهاية HTTP عامة.
أي شخص يقدر يرسل طلب POST لها — مو بس مكوّناتك. عاملها تمامًا مثل أي واجهة API عامة.
القاعدة الأولى: تحقق من المصادقة في كل إجراء
لا تفترض أبدًا إن المستخدم مُصادَق عليه لمجرد إنه وصل للصفحة. الإجراء نقطة نهاية منفصلة ولازم تتحقق منه بشكل مستقل:
'use server'
import { auth } from '@/lib/auth'
import { z } from 'zod'
export async function deletePost(postId: string) {
// Always verify authentication
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// Verify authorization - can this user delete this post?
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.authorId !== session.user.id) {
throw new Error('Forbidden')
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}
القاعدة الثانية: تحقق من البيانات دائمًا في وقت التشغيل
مثل ما ذكرنا، أنواع TypeScript تختفي في وقت التشغيل. استخدم Zod أو مكتبة مماثلة للتحقق من كل مُدخل:
'use server'
const deleteSchema = z.object({
postId: z.string().uuid(),
})
export async function deletePost(formData: FormData) {
// Runtime validation - blocks injection attacks
const result = deleteSchema.safeParse({
postId: formData.get('postId'),
})
if (!result.success) {
return { error: 'Invalid input' }
}
// Now safe to use result.data.postId
}
القاعدة الثالثة: لا تمرر أسرارًا عبر الإغلاقات
هالنقطة تفوت على كثير من المطورين. لمّا تعرّف Server Action داخل مكوّن، تقدر تلتقط متغيرات من النطاق المحيط. Next.js يسلسل هالمتغيرات ويشفّرها ويرسلها للعميل. رغم التشفير، هذا يزيد سطح الهجوم:
// BAD: Secret captured in closure
export default function Page() {
const apiKey = process.env.STRIPE_SECRET_KEY
async function processPayment(formData: FormData) {
'use server'
// apiKey is serialized and sent to client (encrypted)
await stripe(apiKey).charges.create({ /* ... */ })
}
return <form action={processPayment}>...</form>
}
// GOOD: Import secret inside the action file
// app/actions.ts
'use server'
export async function processPayment(formData: FormData) {
// Secret stays on the server - never serialized
const apiKey = process.env.STRIPE_SECRET_KEY
await stripe(apiKey).charges.create({ /* ... */ })
}
القاعدة الرابعة: أضف تحديد معدل الطلبات
للإجراءات الحساسة مثل تسجيل الدخول أو الدفع، لازم تضيف تحديد لمعدل الطلبات. بدونه، تطبيقك مكشوف لهجمات القوة الغاشمة:
'use server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { headers } from 'next/headers'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 requests per minute
})
export async function loginAction(formData: FormData) {
const headersList = await headers()
const ip = headersList.get('x-forwarded-for') ?? '127.0.0.1'
const { success } = await ratelimit.limit(ip)
if (!success) {
return { error: 'Too many requests. Please try again later.' }
}
// Proceed with authentication...
}
متى تستخدم Server Actions ومتى تستخدم مسارات API؟
هالسؤال يتكرر كثير، والجواب أبسط مما تتوقع لمّا تفهم الفرق.
استخدم Server Actions عندما:
- تحتاج لمعالجة إرسال نماذج من مكونات React
- تنفّذ عمليات تعديل بيانات (إنشاء، تحديث، حذف) من داخل تطبيقك
- تبي أمان أنواع TypeScript شامل بين العميل والخادم
- تبي حماية CSRF مدمجة بدون تكوين إضافي
- تبي النموذج يشتغل حتى بدون JavaScript
استخدم مسارات API (Route Handlers) عندما:
- تستقبل طلبات من خدمات خارجية (مثل Webhooks من Stripe أو GitHub)
- تحتاج واجهة API عامة لتطبيقات الجوال أو أطراف ثالثة
- تحتاج تتحكم بالكامل في طرق HTTP (GET، PUT، DELETE وغيرها)
- تحتاج تخزّن استجابات GET مؤقتًا
نصيحة عملية: تقدر تستخدم الاثنين معًا في نفس المشروع — وهذا اللي أنصح فيه. الممارسة الأفضل هي وضع المنطق الأساسي في طبقة وصول بيانات (Data Access Layer) مشتركة، ثم استدعاؤها من Server Actions ومسارات API:
// lib/dal/posts.ts - Data Access Layer
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
export async function createPostDAL(data: { title: string; content: string }) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
return db.post.create({
data: { ...data, authorId: session.user.id },
})
}
// app/actions.ts - Server Action uses DAL
'use server'
import { createPostDAL } from '@/lib/dal/posts'
export async function createPostAction(formData: FormData) {
return createPostDAL({
title: formData.get('title') as string,
content: formData.get('content') as string,
})
}
// app/api/posts/route.ts - Route Handler uses same DAL
import { createPostDAL } from '@/lib/dal/posts'
export async function POST(request: Request) {
const data = await request.json()
const post = await createPostDAL(data)
return Response.json(post, { status: 201 })
}
حالة زر الإرسال مع useFormStatus
لتحسين تجربة المستخدم، استخدم خطاف useFormStatus لعرض حالة تحميل أثناء إرسال النموذج. نقطة مهمة: هالخطاف لازم يُستخدم داخل مكوّن فرعي للنموذج — مو في المكوّن الأب مباشرة:
// components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'
export default function SubmitButton({ label = 'Submit' }: { label?: string }) {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Processing...' : label}
</button>
)
}
الحين تقدر تستخدم هالمكوّن في أي نموذج:
import { submitForm } from '@/app/actions'
import SubmitButton from '@/components/SubmitButton'
export default function MyForm() {
return (
<form action={submitForm}>
<input name="email" type="email" required />
<SubmitButton label="Send" />
</form>
)
}
طيب وش الفرق بين useActionState و useFormStatus؟ باختصار: useActionState لإدارة حالة النموذج الكاملة (النتيجة، الأخطاء، الانتظار). useFormStatus بس لمؤشر التحميل في زر الإرسال. وتقدر تستخدمهم مع بعض في نفس النموذج بدون مشاكل.
استدعاء Server Actions من معالجات الأحداث
Server Actions مو محصورة بالنماذج فقط — وهذي نقطة يغفل عنها بعض المطورين. تقدر تستدعيها من أي معالج حدث في مكوّن عميل:
'use client'
import { useState, useTransition } from 'react'
import { toggleLike } from '@/app/actions'
export default function LikeButton({ postId, initialLiked }: {
postId: string
initialLiked: boolean
}) {
const [liked, setLiked] = useState(initialLiked)
const [isPending, startTransition] = useTransition()
const handleClick = () => {
// Optimistic update
setLiked(!liked)
startTransition(async () => {
const result = await toggleLike(postId)
if (!result.success) {
// Revert on failure
setLiked(liked)
}
})
}
return (
<button onClick={handleClick} disabled={isPending}>
{liked ? '♥ Liked' : '♡ Like'}
</button>
)
}
استخدام useTransition هنا يعطيك حالة isPending ويسمح بالتحديثات المتفائلة (Optimistic Updates) — تحدّث الواجهة فورًا ثم تتراجع إذا فشل الإجراء. تجربة المستخدم تصير أسرع بشكل ملحوظ.
الأسئلة الشائعة
هل Server Actions آمنة للاستخدام في بيئة الإنتاج؟
نعم، مستقرة وجاهزة للإنتاج من Next.js 14 وتم تحسينها بشكل كبير في Next.js 16 مع React 19.2. بس لازم تعاملها كنقاط نهاية HTTP عامة — يعني تتحقق من المصادقة والصلاحيات والبيانات في كل إجراء بدون استثناء.
هل يمكنني استخدام Server Actions لجلب البيانات بدلًا من التعديل؟
تقنيًا تقدر، بس ما يُنصح بذلك. Server Actions تستخدم طريقة POST وما تقدر تخزّنها مؤقتًا. لجلب البيانات، الأفضل تستخدم مكونات الخادم (Server Components) أو مسارات API مع طريقة GET اللي تدعم التخزين المؤقت.
ما الفرق بين updateTag و revalidateTag في Next.js 16؟
revalidateTag تستخدم نمط stale-while-revalidate — تعرض المحتوى القديم فورًا وتحدّث بالخلفية. updateTag تنهي صلاحية البيانات وتحدّثها فورًا بنفس الطلب، فالمستخدم يشوف تغييراته على طول. القاعدة البسيطة: updateTag للنماذج والإعدادات، وrevalidateTag للمحتوى العام مثل المقالات وقوائم المنتجات.
كيف أتعامل مع رفع الملفات عبر Server Actions؟
تقدر تستقبل الملفات عبر FormData في Server Action مثل أي بيانات نموذج عادية. الملف يوصل ككائن File وتقدر تقرأه وتحفظه على الخادم. بس انتبه: الحد الافتراضي لحجم الطلب في Next.js هو 1 ميغابايت — تقدر تغيّره في next.config.js عبر خيار serverActions.bodySizeLimit.
هل تعمل Server Actions مع التحسين التدريجي (Progressive Enhancement)؟
نعم، لمّا تستخدم Server Actions مع عنصر <form> وخاصية action في مكوّن خادم، النموذج يشتغل حتى لو JavaScript معطّل بالمتصفح. طبعًا هالشي ما ينطبق على الاستدعاءات من معالجات الأحداث (onClick وغيرها) لأنها تعتمد على JavaScript بطبيعتها.