الكتابات/tutorial/2026/07
Tutorial15 يوليو 2026·30 دقيقة

النماذج الآمنة بالأنواع مع React Hook Form v8 و Zod v4 و Next.js 15 Server Actions

أنشئ نماذج جاهزة للإنتاج في Next.js 15 باستخدام React Hook Form v8 و Zod v4. يغطي هذا الدرس التحقق من الصحة المدعوم بالمخططات، وتكامل Server Actions، وواجهة المستخدم التفاؤلية مع useActionState، ورفع الملفات، والنماذج متعددة الخطوات.

النماذج هي المصدر الأكثر شيوعًا للأخطاء في تطبيقات الويب الإنتاجية. يمر بريد إلكتروني غير صالح، أو يُمرَّر حقل مطلوب فارغًا، أو يترك خطأ في الخادم المستخدم أمام صفحة فارغة بدون أي تغذية راجعة. يسد React Hook Form v8 مقترنًا بـ Zod v4 وNext.js 15 Server Actions هذه الفجوات الثلاث جميعها — تحصل على تحقق مدعوم بالمخطط يعمل على الجهازين العميل والخادم معًا، وتعزيز تدريجي بحيث تعمل النماذج حتى قبل أن يُنشّط JavaScript، وأمان في الأنواع يتدفق من المخطط حتى استدعاء قاعدة البيانات.

يأخذك هذا الدرس خطوة بخطوة في بناء نموذج اتصال متكامل وجاهز للإنتاج — النوع الذي تشحنه فعلاً شركة برمجيات في تونس أو مشروع SaaS في منطقة الشرق الأوسط وشمال أفريقيا. بنهاية الدرس ستمتلك:

  • مخطط Zod v4 مشترك بين العميل والخادم
  • React Hook Form v8 يدير حالة الحقول بدون مكونات مُتحكَّم فيها
  • Server Action في Next.js 15 يتحقق من البيانات ويحفظها
  • تغذية راجعة فورية بواجهة مستخدم تفاؤلية باستخدام useActionState
  • دعم رفع المرفقات مع التحقق من الحجم ونوع MIME
  • رسائل خطأ إمكانية وصول مرتبطة بحقولها

لماذا هذه التقنيات في 2026؟

ثلاثة أشياء حدثت بتتابع سريع تجعل هذا التوليف الخيار الافتراضي للنماذج في الإنتاج:

Zod v4 (صدر مطلع 2026) أعاد كتابة مكوناته الداخلية بتحسن في السرعة من 3 إلى 14 مرة، وأضاف دعمًا أصليًا لـ z.file() لكائنات File في المتصفح. واجهة برمجية التطبيقات بالكاد تغيرت، لذا الترقية من v3 سطر واحد.

React Hook Form v8 تخلص من اعتماديته على React 18 واحتضن أنماط React 19 بالكامل. تكامل useFormState / useActionState أصبح رسميًا، مما يعني إمكانية استدعاء Server Action من داخل handleSubmit في RHF والحصول مباشرةً على أخطاء منظمة بدون أي توصيل مخصص.

Next.js 15 Server Actions باتت مستقرة وتدعم FormData وقيم الإرجاع المنظمة والتعزيز التدريجي خارج الصندوق.

المتطلبات المسبقة

قبل البدء، تأكد من توفر:

  • Node.js 20+ و pnpm مثبّتَين
  • معرفة أساسية بـ React و Next.js App Router
  • راحة مع TypeScript (نستخدم الوضع الصارم طوال الدرس)
  • محرر كود (VS Code مع إضافة Zod موصى به)

ما ستبنيه

نموذج "طلب عرض أسعار" يحتوي على الحقول التالية:

الحقلالتحقق
الاسم الكاملمطلوب، 2–100 حرف
البريد الإلكترونيبريد إلكتروني صالح، حروف صغيرة
الشركةاختياري، 100 حرف كحد أقصى
الخدمة المطلوبةEnum — أحد أربعة خيارات
النطاق الميزانيEnum — أحد خمسة نطاقات
الرسالةمطلوب، 20–2000 حرف
المرفقPDF اختياري، 5 ميغابايت كحد أقصى

يستخدم النموذج التحقق من جهة العميل للتغذية الراجعة الفورية، وإعادة التحقق من جهة الخادم داخل Server Action قبل وصول البيانات إلى أي طبقة تخزين.

الخطوة 1: إنشاء المشروع

pnpm create next-app@latest proposal-form \
  --typescript \
  --tailwind \
  --app \
  --eslint \
  --src-dir \
  --import-alias "@/*"
cd proposal-form

تثبيت مكتبات النماذج والتحقق:

pnpm add react-hook-form zod @hookform/resolvers

@hookform/resolvers هو الجسر الرسمي بين React Hook Form ومكتبات التحقق بالمخططات. يُغلِّف Zod (وغيره) بحيث يستطيع RHF استدعاء schema.parse() في اللحظات المناسبة.

الخطوة 2: تعريف مخطط Zod

أنشئ الملف src/lib/schemas/proposal.ts. حفظ المخطط في lib/ (لا داخل مكوّن أو ملف Server Action) يجعل كلًا من العميل والخادم يستطيعان استيراده دون مشاكل في التجميع.

// src/lib/schemas/proposal.ts
import { z } from "zod";
 
export const SERVICE_OPTIONS = [
  "web-development",
  "mobile-app",
  "ai-integration",
  "consulting",
] as const;
 
export const BUDGET_OPTIONS = [
  "under-5k",
  "5k-20k",
  "20k-50k",
  "50k-100k",
  "above-100k",
] as const;
 
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const ACCEPTED_TYPES = ["application/pdf"] as const;
 
export const proposalSchema = z.object({
  fullName: z
    .string()
    .min(2, "يجب أن يتكون الاسم من حرفين على الأقل")
    .max(100, "يجب أن يكون الاسم أقل من 100 حرف")
    .trim(),
  email: z
    .string()
    .email("يرجى إدخال عنوان بريد إلكتروني صالح")
    .toLowerCase(),
  company: z
    .string()
    .max(100, "يجب أن يكون اسم الشركة أقل من 100 حرف")
    .trim()
    .optional()
    .or(z.literal("")),
  service: z.enum(SERVICE_OPTIONS, {
    errorMap: () => ({ message: "يرجى اختيار خدمة" }),
  }),
  budget: z.enum(BUDGET_OPTIONS, {
    errorMap: () => ({ message: "يرجى اختيار نطاق الميزانية" }),
  }),
  message: z
    .string()
    .min(20, "يجب أن تتكون الرسالة من 20 حرفًا على الأقل")
    .max(2000, "يجب أن تكون الرسالة أقل من 2000 حرف")
    .trim(),
  attachment: z
    .instanceof(File)
    .refine((f) => f.size <= MAX_FILE_SIZE, "يجب أن يكون حجم الملف أقل من 5 ميغابايت")
    .refine(
      (f) => ACCEPTED_TYPES.includes(f.type as (typeof ACCEPTED_TYPES)[number]),
      "يُقبل ملفات PDF فقط"
    )
    .optional(),
});
 
export type ProposalFormData = z.infer<typeof proposalSchema>;

ملاحظات مهمة:

  • .toLowerCase() على حقل البريد الإلكتروني يحوّل القيمة عند التحليل، لذا Alice@Example.COM تصبح alice@example.com قبل وصولها إلى قاعدة البيانات.
  • .trim() يزيل المسافات البيضاء من بداية ونهاية حقول النص.
  • z.instanceof(File) يستخدم الوعي الأصلي بالملفات في Zod v4. على الخادم، حيث File ليست متاحة عالميًا، نستخدم z.custom() بدلاً منها — نعالج ذلك في الخطوة 4.
  • نمط .optional().or(z.literal("")) في company يتعامل مع الحالة التي يُرسل فيها حقل HTML سلسلة فارغة بدلاً من undefined.

الخطوة 3: بناء Server Action

أنشئ src/app/actions/proposal.ts. يجب أن تكون Server Actions في ملفات مُعلَّمة بـ "use server" في الأعلى.

// src/app/actions/proposal.ts
"use server";
 
import { z } from "zod";
import { proposalSchema } from "@/lib/schemas/proposal";
 
// مخطط جانب الخادم: File يصبح Blob لأن Node لا يوفر File عالميًا
const serverSchema = proposalSchema.extend({
  attachment: z
    .instanceof(Blob)
    .refine((b) => b.size <= 5 * 1024 * 1024, "يجب أن يكون حجم الملف أقل من 5 ميغابايت")
    .refine(
      (b) => b.type === "application/pdf",
      "يُقبل ملفات PDF فقط"
    )
    .optional(),
});
 
export type ProposalActionState = {
  success: boolean;
  message: string;
  errors?: Partial<Record<keyof z.infer<typeof proposalSchema>, string[]>>;
};
 
export async function submitProposal(
  _prev: ProposalActionState,
  formData: FormData
): Promise<ProposalActionState> {
  const raw = {
    fullName: formData.get("fullName"),
    email: formData.get("email"),
    company: formData.get("company"),
    service: formData.get("service"),
    budget: formData.get("budget"),
    message: formData.get("message"),
    attachment: formData.get("attachment") as Blob | null,
  };
 
  if (!raw.attachment || (raw.attachment as Blob).size === 0) {
    delete raw.attachment;
  }
 
  const parsed = serverSchema.safeParse(raw);
 
  if (!parsed.success) {
    const fieldErrors = parsed.error.flatten().fieldErrors;
    return {
      success: false,
      message: "يرجى تصحيح الأخطاء أدناه والمحاولة مرة أخرى.",
      errors: fieldErrors,
    };
  }
 
  const { fullName, email, company, service, budget, message } = parsed.data;
 
  // TODO: احفظ في قاعدة البيانات، أرسل بريدًا إلكترونيًا للإشعار، إلخ.
  console.log("تم استلام الطلب:", { fullName, email, company, service, budget, message });
 
  await new Promise((r) => setTimeout(r, 400));
 
  return {
    success: true,
    message: `شكرًا لك ${fullName}! سنتواصل معك على ${email} خلال 24 ساعة.`,
  };
}

توقيع الدالة (prev, formData) مطلوب من قِبل useActionState — يُمرِّر الخطاف دائمًا الحالة السابقة كوسيط أول والـ FormData الجديد كوسيط ثانٍ.

الخطوة 4: إنشاء مكوّن النموذج

أنشئ src/components/ProposalForm.tsx. بما أن هذا المكوّن يستخدم خطافات المتصفح، يجب أن يكون Client Component.

// src/components/ProposalForm.tsx
"use client";
 
import { useActionState, useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  proposalSchema,
  type ProposalFormData,
  SERVICE_OPTIONS,
  BUDGET_OPTIONS,
} from "@/lib/schemas/proposal";
import { submitProposal, type ProposalActionState } from "@/app/actions/proposal";
 
const INITIAL_STATE: ProposalActionState = {
  success: false,
  message: "",
};
 
const SERVICE_LABELS: Record<(typeof SERVICE_OPTIONS)[number], string> = {
  "web-development": "تطوير الويب",
  "mobile-app": "تطبيق الجوال",
  "ai-integration": "دمج الذكاء الاصطناعي",
  "consulting": "الاستشارات",
};
 
const BUDGET_LABELS: Record<(typeof BUDGET_OPTIONS)[number], string> = {
  "under-5k": "أقل من 5,000 دولار",
  "5k-20k": "5,000 – 20,000 دولار",
  "20k-50k": "20,000 – 50,000 دولار",
  "50k-100k": "50,000 – 100,000 دولار",
  "above-100k": "أكثر من 100,000 دولار",
};
 
export function ProposalForm() {
  const [state, action, isPending] = useActionState(submitProposal, INITIAL_STATE);
  const formRef = useRef<HTMLFormElement>(null);
 
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
    setError,
  } = useForm<ProposalFormData>({
    resolver: zodResolver(proposalSchema),
    defaultValues: {
      fullName: "",
      email: "",
      company: "",
      message: "",
    },
  });
 
  // مزامنة أخطاء الخادم مع حالة أخطاء RHF
  useEffect(() => {
    if (state.errors) {
      for (const [field, messages] of Object.entries(state.errors)) {
        if (messages && messages.length > 0) {
          setError(field as keyof ProposalFormData, {
            type: "server",
            message: messages[0],
          });
        }
      }
    }
    if (state.success) {
      reset();
      formRef.current?.reset();
    }
  }, [state, setError, reset]);
 
  const onSubmit = handleSubmit(() => {
    formRef.current?.requestSubmit();
  });
 
  return (
    <form ref={formRef} action={action} onSubmit={onSubmit} noValidate dir="rtl">
      {state.message && (
        <div
          role="alert"
          className={`mb-6 rounded-lg p-4 text-sm ${
            state.success
              ? "bg-green-50 text-green-800 border border-green-200"
              : "bg-red-50 text-red-800 border border-red-200"
          }`}
        >
          {state.message}
        </div>
      )}
 
      {/* الاسم الكامل */}
      <div className="mb-4">
        <label htmlFor="fullName" className="block text-sm font-medium mb-1">
          الاسم الكامل <span aria-hidden="true">*</span>
        </label>
        <input
          id="fullName"
          type="text"
          autoComplete="name"
          {...register("fullName")}
          aria-describedby={errors.fullName ? "fullName-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        {errors.fullName && (
          <p id="fullName-error" className="mt-1 text-xs text-red-600">
            {errors.fullName.message}
          </p>
        )}
      </div>
 
      {/* البريد الإلكتروني */}
      <div className="mb-4">
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          البريد الإلكتروني <span aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          dir="ltr"
          {...register("email")}
          aria-describedby={errors.email ? "email-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        {errors.email && (
          <p id="email-error" className="mt-1 text-xs text-red-600">
            {errors.email.message}
          </p>
        )}
      </div>
 
      {/* الشركة (اختياري) */}
      <div className="mb-4">
        <label htmlFor="company" className="block text-sm font-medium mb-1">
          الشركة <span className="text-gray-400 text-xs">(اختياري)</span>
        </label>
        <input
          id="company"
          type="text"
          autoComplete="organization"
          {...register("company")}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        {errors.company && (
          <p className="mt-1 text-xs text-red-600">{errors.company.message}</p>
        )}
      </div>
 
      {/* الخدمة */}
      <div className="mb-4">
        <label htmlFor="service" className="block text-sm font-medium mb-1">
          الخدمة المطلوبة <span aria-hidden="true">*</span>
        </label>
        <select
          id="service"
          {...register("service")}
          aria-describedby={errors.service ? "service-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="">اختر خدمة…</option>
          {SERVICE_OPTIONS.map((v) => (
            <option key={v} value={v}>
              {SERVICE_LABELS[v]}
            </option>
          ))}
        </select>
        {errors.service && (
          <p id="service-error" className="mt-1 text-xs text-red-600">
            {errors.service.message}
          </p>
        )}
      </div>
 
      {/* الميزانية */}
      <div className="mb-4">
        <label htmlFor="budget" className="block text-sm font-medium mb-1">
          النطاق الميزاني <span aria-hidden="true">*</span>
        </label>
        <select
          id="budget"
          {...register("budget")}
          aria-describedby={errors.budget ? "budget-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="">اختر نطاقًا…</option>
          {BUDGET_OPTIONS.map((v) => (
            <option key={v} value={v}>
              {BUDGET_LABELS[v]}
            </option>
          ))}
        </select>
        {errors.budget && (
          <p id="budget-error" className="mt-1 text-xs text-red-600">
            {errors.budget.message}
          </p>
        )}
      </div>
 
      {/* الرسالة */}
      <div className="mb-4">
        <label htmlFor="message" className="block text-sm font-medium mb-1">
          الرسالة <span aria-hidden="true">*</span>
        </label>
        <textarea
          id="message"
          rows={5}
          {...register("message")}
          aria-describedby={errors.message ? "message-error" : undefined}
          className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
        />
        {errors.message && (
          <p id="message-error" className="mt-1 text-xs text-red-600">
            {errors.message.message}
          </p>
        )}
      </div>
 
      {/* المرفق */}
      <div className="mb-6">
        <label htmlFor="attachment" className="block text-sm font-medium mb-1">
          المرفق <span className="text-gray-400 text-xs">(PDF، 5 ميغابايت كحد أقصى)</span>
        </label>
        <input
          id="attachment"
          type="file"
          accept="application/pdf"
          {...register("attachment")}
          className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700"
        />
        {errors.attachment && (
          <p className="mt-1 text-xs text-red-600">{errors.attachment.message}</p>
        )}
      </div>
 
      <button
        type="submit"
        disabled={isPending}
        className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
      >
        {isPending ? "جارٍ الإرسال…" : "إرسال طلب عرض الأسعار"}
      </button>
    </form>
  );
}

الخطوة 5: تضمينه في صفحة

// src/app/contact/page.tsx
import { ProposalForm } from "@/components/ProposalForm";
 
export const metadata = {
  title: "طلب عرض أسعار",
  description: "أخبرنا عن مشروعك وسنتواصل معك خلال 24 ساعة.",
};
 
export default function ContactPage() {
  return (
    <main className="mx-auto max-w-lg px-4 py-16" dir="rtl">
      <h1 className="text-2xl font-bold mb-2">طلب عرض أسعار</h1>
      <p className="text-gray-500 text-sm mb-8">
        أكمل التفاصيل أدناه وسنرد خلال يوم عمل واحد.
      </p>
      <ProposalForm />
    </main>
  );
}

كيف تعمل طبقتا التحقق معًا

فهم البنية ذات الطبقتين يمنع الأخطاء:

الطبقة الأولى — العميل (React Hook Form + محلل Zod): عند نقر المستخدم على إرسال، يستدعي handleSubmit المحلل zodResolver(proposalSchema) قبل إطلاق Server Action. تملأ الأخطاء خريطة أخطاء RHF فوريًا دون أي ذهاب وإياب عبر الشبكة. يرى المستخدم رسائل أخطاء مضمّنة تحت الحقول المعنية.

الطبقة الثانية — الخادم (Server Action + serverSchema): حتى لو أوقف شخص ما JavaScript أو أرسل طلب fetch خامًا لتجاوز العميل، يُعيد Server Action التحقق باستخدام serverSchema.safeParse(). تعود الأخطاء في الكائن المنظم state.errors، ويقوم useEffect في المكوّن بمزامنتها مع حالة أخطاء RHF بحيث تُعرض بنفس الطريقة تمامًا.

الفرق الوحيد بين المخططَين هو حقل attachment: في العميل يعمل z.instanceof(File) لأن File متغير عالمي للمتصفح؛ في الخادم نستبدله بـ z.instanceof(Blob) الذي يوفره Node 20+ أصليًا.

الخطوة 6: إضافة التغذية الراجعة التفاؤلية

import { useOptimistic } from "react";
 
// داخل المكوّن:
const [optimisticSent, setOptimisticSent] = useOptimistic(false);
 
const onSubmit = handleSubmit(() => {
  setOptimisticSent(true);
  formRef.current?.requestSubmit();
});
 
// في JSX:
{optimisticSent && !state.success && (
  <p className="text-blue-600 text-sm mb-4 animate-pulse">
    جارٍ إرسال طلبك…
  </p>
)}

useOptimistic من React 19 ينقلب إلى true في اللحظة التي ينقر فيها المستخدم على إرسال ويُعيد ضبط نفسه تلقائيًا بمجرد حل Server Action. يُبقي هذا الواجهة متجاوبة حتى على الاتصالات البطيئة.

الخطوة 7: قواعد التحقق المخصصة

تتيح .refine() و .superRefine() في Zod إضافة قواعد أعمال تتجاوز فحوصات الشكل.

// مثال: إلزامية اسم الشركة للميزانيات الكبيرة
export const proposalSchema = z
  .object({
    // ... الحقول كما سبق
  })
  .refine(
    (data) => {
      if (data.budget === "above-100k" && !data.company) {
        return false;
      }
      return true;
    },
    {
      message: "اسم الشركة مطلوب للميزانيات الكبيرة",
      path: ["company"],
    }
  );

التحقق متعدد الحقول كهذا هو ما يتألق فيه Zod. خيار path يُخبر React Hook Form بالحقل الذي يجب تمييزه، لذا يظهر الخطأ تحت "الشركة" حتى لو كانت القاعدة تقارن حقلَين.

اختبار التطبيق

شغّل خادم التطوير:

pnpm dev

ثم انتقل إلى http://localhost:3000/contact واختبر هذه السيناريوهات:

المسار السعيد: أكمل جميع الحقول المطلوبة، أرفق ملف PDF صالحًا أقل من 5 ميغابايت، أرسل. يجب أن ترى شعار النجاح.

التحقق من جهة العميل: أرسل النموذج باسم فارغ. يجب أن يظهر الخطأ فورًا دون مؤشر تحميل ودون أي طلب شبكة.

تجاوز جهة الخادم: افتح أدوات المطور، اذهب إلى Network، انقر بالزر الأيمن على إرسال النموذج وعطّل JavaScript. أرسل عبر إجراء النموذج الأصلي. يجب أن تُعاد الصفحة مع نفس أخطاء الحقول مُعرَّضة من جهة الخادم.

رفض الملف: أرفق صورة أو ملفًا أكبر من 5 ميغابايت. يجب أن يظهر الخطأ على جهة العميل قبل إرسال النموذج.

استكشاف الأخطاء وإصلاحها

setError is not a function أو لا تظهر أخطاء الخادم: تأكد من أن مصفوفة التبعيات في useEffect تتضمن [state, setError, reset]. غياب state يعني عدم إعادة تشغيل التأثير بعد حل الإجراء.

حقل إدخال الملف لا يُعاد ضبطه بعد النجاح: حقول إدخال HTML للملفات غير مُتحكَّم فيها بطبيعتها. استدعاء formRef.current?.reset() في useEffect يُعيد ضبط عنصر النموذج الأصلي، مما يمسح أداة اختيار الملفات. لا تحاول إعادة ضبط حقل الملف عبر RHF وحده.

خطأ TypeScript على z.instanceof(File) في وحدة الخادم: إذا استورد Server Action عن طريق الخطأ proposalSchema (الذي يستخدم z.instanceof(File))، سيطرح Node خطأً لأن File غير معرّف في بيئة الخادم. أبقِ serverSchema مع z.instanceof(Blob) في ملف الإجراء واستورد proposalSchema في Client Components فقط.

الخطوات التالية

مع توفر الأساس، فكر في هذه التحسينات:

  • الحفظ في قاعدة بيانات باستخدام Drizzle ORM أو Prisma مع كائن parsed.data المُكتَّب — كلاهما يقبل النوع المستنتَج من Zod مباشرةً
  • إرسال إشعارات بالبريد الإلكتروني باستخدام @react-email/components من Resend عند نجاح الإرسال
  • إضافة CAPTCHA باستخدام Cloudflare Turnstile لمنع البريد العشوائي
  • نموذج متعدد الخطوات — قسّم الحقول عبر صفحات، واحفظ الحالة الجزئية في sessionStorage
  • تحديد معدل الطلبات — غلّف Server Action بـ Upstash Redis أو Arcjet لمنع الإساءة

الخلاصة

React Hook Form v8 و Zod v4 ليستا تقنيتَين جديدتَين — بل ناضجتَان منذ سنوات — لكن تكاملهما مع Next.js 15 Server Actions يُلغي أخيرًا الفجوة المحرجة بين التحقق على العميل والتحقق على الخادم. المخطط المُعرَّف في lib/schemas/proposal.ts هو مصدر الحقيقة الوحيد: يُحرك أنواع TypeScript، ورسائل الأخطاء على العميل، وإعادة التحقق على الخادم، وشكل إدراج قاعدة البيانات.

النمط يتوسع بسلاسة. نموذج الدفع، أو تدفق الإعداد متعدد المستأجرين، أو لوحة الإدارة ذات التحقق الثقيل — جميعها تتبع نفس البنية: المخطط في lib/، الإجراء في app/actions/، المكوّن في components/. أبقِ هذه الأجزاء الثلاثة منفصلة ولن تجد نفسك تطارد خطأ تحقق لا يظهر إلا في الإنتاج.