Next.js 16 شرح Optimistic UI: الـ Server Actions لعام

١٨ مايو ٢٠٢٦

Next.js 16 Optimistic UI Tutorial: Server Actions 2026

ملخص. يبني هذا الدليل تطبيقاً مصغراً قابلاً للتشغيل بـ Next.js 16.2.6 + React 19.2.6 يقوم بإضافة وحذف الملاحظات من SQLite مع واجهة مستخدم متفائلة (optimistic UI) تصمد أمام الأخطاء، وإعادة تحميل الصفحة، وقيود معدل الطلبات. ستقوم بربط useActionState لحالة النموذج (form state)، و useFormStatus لزر الإرسال، و useOptimistic لتحديثات القائمة الفورية — مع نظرة فاحصة على فخ "الفشل الصامت" الذي يكسر معظم أكواد الشروحات التعليمية. الوقت الإجمالي: ~25 دقيقة.

ما ستتعلمه

  1. إنشاء هيكل مشروع Next.js 16.2.6 + TypeScript مع Drizzle + SQLite ليعمل المشروع محلياً بالكامل (بدون حساب سحابي)
  2. كتابة Server Action مع مخطط Zod وعقد خطأ محدد النوع (typed error contract)
  3. استخدام useActionState لإظهار أخطاء التحقق بجانب حقل الإدخال
  4. استخدام useFormStatus داخل مكون فرعي <SubmitButton> لإظهار حالة الانتظار
  5. استخدام useOptimistic لتحديثات القائمة الفورية — وتجنب فخ الفشل الصامت حيث لا تتراجع الأخطاء عن التغييرات
  6. تحديث الصفحة التي يتم رندررتها على الخادم باستخدام revalidatePath ليعكس الـ DOM كل عملية تغيير
  7. تأمين الـ Server Actions باستخدام serverActions.allowedOrigins وتحديد معدل الطلبات بنظام النافذة المنزلقة (sliding-window rate limit)

المتطلبات الأساسية

  • Node.js 24 LTS (دعم نشط حتى أكتوبر 2026)1 أو Node 22 Maintenance LTS (حتى أبريل 2027)
  • npm 10+ (يأتي مع 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 هو الوسيط الثاني.7
  • useFormStatus يقرأ فقط حالة النموذج الأب له، لذا يجب أن يتواجد داخل مكون فرعي — هنا هو <SubmitButton>.8
  • useOptimistic يأخذ القيمة القياسية 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

في المتصفح:

  1. افتح http://localhost:3000. أضف ملاحظة — يظهر زر الإرسال "Adding…" أثناء تنفيذ الـ action (هذا هو useFormStatus).
  2. أرسل نموذجًا فارغًا — يرفض Zod الطلب وتظهر رسالة الخطأ في مكانها. (لقد قمنا بربط useOptimistic بمسار الحذف فقط عن قصد، لأن الحذف هو المكان الذي تهم فيه قصة التراجع؛ تستخدم عمليات الإضافة عقد الخطأ المتزامن الخاص بـ useActionState بدلاً من ذلك.)
  3. انقر فوق حذف في أحد الصفوف — يختفي فورًا (بشكل متفائل)، ثم يختفي نهائيًا بمجرد تأكيد الخادم عبر revalidatePath.
  4. لإثبات نجاح التراجع، قم بتعديل 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.

الحواشي

الحواشي

  1. جدول إصدارات Node.js — دخل Node 24 مرحلة LTS النشطة في 28 أكتوبر 2025 ويظل كذلك حتى أكتوبر 2026؛ دخل Node 22 مرحلة LTS للصيانة في أكتوبر 2025 مع دعم حتى أبريل 2027. انظر https://endoflife.date/nodejs وصفحة إصدارات nodejs.org لمعرفة تواريخ المراحل الرسمية.

  2. منشور إصدار 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").

  3. مرجع واجهة سطر أوامر create-next-app — عائلة --no-* تلغي الخيارات الافتراضية؛ علامة "تخطي المدقق" الرسمية في FastAPI 16 هي --no-linter. https://nextjs.org/docs/app/api-reference/cli/create-next-app

  4. إرشادات أمان Server Actions في FastAPI — "يجب دائمًا معاملة Server Actions على أنها معادية، ويجب التحقق من المدخلات." https://nextjs.org/blog/security-nextjs-server-components-actions

  5. توثيق React — تعمق في useOptimistic "ماذا يحدث عندما يفشل الـ Action": "إذا رمى الـ Action خطأً، فإن الـ Transition ينتهي، ويقوم React بالرندر باستخدام أي قيمة كانت لـ value حاليًا." https://react.dev/reference/react/useOptimistic 2 3 4

  6. revalidatePath تقوم بإلغاء صلاحية البيانات التي تم جلبها على مسار معين في الطلب التالي. https://nextjs.org/docs/app/API-reference/functions/revalidatePath 2

  7. مرجع useActionState — مع <form action={dispatch}>، يكون توقيع الـ reducer هو (previousState, formData). https://React.dev/reference/React/useActionState

  8. مرجع useFormStatus — "يجب استدعاؤه من مكون يتم رندرة (render) داخله <form>" و "لن يعيد معلومات الحالة لأي <form> يتم رندرته في نفس المكون." https://React.dev/reference/React-dom/hooks/useFormStatus 2

  9. توثيق Next.js — تقوم Server Actions بمقارنة هيدر Origin بهيدر Host وترفض الطلب في حالة عدم التطابق كإجراء مدمج للحماية من CSRF بالإضافة إلى ملفات تعريف الارتباط (cookies) من نوع SameSite. https://nextjs.org/docs/app/guides/data-security

  10. إعدادات serverActions — يتم تكوين bodySizeLimit (الافتراضي 1mb) و allowedOrigins تحت experimental.serverActions في ملف next.config.ts. https://nextjs.org/docs/app/API-reference/config/next-config-js/serverActions 2

  11. منشور إطلاق React 19 — يقوم <form action={fn}> تلقائياً بإعادة تعيين المدخلات غير المتحكم بها (uncontrolled inputs) بعد اكتمال الـ action. التحكم اليدوي متاح عبر requestFormReset في React-dom. https://React.dev/blog/2024/12/05/React-19


نشرة أسبوعية مجانية

ابقَ على مسار النيرد

بريد واحد أسبوعياً — دورات، مقالات معمّقة، أدوات، وتجارب ذكاء اصطناعي.

بدون إزعاج. إلغاء الاشتراك في أي وقت.