إجراءات الخادم في Next.js
إجراءات الخادم هي ميزة قوية في Next.js تمكنك من تشغيل كود جانب الخادم مباشرة من مكوناتك. توفر طريقة سلسة للتعامل مع إرسال النماذج والتحديثات وتحديثات البيانات دون إنشاء مسارات API.
ما هي إجراءات الخادم؟ إجراءات الخادم هي دوال غير متزامنة يتم تنفيذها على الخادم. يمكن استدعاؤها من مكونات الخادم أو مكونات العميل، مما يمكّن التحسين التدريجي وتجارب المستخدم الأفضل.
إنشاء أول إجراء خادم لك
يتم تعريف إجراءات الخادم باستخدام توجيه 'use server' في أعلى دالة async أو ملف:
إجراء خادم مضمن
// app/posts/create/page.tsx
export default function CreatePostPage() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// عملية قاعدة البيانات تعمل على الخادم
await db.post.create({
data: { title, content }
});
// إعادة التوجيه بعد الإنشاء الناجح
redirect('/posts');
}
return (
<form action={createPost}>
<input type="text" name="title" required />
<textarea name="content" required></textarea>
<button type="submit">إنشاء منشور</button>
</form>
);
}
التحسين التدريجي: النماذج مع إجراءات الخادم تعمل حتى عندما يكون JavaScript معطلاً. يتعامل المتصفح مع إرسال النموذج بشكل أصلي، ويعالجه Next.js على الخادم.
ملفات إجراءات خادم منفصلة
للتنظيم الأفضل، أنشئ ملفات مخصصة لإجراءات الخادم الخاصة بك:
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// التحقق من صحة الإدخال
if (!title || title.length < 3) {
throw new Error('يجب أن يكون العنوان 3 أحرف على الأقل');
}
// إنشاء منشور في قاعدة البيانات
const post = await db.post.create({
data: {
title,
content,
published: false
}
});
// إعادة التحقق من قائمة المنشورات
revalidatePath('/posts');
// إعادة التوجيه إلى المنشور الجديد
redirect(`/posts/${post.id}`);
}
export async function deletePost(postId: string) {
await db.post.delete({
where: { id: postId }
});
revalidatePath('/posts');
}
export async function updatePost(postId: string, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.post.update({
where: { id: postId },
data: { title, content }
});
revalidatePath(`/posts/${postId}`);
revalidatePath('/posts');
}
استخدام إجراءات الخادم من المكونات:
// app/posts/create/page.tsx
import { createPost } from '@/app/actions/posts';
export default function CreatePostPage() {
return (
<form action={createPost}>
<input type="text" name="title" placeholder="عنوان المنشور" required />
<textarea name="content" placeholder="محتوى المنشور" required></textarea>
<button type="submit">إنشاء منشور</button>
</form>
);
}
إجراءات الخادم في مكونات العميل
يمكنك استخدام إجراءات الخادم من مكونات العميل للنماذج التفاعلية مع التحديثات المتفائلة:
// app/components/PostForm.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions/posts';
// زر الإرسال الذي يظهر حالة التحميل
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'جاري الإنشاء...' : 'إنشاء منشور'}
</button>
);
}
export default function PostForm() {
const [state, formAction] = useFormState(createPost, { message: '' });
return (
<form action={formAction}>
<div>
<label htmlFor="title">العنوان</label>
<input type="text" id="title" name="title" required />
</div>
<div>
<label htmlFor="content">المحتوى</label>
<textarea id="content" name="content" required></textarea>
</div>
{state.message && (
<p className="error">{state.message}</p>
)}
<SubmitButton />
</form>
);
}
خطاف useFormState
يسمح لك خطاف useFormState بتحديث الحالة بناءً على نتيجة إجراء النموذج:
// app/actions/posts.ts
'use server';
export async function createPost(
prevState: { message: string },
formData: FormData
) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// التحقق من الصحة
if (!title || title.length < 3) {
return { message: 'يجب أن يكون العنوان 3 أحرف على الأقل' };
}
if (!content || content.length < 10) {
return { message: 'يجب أن يكون المحتوى 10 أحرف على الأقل' };
}
try {
await db.post.create({
data: { title, content }
});
revalidatePath('/posts');
redirect('/posts');
} catch (error) {
return { message: 'فشل إنشاء المنشور. حاول مرة أخرى.' };
}
}
// app/components/CreatePostForm.tsx
'use client';
import { useFormState } from 'react-dom';
import { createPost } from '@/app/actions/posts';
export default function CreatePostForm() {
const initialState = { message: '' };
const [state, formAction] = useFormState(createPost, initialState);
return (
<form action={formAction}>
<input type="text" name="title" />
<textarea name="content"></textarea>
{state.message && (
<div className="alert">{state.message}</div>
)}
<button type="submit">إنشاء</button>
</form>
);
}
مهم: عند استخدام useFormState، يجب أن يقبل إجراء الخادم الخاص بك معاملين: prevState (الحالة السابقة) و formData (بيانات النموذج). يجب أن تعيد الدالة كائناً يمثل الحالة الجديدة.
خطاف useFormStatus
يوفر خطاف useFormStatus معلومات حول حالة إرسال النموذج:
// app/components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ label }: { label: string }) {
const { pending, data, method, action } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={pending ? 'loading' : ''}
>
{pending ? 'جاري الإرسال...' : label}
</button>
);
}
// الاستخدام في نموذج
// app/posts/create/page.tsx
import { createPost } from '@/app/actions/posts';
import { SubmitButton } from '@/app/components/SubmitButton';
export default function CreatePostPage() {
return (
<form action={createPost}>
<input type="text" name="title" />
<textarea name="content"></textarea>
<SubmitButton label="إنشاء منشور" />
</form>
);
}
قيد الخطاف: يجب استدعاء useFormStatus من مكون يتم عرضه داخل <form>. لن يعمل إذا تم استدعاؤه في نفس المكون الذي يعرض النموذج.
التعامل مع الإجراءات غير المستندة إلى النموذج
يمكن أيضاً تشغيل إجراءات الخادم برمجياً، وليس فقط من النماذج:
// app/actions/posts.ts
'use server';
export async function likePost(postId: string, userId: string) {
await db.like.create({
data: {
postId,
userId
}
});
revalidatePath(`/posts/${postId}`);
return { success: true };
}
// app/components/LikeButton.tsx
'use client';
import { useState, useTransition } from 'react';
import { likePost } from '@/app/actions/posts';
export default function LikeButton({
postId,
userId,
initialLikes
}: {
postId: string;
userId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, startTransition] = useTransition();
const handleLike = () => {
// تحديث متفائل
setLikes(likes + 1);
startTransition(async () => {
try {
await likePost(postId, userId);
} catch (error) {
// التراجع عن التحديث المتفائل عند الخطأ
setLikes(likes);
}
});
};
return (
<button onClick={handleLike} disabled={isPending}>
❤️ {likes} {isPending && '...'}
</button>
);
}
التحقق من الصحة ومعالجة الأخطاء
تحقق دائماً من صحة الإدخال وعالج الأخطاء بشكل صحيح في إجراءات الخادم:
// app/actions/posts.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
// تعريف مخطط التحقق
const postSchema = z.object({
title: z.string().min(3, 'يجب أن يكون العنوان 3 أحرف على الأقل'),
content: z.string().min(10, 'يجب أن يكون المحتوى 10 أحرف على الأقل'),
tags: z.array(z.string()).max(5, 'الحد الأقصى 5 وسوم مسموح')
});
export async function createPost(formData: FormData) {
// تحليل والتحقق من الصحة
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags')
};
const validationResult = postSchema.safeParse(rawData);
if (!validationResult.success) {
return {
success: false,
errors: validationResult.error.flatten().fieldErrors
};
}
const { title, content, tags } = validationResult.data;
try {
const post = await db.post.create({
data: {
title,
content,
tags: {
connectOrCreate: tags.map(tag => ({
where: { name: tag },
create: { name: tag }
}))
}
}
});
revalidatePath('/posts');
return {
success: true,
postId: post.id
};
} catch (error) {
console.error('فشل إنشاء المنشور:', error);
return {
success: false,
errors: { _form: ['فشل إنشاء المنشور'] }
};
}
}
المصادقة في إجراءات الخادم
تحقق من مصادقة المستخدم والتفويض في إجراءات الخادم:
// app/actions/posts.ts
'use server';
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export async function deletePost(postId: string) {
// التحقق من المصادقة
const session = await auth();
if (!session?.user) {
redirect('/login');
}
// التحقق من التفويض
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true }
});
if (post?.authorId !== session.user.id) {
throw new Error('غير مصرح: يمكنك فقط حذف منشوراتك الخاصة');
}
// تنفيذ الحذف
await db.post.delete({
where: { id: postId }
});
revalidatePath('/posts');
redirect('/posts');
}
الأمان: تحقق دائماً من أذونات المستخدم في إجراءات الخادم. لا تثق أبداً في عمليات التحقق من جانب العميل وحدها. يمكن استدعاء إجراءات الخادم مباشرة، متجاوزة واجهة المستخدم الخاصة بك.
إعادة التحقق مع إجراءات الخادم
حدث البيانات المخزنة مؤقتاً بعد التحديثات باستخدام revalidatePath() و revalidateTag():
// app/actions/posts.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function publishPost(postId: string) {
await db.post.update({
where: { id: postId },
data: { published: true, publishedAt: new Date() }
});
// إعادة التحقق من مسارات محددة
revalidatePath('/posts');
revalidatePath(`/posts/${postId}`);
revalidatePath('/'); // الصفحة الرئيسية إذا كانت تعرض المنشورات
// إعادة التحقق بواسطة العلامة (إذا وضعت علامة على طلبات fetch الخاصة بك)
revalidateTag('posts');
return { success: true };
}
ملفات تعريف الارتباط والعناوين
الوصول إلى ملفات تعريف الارتباط والعناوين في إجراءات الخادم:
// app/actions/preferences.ts
'use server';
import { cookies, headers } from 'next/headers';
export async function updateTheme(theme: string) {
// تعيين ملف تعريف ارتباط
cookies().set('theme', theme, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365, // سنة واحدة
path: '/'
});
return { success: true };
}
export async function trackAction() {
// قراءة العناوين
const headersList = headers();
const userAgent = headersList.get('user-agent');
const referer = headersList.get('referer');
// قراءة ملفات تعريف الارتباط
const sessionId = cookies().get('sessionId')?.value;
// تسجيل أو تتبع الإجراء
await db.analytics.create({
data: {
sessionId,
userAgent,
referer,
timestamp: new Date()
}
});
return { success: true };
}
تحميل الملفات مع إجراءات الخادم
التعامل مع تحميل الملفات باستخدام FormData:
// app/actions/upload.ts
'use server';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export async function uploadImage(formData: FormData) {
const file = formData.get('image') as File;
if (!file) {
return { success: false, error: 'لم يتم توفير ملف' };
}
// التحقق من نوع الملف
if (!file.type.startsWith('image/')) {
return { success: false, error: 'يجب أن يكون الملف صورة' };
}
// التحقق من حجم الملف (5 ميجابايت كحد أقصى)
if (file.size > 5 * 1024 * 1024) {
return { success: false, error: 'يجب أن يكون الملف أقل من 5 ميجابايت' };
}
try {
// قراءة الملف كمخزن مؤقت
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// إنشاء اسم ملف فريد
const filename = `${Date.now()}-${file.name}`;
const path = join(process.cwd(), 'public', 'uploads', filename);
// كتابة الملف على القرص
await writeFile(path, buffer);
// إرجاع عنوان URL العام
return {
success: true,
url: `/uploads/${filename}`
};
} catch (error) {
return { success: false, error: 'فشل تحميل الملف' };
}
}
// app/components/ImageUploadForm.tsx
'use client';
import { uploadImage } from '@/app/actions/upload';
import { useState } from 'react';
export default function ImageUploadForm() {
const [imageUrl, setImageUrl] = useState(null);
async function handleSubmit(formData: FormData) {
const result = await uploadImage(formData);
if (result.success) {
setImageUrl(result.url);
} else {
alert(result.error);
}
}
return (
<form action={handleSubmit}>
<input type="file" name="image" accept="image/*" required />
<button type="submit">تحميل</button>
{imageUrl && (
<img src={imageUrl} alt="تم التحميل" width={300} />
)}
</form>
);
}
تمرين عملي
المهمة: أنشئ تطبيق todo كامل باستخدام إجراءات الخادم:
- أنشئ نموذجاً لإضافة todos جديدة مع التحقق (3 أحرف كحد أدنى)
- نفذ إجراء خادم لتبديل حالة إكمال todo
- أضف إجراء خادم لحذف todo مع فحص المصادقة
- نفذ تحديثات واجهة مستخدم متفائلة لتبديل todos
- أضف حالات التحميل باستخدام useFormStatus
- اعرض أخطاء التحقق باستخدام useFormState
- نفذ إعادة التحقق الصحيحة بعد كل إجراء
المتطلبات:
- استخدم TypeScript مع الأنواع الصحيحة
- نفذ مخطط التحقق من Zod
- أضف معالجة الأخطاء لجميع الإجراءات
- استخدم التحسين التدريجي (يعمل بدون JS)
- نفذ فحوصات المصادقة
التحدي الإضافي: أضف إجراء خادم لحذف todos المكتملة بالجملة ونفذ مربع حوار تأكيد مع useFormState.
الملخص
- إجراءات الخادم هي دوال async تعمل على الخادم باستخدام 'use server'
- تمكّن التعامل مع النماذج دون إنشاء مسارات API
- استخدم useFormState لإدارة حالة النموذج وعرض أخطاء التحقق
- استخدم useFormStatus لإظهار حالات التحميل أثناء إرسال النموذج
- إجراءات الخادم تدعم التحسين التدريجي (تعمل بدون JavaScript)
- تحقق دائماً من صحة الإدخال وتحقق من المصادقة/التفويض
- استخدم revalidatePath() و revalidateTag() لتحديث البيانات المخزنة
- يمكن استدعاء إجراءات الخادم من النماذج أو برمجياً
- الوصول إلى ملفات تعريف الارتباط والعناوين باستخدام أدوات خادم Next.js
- التعامل مع تحميل الملفات باستخدام FormData في إجراءات الخادم