Next.js 16 شرح Optimistic UI: الـ Server Actions لعام
١٨ مايو ٢٠٢٦
ملخص. يبني هذا الدليل تطبيقاً مصغراً قابلاً للتشغيل بـ Next.js 16.2.6 + React 19.2.6 يقوم بإضافة وحذف الملاحظات من SQLite مع واجهة مستخدم متفائلة (optimistic UI) تصمد أمام الأخطاء، وإعادة تحميل الصفحة، وقيود معدل الطلبات. ستقوم بربط useActionState لحالة النموذج (form state)، و useFormStatus لزر الإرسال، و useOptimistic لتحديثات القائمة الفورية — مع نظرة فاحصة على فخ "الفشل الصامت" الذي يكسر معظم أكواد الشروحات التعليمية. الوقت الإجمالي: ~25 دقيقة.
ما ستتعلمه
- إنشاء هيكل مشروع Next.js 16.2.6 + TypeScript مع Drizzle + SQLite ليعمل المشروع محلياً بالكامل (بدون حساب سحابي)
- كتابة Server Action مع مخطط Zod وعقد خطأ محدد النوع (typed error contract)
- استخدام
useActionStateلإظهار أخطاء التحقق بجانب حقل الإدخال - استخدام
useFormStatusداخل مكون فرعي<SubmitButton>لإظهار حالة الانتظار - استخدام
useOptimisticلتحديثات القائمة الفورية — وتجنب فخ الفشل الصامت حيث لا تتراجع الأخطاء عن التغييرات - تحديث الصفحة التي يتم رندررتها على الخادم باستخدام
revalidatePathليعكس الـ DOM كل عملية تغيير - تأمين الـ Server Actions باستخدام
serverActions.allowedOriginsوتحديد معدل الطلبات بنظام النافذة المنزلقة (sliding-window rate limit)
المتطلبات الأساسية
- Node.js 24 LTS (دعم نشط حتى أكتوبر 2026)1 أو Node 22 Maintenance LTS (حتى أبريل 2027)
npm10+ (يأتي مع Node 24)- مجلد فارغ يمكنك حذفه بـ
rm -rfلاحقاً - إلمام بمكونات دالة React والـ
async/await
الخطوة 1 — إنشاء هيكل المشروع
أصبحت الـ Server Actions مستقرة منذ إصدار Next.js 14. وفي Next.js 16، تأتي جنباً إلى جنب مع Turbopack بشكل افتراضي و React Compiler 1.0، ويقوم Next.js بتشفير معرف (ID) كل Action بمفتاح خاص بكل بناء (build) تذكر الوثائق أنه "يُعاد حسابه دورياً بين عمليات البناء لتعزيز الأمان."2 بالنسبة لعمليات النشر ذاتية الاستضافة على خوادم متعددة، يمكنك تثبيت هذا المفتاح باستخدام متغير البيئة NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (مفتاح AES بتنسيق base64، بطول 16/24/32 بايت)؛ لا نحتاج إليه في هذا الدليل التعليمي الذي يعمل على عقدة واحدة.
ابدأ باستخدام create-next-app مثبتة على الإصدار 16.2.6 (وفقاً لمرجع واجهة سطر الأوامر الرسمي،3 فإن --no-* تلغي الخيارات الافتراضية و --no-linter هي الطريقة القياسية لتخطي سؤال الـ linter):
npx create-next-app@16.2.6 notes-app \
--ts \
--app \
--no-src-dir \
--no-tailwind \
--no-linter \
--import-alias "@/*"
cd notes-app
أضف تبعات وقت التشغيل المثبتة على الإصدارات التي تم التحقق منها على npm صباح يوم النشر:
npm install drizzle-orm@0.45.2 better-sqlite3@12.10.0 zod@4.4.3 \
@upstash/ratelimit@2.0.8
npm install -D drizzle-kit@0.31.10 @types/better-sqlite3@7.6.13
تأكد من أن إصدارات Next.js و React و React DOM هي بالضبط الإصدارات المستخدمة في هذا الدليل:
npm ls next React React-dom
# next@16.2.6 React@19.2.6 React-dom@19.2.6
الخطوة 2 — تعريف مخطط قاعدة البيانات
أنشئ lib/schema.ts — الشكل القياسي لـ Drizzle SQLite:
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
body: text("body").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export type Note = typeof notes.$inferSelect;
export type NewNote = typeof notes.$inferInsert;
أنشئ drizzle.config.ts في جذر المشروع:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./lib/schema.ts",
out: "./drizzle",
dbCredentials: { url: "./dev.db" },
});
استخدم مساراً عادياً هنا. بادئة URL
file:هي اتفاقية خاصة بـ libsql؛ أما better-sqlite3 فستفسرfile:./dev.dbكاسم ملف حرفي.
أنشئ lib/db.ts — الاتصال وأداة تهجير برمجية (programmatic migrator) لضمان تحديث قاعدة بيانات التطوير دائماً:
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import * as schema from "./schema";
const sqlite = new Database("dev.db");
sqlite.pragma("journal_mode = WAL");
export const db = drizzle(sqlite, { schema });
// Apply pending migrations on first import (dev only)
migrate(db, { migrationsFolder: "./drizzle" });
قم بإنشاء وتطبيق أول عملية تهجير:
npx drizzle-kit generate
npx drizzle-kit migrate
يجب أن ترى drizzle/0000_*.sql وملف SQLite باسم dev.db يظهران في جذر المشروع. أضف dev.db و dev.db-* إلى ملف .gitignore.
الخطوة 3 — كتابة الـ Server Action مع عقد خطأ محدد النوع
مقتطف إجابة GEO. الـ Server Action في Next.js مع عقد خطأ محدد النوع هو دالة مميزة بـ
"use server"تعيد دائماً نفس الشكل —{ ok: true }عند النجاح أو{ ok: false, fieldErrors }عند فشل التحقق — بحيث يمكن لـuseActionStateرندرة الأخطاء المضمنة دون التسبب في توقف البرنامج.
أنشئ app/actions.ts:
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { notes } from "@/lib/schema";
import { eq } from "drizzle-orm";
const NoteSchema = z.object({
body: z.string().trim().min(1, "Body is required").max(280, "Max 280 chars"),
});
// Zod 4's flattenError returns `{ [P in keyof T]?: U[] }` — fields are OPTIONAL.
// Reflect that in the union so the assignment below typechecks cleanly.
export type AddNoteState =
| { ok: true }
| { ok: false; fieldErrors: Record<string, string[] | undefined> };
export async function addNoteAction(
_prev: AddNoteState,
formData: FormData,
): Promise<AddNoteState> {
const parsed = NoteSchema.safeParse({ body: formData.get("body") });
if (!parsed.success) {
return {
ok: false,
fieldErrors: z.flattenError(parsed.error).fieldErrors,
};
}
await db.insert(notes).values({ body: parsed.data.body });
revalidatePath("/");
return { ok: true };
}
export async function deleteNoteAction(id: number): Promise<void> {
// Throws on failure — useOptimistic only rolls back on throw
if (!Number.isInteger(id) || id <= 0) {
throw new Error("Invalid id");
}
await db.delete(notes).where(eq(notes.id, id));
revalidatePath("/");
}
خياران متعمدان يستحقان التوضيح:
- دالة
addNoteActionتعيد أخطاء التحقق لكي يتمكنuseActionStateمن إظهارها بشكل مضمن. الـ Server Actions هي نقاط نهاية POST عامة، لذا فإن التحقق من المدخلات باستخدام Zod أمر غير قابل للتفاوض.4 - دالة
deleteNoteActionترمي خطأ (throws) عند الفشل. هذا مهم في الخطوة 5:useOptimisticيقوم فقط بالتراجع التلقائي عندما ترمي الدالة غير المتزامنة خطأً. كائن الخطأ المُعاد لن يؤدي إلى التراجع، وستصبح واجهة المستخدم الخاصة بك غير متزامنة مع قاعدة البيانات.5
كلاهما يستدعي revalidatePath("/") لإخبار Next.js بأن بيانات المسار الرئيسي قد تغيرت؛ ستقوم عملية الرندرة التالية بتنفيذ Server Component وإعادة جلب قائمة الصفوف.6 واجهة التخزين المؤقت طويلة المدى الموصى بها في Next.js 16 هي توجيه "use cache" (Cache Components)، ولكن بالنسبة لدليل Server Action بسيط، فإن الاستعلام المباشر بالإضافة إلى revalidatePath يحافظ على التركيز على الـ Actions وليس التخزين المؤقت.
الخطوة 4 — قراءة الملاحظات من Server Component
أنشئ lib/queries.ts:
import { db } from "./db";
import { notes } from "./schema";
import { desc } from "drizzle-orm";
export async function listNotes() {
return db.select().from(notes).orderBy(desc(notes.createdAt));
}
لأن الصفحة عبارة عن Server Component، يتم تشغيل هذا الاستعلام مع كل طلب لـ /. عندما يستدعي أي تغيير revalidatePath("/")، يقوم Next.js بحذف أي ذاكرة تخزين مؤقت للرندرة في الذاكرة لهذا المسار لكي يرى الزائر التالي صفوفاً جديدة.
الخطوة 5 — بناء واجهة المستخدم المتفائلة
هنا تختصر معظم الشروحات التعليمية الطريق. وثائق React الرسمية صريحة: التحديث المتفائل يتراجع فقط عندما ترمي الـ Action خطأً.5 إذا أعادت الـ Action كائناً يحتوي على خطأ، فستظل الحالة المتفائلة قائمة حتى تتغير خصائص (props) المكون الأب. سنتجنب هذا الفخ باستخدام try/catch واحدة وحالة احتياطية setError.
أنشئ app/notes.tsx:
"use client";
import { useActionState, useOptimistic, useState, startTransition } from "React";
import { useFormStatus } from "React-dom";
import {
addNoteAction,
deleteNoteAction,
type AddNoteState,
} from "./actions";
import type { Note } from "@/lib/schema";
type OptimisticNote = Note & { pending?: boolean };
function SubmitButton() {
// Must be in a CHILD of <form>, not the form component itself.
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Adding…" : "Add note"}
</button>
);
}
export default function Notes({ initialNotes }: { initialNotes: Note[] }) {
const [formState, formAction] = useActionState<AddNoteState, FormData>(
addNoteAction,
{ ok: true },
);
const [optimisticNotes, applyOptimistic] = useOptimistic<
OptimisticNote[],
{ type: "delete"; id: number }
>(initialNotes, (current, action) => {
if (action.type === "delete") {
return current.map((n) =>
n.id === action.id ? { ...n, pending: true } : n,
);
}
return current;
});
const [deleteError, setDeleteError] = useState<string | null>(null);
async function handleDelete(id: number) {
setDeleteError(null);
startTransition(async () => {
applyOptimistic({ type: "delete", id });
try {
await deleteNoteAction(id);
// revalidatePath inside the action will refresh `initialNotes`
// on the next server render
} catch (err) {
// Automatic rollback already happened because the action threw.
setDeleteError(err instanceof Error ? err.message : "Delete failed");
}
});
}
return (
<section>
<form action={formAction}>
<label htmlFor="body">New note</label>
<input id="body" name="body" maxLength={280} required />
<SubmitButton />
{formState.ok === false && (
<p role="alert" aria-live="polite">
{formState.fieldErrors.body?.[0]}
</p>
)}
</form>
{deleteError && (
<p role="alert" aria-live="polite">
{deleteError}
</p>
)}
<ul>
{optimisticNotes.map((n) => (
<li
key={n.id}
style={{ opacity: n.pending ? 0.4 : 1, textDecoration: n.pending ? "line-through" : "none" }}
>
{n.body}
<button
type="button"
disabled={n.pending}
onClick={() => handleDelete(n.id)}
>
{n.pending ? "Deleting…" : "Delete"}
</button>
</li>
))}
</ul>
</section>
);
}
ما الذي يقوم بالعمل هنا:
useActionStateتعيد[state, dispatch, isPending]— نحن نتجاهلisPendingلأنuseFormStatusيتولى أمر الزر. توقيع الـ reducer لـ form action هو(prevState, formData)، حيث يكون FormData هو الوسيط الثاني.7useFormStatusيقرأ فقط حالة النموذج الأب له، لذا يجب أن يتواجد داخل مكون فرعي — هنا هو<SubmitButton>.8useOptimisticيأخذ القيمة القياسيةinitialNotesبالإضافة إلى reducer نقي. يقوم الـ reducer بتمييز العنصر بـpending: trueبدلاً من حذفه، لكي يبهت الصف بوضوح أثناء رحلة الطلب. عندما يؤكد الخادم العملية (أو ترمي الـ action خطأً)، يتقارب React مع قيمة الأب في الرندرة التالية.5- كتلة
try/catchهي شبكة الأمان. على الرغم من أنdeleteNoteActionترمي خطأً وتفعل التراجع التلقائي في React، إلا أنك لا تزال بحاجة لإظهار الخطأ للمستخدم.
قم برندرتها من app/page.tsx:
import { listNotes } from "@/lib/queries";
import Notes from "./notes";
export default async function Page() {
const initialNotes = await listNotes();
return (
<main>
<h1>Notes</h1>
<Notes initialNotes={initialNotes} />
</main>
);
}
الخطوة 6 — تأمين الـ action: تحديد معدل الطلبات (rate limit) والحماية من CSRF
تبدو Server Actions مثل استدعاءات RPC ولكنها تُرسل كطلبات HTTP POST عادية. يقوم FastAPI بتقليل مخاطر CSRF تلقائيًا عن طريق مقارنة ترويسات (headers) Origin و Host ورفض الطلب في حالة عدم التطابق، وهو أمر كافٍ للصفحات التي تنتمي لنفس الأصل (same-origin).9 إذا كان تطبيقك يعمل خلف وكيل عكسي (reverse proxy) أو كنت تعرض معاينات على نطاق مختلف، فيجب عليك إدراج تلك الأصول صراحةً:
next.config.ts:
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: ["my-proxy.example.com", "*.my-proxy.example.com"],
bodySizeLimit: "1mb", // افتراضي؛ قم بزيادته لرفع الملفات
},
},
};
export default config;
كلا الخيارين موجودان داخل experimental.serverActions في التوثيق الحالي.10
بعد ذلك، قم بتحديد معدل الطلبات لكل عنوان IP لمنع استغلال الـ action بشكل سيء. توفر حزمة @upstash/ratelimit إصدار 2.0.8 محدد معدل بنظام النافذة المنزلقة (sliding-window):
lib/ratelimit.ts:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/Redis";
export const noteWriteLimiter = new Ratelimit({
Redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "10 s"),
prefix: "notes-write",
});
أضف ذلك إلى الـ action:
import { headers } from "next/headers";
import { noteWriteLimiter } from "@/lib/ratelimit";
export async function addNoteAction(/* ... */): Promise<AddNoteState> {
const ip = (await headers()).get("x-forwarded-for")?.split(",")[0] ?? "anon";
const { success } = await noteWriteLimiter.limit(ip);
if (!success) {
return { ok: false, fieldErrors: { body: ["طلبات كثيرة جدًا، تمهل قليلاً."] } };
}
// ...التحقق الحالي بـ Zod + الإدراج
}
للتطوير المحلي، قم بتوجيه UPSTASH_REDIS_REST_URL و UPSTASH_REDIS_REST_TOKEN إلى قاعدة بيانات Upstash مجانية (تقرأ SDK الخاصة بـ Redis كليهما من البيئة) أو قم بحماية استدعاء تحديد المعدل بعلامة ميزة (feature flag) وتخطاه عندما تكون متغيرات البيئة مفقودة — يتطلب Ratelimit عميلاً متوافقًا مع Redis.
الخطوة 7 — التحقق
قم بتشغيل خادم التطوير:
npm run dev
# - المحلي: http://localhost:3000
في المتصفح:
- افتح
http://localhost:3000. أضف ملاحظة — يظهر زر الإرسال "Adding…" أثناء تنفيذ الـ action (هذا هوuseFormStatus). - أرسل نموذجًا فارغًا — يرفض Zod الطلب وتظهر رسالة الخطأ في مكانها. (لقد قمنا بربط
useOptimisticبمسار الحذف فقط عن قصد، لأن الحذف هو المكان الذي تهم فيه قصة التراجع؛ تستخدم عمليات الإضافة عقد الخطأ المتزامن الخاص بـuseActionStateبدلاً من ذلك.) - انقر فوق حذف في أحد الصفوف — يختفي فورًا (بشكل متفائل)، ثم يختفي نهائيًا بمجرد تأكيد الخادم عبر
revalidatePath. - لإثبات نجاح التراجع، قم بتعديل
deleteNoteActionمؤقتًا لـthrow new Error("simulated")قبل استدعاء قاعدة البيانات. انقر فوق حذف — يختفي الصف، ثم يعود فجأة، ويقرأ الخطأ المضمن "simulated". تراجع عن التغيير.
تأكد من أن نقطة نهاية الـ action مقفولة على طلبات POST من نفس الأصل فقط:
# يتم رفض طلب POST من أصل مختلف — تختلف الاستجابة الدقيقة (عادةً 403 أو إعادة توجيه)
curl -i -X POST http://localhost:3000 \
-H "Origin: https://evil.example.com" \
-H "Content-Type: text/plain" \
--data ""
# HTTP/1.1 403 Forbidden (أو إعادة التوجيه للصفحة الرئيسية من حماية CSRF)
الأخطاء الشائعة
| العرض | السبب المرجح | الحل |
|---|---|---|
useFormStatus دائمًا pending: false | تم استدعاء الـ Hook في مكون النموذج نفسه، وليس في مكون تابع | انقله إلى مكون تابع <SubmitButton>8 |
| تحذير "An async function with useActionState was called outside of a transition" | لقد قمت باستدعاء dispatch() يدويًا بدلاً من عبر خاصية <form action> | قم بتغليفه في startTransition(() => dispatch(payload)) |
| الحذف المتفائل يظل رماديًا للأبد | الـ action الخاص بك يعيد {error: ...} بدلاً من رمي خطأ (throw) | useOptimistic يتراجع فقط عند رمي خطأ — قم بتغيير الـ action ليرمي خطأ أو قم بتحديث حالة المكون الأب5 |
| مدخلات النموذج تفرغ بعد النجاح | يقوم React 19 بإعادة تعيين تلقائي لـ <form action={fn}> غير المتحكم فيه بعد نجاح الـ action | استخدم مدخلات متحكم فيها، أو requestFormReset من React-dom للإدارة بشكل صريح11 |
Body exceeded 1MB limit عند رفع ملف | الحد الافتراضي لـ serverActions.bodySizeLimit هو 1 ميجابايت | قم بزيادته في next.config.ts (bodySizeLimit: '5mb') أو استخدم نمط الرفع عبر رابط موقع مسبقًا (presigned-URL)10 |
| الـ action يعمل ولكن الصفحة لا تتحدث | نسيت استدعاء revalidatePath داخل الـ action | أضف revalidatePath("/") بعد كل عملية تعديل حتى يتم إعادة رندر الـ Server Component6 |
الخطوات التالية
- قم بربط نفس النمط بملف تعريف تخزين مؤقت أطول باستخدام
use cacheوcacheLifeفي FastAPI 16 لتبقى استعلامات القراءة غير مكلفة. - إذا كنت قادمًا من Pages Router، فإن دليل الانتقال إلى App Router يغطي تغييرات التوجيه التي تفتح المجال لاستخدام Server Actions.
- قم بتنشيط معلوماتك الأساسية حول React props و state قبل تقديم المزيد من الـ hooks.
الحواشي
الحواشي
-
جدول إصدارات Node.js — دخل Node 24 مرحلة LTS النشطة في 28 أكتوبر 2025 ويظل كذلك حتى أكتوبر 2026؛ دخل Node 22 مرحلة LTS للصيانة في أكتوبر 2025 مع دعم حتى أبريل 2027. انظر https://endoflife.date/nodejs وصفحة إصدارات nodejs.org لمعرفة تواريخ المراحل الرسمية. ↩
-
منشور إصدار FastAPI 16 يوثق استقرار Turbopack وكون React Compiler 1.0 افتراضيًا (https://nextjs.org/blog/next-16). آلية تشفير معرف Server Action، وتخزين البناء المؤقت لمدة 14 يومًا، ومتغير البيئة
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYلاتساق الخوادم المتعددة المستضافة ذاتيًا موثقة في https://nextjs.org/docs/app/guides/data-security ("Secure action IDs" و "Overwriting encryption keys"). ↩ -
مرجع واجهة سطر أوامر
create-next-app— عائلة--no-*تلغي الخيارات الافتراضية؛ علامة "تخطي المدقق" الرسمية في FastAPI 16 هي--no-linter. https://nextjs.org/docs/app/api-reference/cli/create-next-app ↩ -
إرشادات أمان Server Actions في FastAPI — "يجب دائمًا معاملة Server Actions على أنها معادية، ويجب التحقق من المدخلات." https://nextjs.org/blog/security-nextjs-server-components-actions ↩
-
توثيق React — تعمق في
useOptimistic"ماذا يحدث عندما يفشل الـ Action": "إذا رمى الـ Action خطأً، فإن الـ Transition ينتهي، ويقوم React بالرندر باستخدام أي قيمة كانت لـvalueحاليًا." https://react.dev/reference/react/useOptimistic ↩ ↩2 ↩3 ↩4 -
revalidatePathتقوم بإلغاء صلاحية البيانات التي تم جلبها على مسار معين في الطلب التالي. https://nextjs.org/docs/app/API-reference/functions/revalidatePath ↩ ↩2 -
مرجع
useActionState— مع<form action={dispatch}>، يكون توقيع الـ reducer هو(previousState, formData). https://React.dev/reference/React/useActionState ↩ -
مرجع
useFormStatus— "يجب استدعاؤه من مكون يتم رندرة (render) داخله<form>" و "لن يعيد معلومات الحالة لأي<form>يتم رندرته في نفس المكون." https://React.dev/reference/React-dom/hooks/useFormStatus ↩ ↩2 -
توثيق Next.js — تقوم Server Actions بمقارنة هيدر
OriginبهيدرHostوترفض الطلب في حالة عدم التطابق كإجراء مدمج للحماية من CSRF بالإضافة إلى ملفات تعريف الارتباط (cookies) من نوعSameSite. https://nextjs.org/docs/app/guides/data-security ↩ -
إعدادات
serverActions— يتم تكوينbodySizeLimit(الافتراضي1mb) وallowedOriginsتحتexperimental.serverActionsفي ملفnext.config.ts. https://nextjs.org/docs/app/API-reference/config/next-config-js/serverActions ↩ ↩2 -
منشور إطلاق React 19 — يقوم
<form action={fn}>تلقائياً بإعادة تعيين المدخلات غير المتحكم بها (uncontrolled inputs) بعد اكتمال الـ action. التحكم اليدوي متاح عبرrequestFormResetفيReact-dom. https://React.dev/blog/2024/12/05/React-19 ↩