قسمت پانزدهم - اعتبارسنجی فرم با Zod در Next.js
بعد از اینکه فرمهای پروژه را ساختیم، قبل از هر چیز باید مطمئن شویم دادهای که از کاربر میگیریم، واقعاً معتبر است.اینجاست که validation وارد میشود.
#اعتبارسنجی فرم با Zod در Next.js
بعد از اینکه فرمهای پروژه را ساختیم، قدم بعدی فقط submit کردن آنها نیست. قبل از هر چیز باید مطمئن شویم دادهای که از کاربر میگیریم، واقعاً معتبر است.
مثلاً اگر کاربر فرم تماس را بدون ایمیل ارسال کند، یا در فرم ایجاد پروژه یک slug خالی وارد شود، یا در فرم مقاله یک title بیش از حد کوتاه باشد، نباید این داده بدون بررسی وارد سیستم شود.
اینجاست که validation وارد میشود.
در یک پروژه Next.js، مخصوصاً وقتی فرمها قرار است به بکاند، دیتابیس و CMS وصل شوند، validation یکی از بخشهای اصلی معماری فرم است، نه یک جزئیات فرعی.
در این پست روی اعتبارسنجی فرمها با Zod تمرکز میکنیم و schemaهایی برای فرم تماس، پروژه و مقاله میسازیم. هدف این است که منطق اعتبارسنجی از UI جدا شود و در یک لایه مشخص و قابل استفاده مجدد قرار بگیرد.
چرا validation لازم است؟
فرم بدون validation فقط یک مجموعه input است.
کاربر میتواند هر چیزی وارد کند:
- فیلدهای خالی
- ایمیل نامعتبر
URLاشتباه- متن بیش از حد کوتاه یا بیش از حد بلند
slugبا فرمت نامناسب- دادهای که اصلاً با مدل دیتابیس هماهنگ نیست
اگر این دادهها بدون بررسی پردازش شوند، چند مشکل به وجود میآید:
- تجربه کاربری ضعیف میشود
- خطاهای سمت سرور بیشتر میشوند
- دادههای ناسالم وارد دیتابیس میشوند
- منطق فرم در چند جا تکرار میشود
- نگهداری پروژه سختتر میشود
نکته مهم این است که validation فقط برای سمت client نیست.
حتی اگر در UI پیام خطا نشان بدهیم، باز هم باید در لایه سرور داده را validate کنیم، چون هر درخواست client-side قابل دور زدن است.
پس validation خوب معمولاً این ویژگیها را دارد:
- نزدیک به مدل واقعی داده است
- در یک نقطه مرکزی تعریف میشود
- قابل
reuseاست - پیام خطاهای واضح تولید میکند
- در چند بخش مختلف پروژه قابل استفاده است
معرفی Zod
Zod یکی از رایجترین کتابخانهها برای validation در پروژههای TypeScript و Next.js است.
دلیل محبوبیت آن این است که schema را بهصورت شفاف تعریف میکنیم و همان schema هم برای validate کردن داده و هم برای infer کردن type قابل استفاده است.
نصب:
npm install zod
یک نمونه ساده:
import { z } from 'zod'
const contactSchema = z.object({
name: z.string().min(2),
email: z.email(),
message: z.string().min(10),
})
بعد میتوانیم داده را با آن بررسی کنیم:
const result = contactSchema.safeParse({
name: 'Matin',
email: 'matin@example.com',
message: 'Hello from contact form',
})
اگر داده معتبر باشد، result.success برابر true میشود.
اگر نامعتبر باشد، خطاها را در خروجی میگیریم.
در عمل، safeParse() برای فرمها انتخاب بهتری است، چون بهجای throw کردن، یک نتیجه قابل کنترل برمیگرداند.
ساخت schema برای فرم تماس
فرم تماس معمولاً فیلدهای کمی دارد، اما همین فرم ساده هم باید اعتبارسنجی مشخصی داشته باشد.
مثلاً:
- نام نباید خالی باشد
- ایمیل باید معتبر باشد
- پیام نباید خیلی کوتاه باشد
میتوانیم schema را اینطور تعریف کنیم:
import { z } from 'zod'
export const contactFormSchema = z.object({
name: z
.string()
.trim()
.min(2, 'نام باید حداقل 2 کاراکتر باشد.')
.max(100, 'نام نباید بیشتر از 100 کاراکتر باشد.'),
email: z
.email('ایمیل معتبر نیست.')
.trim()
.toLowerCase(),
message: z
.string()
.trim()
.min(10, 'پیام باید حداقل 10 کاراکتر باشد.')
.max(2000, 'پیام نباید بیشتر از 2000 کاراکتر باشد.'),
})
و type آن را هم از همان schema بگیریم:
export type ContactFormInput = z.infer<typeof contactFormSchema>
این الگو دو مزیت مهم دارد:
- تعریف داده و
validationدر یک جا قرار میگیرد typeفرم ازschemaجدا نمیشود وmismatchایجاد نمیکند
اگر بخواهی این schema را در ساختار پروژه تمیز نگه داری، مسیر زیر مناسب است:
lib/validations/contact.ts
ساخت schema برای پروژه
فرم ایجاد پروژه معمولاً از فرم تماس پیچیدهتر است و فیلدهای بیشتری دارد.
در یک پرتفولیوی واقعی، ممکن است این فیلدها را داشته باشیم:
titleslugdescriptioncontenttechnologiesdemoUrlgithubUrlpublished
یک schema مناسب میتواند اینطور باشد:
import { z } from 'zod'
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export const projectSchema = z.object({
title: z
.string()
.trim()
.min(3, 'عنوان پروژه باید حداقل 3 کاراکتر باشد.')
.max(120, 'عنوان پروژه نباید بیشتر از 120 کاراکتر باشد.'),
slug: z
.string()
.trim()
.min(3, 'slug باید حداقل 3 کاراکتر باشد.')
.max(160, 'slug نباید بیشتر از 160 کاراکتر باشد.')
.regex(slugRegex, 'slug فقط باید شامل حروف کوچک انگلیسی، عدد و - باشد.'),
description: z
.string()
.trim()
.min(10, 'توضیح کوتاه باید حداقل 10 کاراکتر باشد.')
.max(300, 'توضیح کوتاه نباید بیشتر از 300 کاراکتر باشد.'),
content: z
.string()
.trim()
.min(20, 'محتوای پروژه باید حداقل 20 کاراکتر باشد.'),
technologies: z
.array(z.string().trim().min(1))
.min(1, 'حداقل یک تکنولوژی وارد کنید.'),
demoUrl: z
.union([z.url('لینک دمو معتبر نیست.'), z.literal('')])
.transform((value) => value || null),
githubUrl: z
.union([z.url('لینک گیتهاب معتبر نیست.'), z.literal('')])
.transform((value) => value || null),
published: z.boolean().default(false),
})
چند نکته در این schema مهم است:
اول، slug با یک regex مشخص اعتبارسنجی میشود تا فرمت URL خراب نشود.
دوم، برای demoUrl و githubUrl فرض کردهایم که این فیلدها اختیاری هستند. اگر کاربر آنها را خالی بگذارد، با transform() میتوانیم مقدار خالی را به null تبدیل کنیم تا با مدل دیتابیس هماهنگتر شود.
سوم، technologies را بهصورت آرایه تعریف کردهایم.
این یعنی اگر در UI آن را بهصورت یک input متنی میگیریم، قبل از validation یا در یک مرحله transform باید آن را به آرایه تبدیل کنیم.
مثلاً اگر کاربر این را وارد کند:
Next.js, Prisma, PostgreSQL, Tailwind CSS
میتوانیم آن را اینطور normalize کنیم:
const technologies = rawValue
.split(',')
.map((item) => item.trim())
.filter(Boolean)
ساخت schema برای مقاله
برای فرم مقاله هم میتوانیم schema جداگانهای تعریف کنیم.
این فرم معمولاً به فیلدهایی مثل عنوان، slug، خلاصه، محتوا و وضعیت انتشار نیاز دارد.
import { z } from 'zod'
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export const postSchema = z.object({
title: z
.string()
.trim()
.min(3, 'عنوان مقاله باید حداقل 3 کاراکتر باشد.')
.max(160, 'عنوان مقاله نباید بیشتر از 160 کاراکتر باشد.'),
slug: z
.string()
.trim()
.min(3, 'slug باید حداقل 3 کاراکتر باشد.')
.max(160, 'slug نباید بیشتر از 160 کاراکتر باشد.')
.regex(slugRegex, 'slug فقط باید شامل حروف کوچک انگلیسی، عدد و - باشد.'),
excerpt: z
.string()
.trim()
.min(10, 'خلاصه مقاله باید حداقل 10 کاراکتر باشد.')
.max(300, 'خلاصه مقاله نباید بیشتر از 300 کاراکتر باشد.'),
content: z
.string()
.trim()
.min(50, 'محتوای مقاله باید حداقل 50 کاراکتر باشد.'),
published: z.boolean().default(false),
})
این جداسازی مهم است، چون هر فرم قواعد خودش را دارد و بهتر است schema هر بخش بهصورت مستقل تعریف شود.
مدیریت خطاها
تعریف schema فقط نیمی از کار است. بخش مهم بعدی این است که خطاها را به شکلی قابل استفاده به UI برگردانیم.
معمولاً برای فرمها از safeParse() استفاده میکنیم:
const result = projectSchema.safeParse(formValues)
اگر داده معتبر باشد:
result.success === true
و اگر نامعتبر باشد:
result.success === false
در حالت نامعتبر، Zod جزئیات خطاها را داخل result.error برمیگرداند.
برای اینکه این خطاها را راحتتر در فرم نمایش دهیم، میتوانیم از flatten() استفاده کنیم:
const result = projectSchema.safeParse(formValues)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
return {
success: false,
errors,
}
}
خروجی fieldErrors معمولاً ساختاری شبیه این دارد:
{
title: ['عنوان پروژه باید حداقل 3 کاراکتر باشد.'],
slug: ['slug معتبر نیست.'],
demoUrl: ['لینک دمو معتبر نیست.']
}
این ساختار برای فرمها بسیار مناسب است، چون میتوانی خطای هر فیلد را دقیقاً زیر همان input نمایش دهی.
مثلاً در UI:
{errors.title?.[0] && (
<p className="text-sm text-red-400">{errors.title[0]}</p>
)}
این الگو باعث میشود ارتباط بین schema و UI واضح بماند.
یعنی منطق اعتبارسنجی داخل schema است، اما نمایش خطا در خود فرم انجام میشود.
یک helper ساده برای خواندن داده فرم
در بسیاری از مواقع، داده فرم در همان شکلی که کاربر ارسال کرده برای validation مناسب نیست.
مثلاً یک checkbox باید به boolean تبدیل شود، یا یک input متنی باید به آرایه تبدیل شود.
برای همین بهتر است قبل از safeParse() یک مرحله normalize داشته باشیم.
مثلاً:
export function getProjectInput(formData: FormData) {
return {
title: String(formData.get('title') ?? ''),
slug: String(formData.get('slug') ?? ''),
description: String(formData.get('description') ?? ''),
content: String(formData.get('content') ?? ''),
technologies: String(formData.get('technologies') ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
demoUrl: String(formData.get('demoUrl') ?? ''),
githubUrl: String(formData.get('githubUrl') ?? ''),
published: formData.get('published') === 'on',
}
}
بعد از آن میتوانیم همین داده را به schema بدهیم:
const input = getProjectInput(formData)
const result = projectSchema.safeParse(input)
این جداسازی مهم است، چون:
- فرم داده خام میفرستد
- داده را
normalizeمیکنیم Zodداده نهایی راvalidateمیکند
در نتیجه کد خواناتر و قابل نگهداریتر میشود.
چرا safeParse از parse بهتر است؟
parse() در صورت نامعتبر بودن داده خطا throw میکند.
اما در فرمها معمولاً ترجیح میدهیم بهجای متوقف شدن اجرای کد، یک خروجی قابل کنترل داشته باشیم.
برای همین safeParse() در بیشتر سناریوهای فرم انتخاب بهتری است:
- کنترل راحتتر روی خطاها
- مناسب برای بازگرداندن خطا به
UI - بدون نیاز به
try/catchدر حالتهای ساده - قابل استفاده در لایههای مختلف پروژه
پس برای پردازش فرمها، safeParse() معمولاً گزینه استانداردتری است.
محل نگهداری schemaها در پروژه
برای اینکه validationها در پروژه پراکنده نشوند، بهتر است schemaها را در یک مسیر مشخص نگه داریم.
مثلاً:
lib/validations/contact.ts
lib/validations/project.ts
lib/validations/post.ts
این ساختار چند مزیت دارد:
schemaها ازUIجدا میمانند- چند فرم یا چند مسیر مختلف میتوانند از یک
schemaمشترک استفاده کنند - تغییر قواعد
validationسادهتر میشود - پروژه با بزرگتر شدن شلوغ و نامنظم نمیشود
اگر خواستی، حتی میتوانی مقادیر مشترک مثل slugRegex یا helperهای مربوط به URLهای اختیاری را هم در فایلهای مشترکتر نگه داری.
جمعبندی
اعتبارسنجی فرم فقط یک مرحله جانبی نیست؛ بخشی از هسته ورود داده به سیستم است.
در این پست چند اصل مهم را دیدیم:
validationباید قبل از ورود داده به سیستم انجام شودZodراهی تمیز وtype-safeبرای تعریف قواعد اعتبارسنجی میدهدschemaها بهتر است ازUIجدا باشند- برای فرم تماس، پروژه و مقاله میتوان
schemaهای مستقل تعریف کرد safeParse()برای مدیریت کنترلشده خطاها در فرمها بسیار مناسب است- خطاهای
Zodرا میتوان به شکل قابل استفاده بهUIبرگرداند
با این ساختار، فرمهای پروژه فقط از نظر ظاهری کامل نیستند، بلکه از نظر صحت داده هم آماده ورود به مرحله بعدی هستند.