Next.js
۳ دقیقه مطالعهفرمهای Type-Safe در Next.js: ترکیب Server Actions، Zod و useActionState
یه اسکیمای Zod مشترک بین کلاینت و سرور، یه Server Action، و useActionState — بدون فرملایبرری، بدون API route، بدون واگرایی validation کلاینت و سرور.

دقیقاً همین الگو — Server Action + اسکیمای Zod + useActionState — چیزیه که پشت هر فرم پنل ادمین این سایت کار میکنه. ارزش نوشتن درستوحسابیشو داره، چون جای یه مقدار غافلگیرکننده از چیزی رو میگیره که قبلاً به یه فرملایبرری، یه API route، و یه کتابخونهی validation سمت کلاینت نیاز داشت که همهشون دستی به هم وصل بشن.
شکل کلیش
- یه اسکیمای Zod تعریف میکنه دادهی معتبر چه شکلیه — فقط یهبار، مشترک بین پیام خطای قابلمشاهدهی کلاینت و validation سمت سرور.
- یه Server Action،
FormDataرو میخونه، با اسکیما validateش میکنه، و یا خطای فیلد برمیگردونه یا خودِ mutation رو انجام میده. useActionStateیه فرم رو بدون هیچfetchیا JSON دستی به اون اکشن وصل میکنه، و حتی بدون جاوااسکریپت هم کار میکنه (progressive enhancement) چون یه<form action>واقعیه.
۱. اسکیما
import { z } from "zod";
export const projectSchema = z.object({
slug: z.string().min(2).regex(/^[a-z0-9-]+$/, "Slug must be kebab-case"),
titleEn: z.string().min(1, "English title is required"),
order: z.coerce.number().int().default(0), // FormData values are always strings — coerce them
});۲. Server Action
"use server";
export type ActionState = {
success: boolean;
message?: string;
fieldErrors?: Record<string, string[]>;
};
export async function createProject(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = projectSchema.safeParse({
slug: formData.get("slug"),
titleEn: formData.get("titleEn"),
order: formData.get("order") ?? 0,
});
if (!parsed.success) {
return {
success: false,
message: "Please fix the highlighted fields.",
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
await projectRepository.create(parsed.data);
return { success: true };
}۳. وصل کردنش به فرم
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
const INITIAL_STATE: ActionState = { success: false };
function SubmitButton() {
const { pending } = useFormStatus(); // reads the pending state of the nearest parent <form>
return <button disabled={pending}>{pending ? "Saving…" : "Create"}</button>;
}
export default function ProjectForm() {
const [state, formAction] = useActionState(createProject, INITIAL_STATE);
return (
<form action={formAction}>
<input name="titleEn" />
{state.fieldErrors?.titleEn && <p>{state.fieldErrors.titleEn[0]}</p>}
<SubmitButton />
</form>
);
}یه نکتهی مهم: ریدایرکت
وسوسهانگیزه که آخر یه Server Action که با useActionState اجرا میشه، redirect() صدا بزنی — ولی این فعلاً توی Next.js قابلاعتماد نیست (اولین submit ممکنه بیسروصدا ریدایرکت نکنه). الگوی قابلاعتمادتر: از اکشن { success: true } برگردون، و ریدایرکت رو سمت کلاینت از یه useEffect که اون state رو نگاه میکنه انجام بده.
useEffect(() => {
if (state.success) {
router.push("/admin/projects");
router.refresh(); // re-fetches the destination page’s server data
}
}, [state.success, router]);چرا این از یه فرملایبرری سمت کلاینت اینجا بهتره
- یه اسکیما، نه دوتا — بدون یه config جدای validation کلاینت که ممکنه از قوانین سرور جدا بیفته.
- قبل از تموم شدن hydration هم کار میکنه، و بدون جاوااسکریپت به یه POST فرم واقعی تنزل پیدا میکنه.
- بدون API route، بدون
fetch، بدون سریالایز/دیسریالایز دستی JSON، بدون لولهکشی state لودینگ فراتر ازuseFormStatus.
این جایگزین چیزی مثل React Hook Form برای فرمهای واقعاً پیچیده و بسیار تعاملی نیست (ویزاردهای چندمرحلهای با وابستگی سنگین سمت کلاینت) — ولی برای حالت خیلی رایجِ «این رکورد رو بساز/ویرایش کن»، کد کمتر، بخشهای متحرک کمتر، و یه منبع حقیقت واحد برای validationه.