MATIN MOLKARA
SERIES: آموزش Next js — PART 15 OF 28

قسمت پانزدهم - اعتبارسنجی فرم با 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 برای پروژه

فرم ایجاد پروژه معمولاً از فرم تماس پیچیده‌تر است و فیلدهای بیشتری دارد.

در یک پرتفولیوی واقعی، ممکن است این فیلدها را داشته باشیم:

  • title
  • slug
  • description
  • content
  • technologies
  • demoUrl
  • githubUrl
  • published

یک 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 برگرداند

با این ساختار، فرم‌های پروژه فقط از نظر ظاهری کامل نیستند، بلکه از نظر صحت داده هم آماده ورود به مرحله بعدی هستند.

// 0 comments
#Next.js#Validation#ZOD#اموزش

نظرات (0)

نظر خود را بنویسید