واکشی داده، کشینگ و Server Actions در Next.js 15: راهنمای جامع

راهنمای عملی واکشی داده، مدل جدید کشینگ و Server Actions در Next.js 15. از الگوهای پایه تا use cache، آپدیت خوش‌بینانه با useOptimistic و Partial Prerendering با مثال‌های کاربردی.

مقدمه: چرا واکشی داده و کشینگ در Next.js 15 کاملا عوض شده

خب، بیاید رک و راست باشیم. اگه از Next.js 13 یا 14 به نسخه 15 مهاجرت کردید، احتمالا اولین چیزی که متوجه شدید این بوده که رفتار کشینگ کاملا عوض شده. توی نسخه‌های قبلی، fetch داخل Server Component‌ها به‌طور پیش‌فرض کش می‌شد و خیلی از توسعه‌دهنده‌ها (از جمله خود من!) ساعت‌ها وقت می‌ذاشتن تا بفهمن چرا دیتاشون آپدیت نمیشه.

حالا در Next.js 15، فلسفه عوض شده: هیچ‌چیز به‌طور پیش‌فرض کش نمیشه و شما باید صراحتا اعلام کنید که چه‌چیزی کش بشه. صادقانه بگم، این تصمیم خیلی عاقلانه بود.

این تغییر فقط یه آپدیت ساده نیست — یه تغییر بنیادین در نحوه فکر کردن به واکشی داده، کشینگ و mutation هاست. در کنارش، ابزارهای جدیدی مثل دستور "use cache"، هوک useActionState از React 19، و قابلیت Partial Prerendering (PPR) هم اضافه شدن. مجموعا یه اکوسیستم قدرتمند و انعطاف‌پذیر ایجاد کردن که واقعا کار باهاش لذت‌بخشه.

اگه مقاله قبلی ما درباره احراز هویت با Auth.js v5 رو خوندید، این مقاله مکمل اون محسوب میشه. اونجا یاد گرفتیم چطور کاربر رو احراز هویت کنیم، اینجا یاد می‌گیریم چطور داده‌ها رو به‌شکل بهینه واکشی کنیم، کش کنیم و با Server Actions تغییرات رو مدیریت کنیم.

در این راهنما، از واکشی داده در Server Component‌ها شروع می‌کنیم، مدل جدید کشینگ رو بررسی می‌کنیم، به Server Actions و ترکیبشون با کشینگ می‌رسیم، و در نهایت PPR و بهترین شیوه‌های عملکردی رو پوشش میدیم. آماده‌اید؟ بزن بریم!

واکشی داده در Server Components

یکی از بزرگ‌ترین مزایای App Router در Next.js اینه که Server Component‌ها می‌تونن مستقیما داده واکشی کنن. دیگه خبری از useEffect و useState برای لود کردن داده نیست. دیگه نیازی به اسپینر لودینگ اولیه روی کلاینت نیست.

کامپوننت شما یه تابع async هست که مستقیما از دیتابیس یا API داده می‌گیره و HTML رندر شده رو به کلاینت می‌فرسته. همین.

الگوی پایه: async/await در کامپوننت

ساده‌ترین حالت واکشی داده در Server Component اینطوری هست:

// app/blog/page.tsx

// تابع واکشی داده از API
async function getPosts() {
  const res = await fetch('https://api.example.com/posts')

  if (!res.ok) {
    throw new Error('خطا در دریافت پست‌ها')
  }

  return res.json()
}

// کامپوننت سرور — مستقیما async هست
export default async function BlogPage() {
  const posts = await getPosts()

  return (
    <div>
      <h1>وبلاگ</h1>
      <ul>
        {posts.map((post: any) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

ببینید چقدر ساده‌ست! هیچ useEffectای نیست. هیچ state loading نیست. کامپوننت مستقیما داده رو می‌گیره و رندر می‌کنه. این داده روی سرور واکشی میشه، بنابراین کلاینت هیچ‌وقت درخواست اضافه‌ای نمی‌فرسته و SEO هم کامل پوشش داده میشه.

واکشی موازی با Promise.all

فرض کنید یه صفحه دارید که هم پست‌های وبلاگ و هم اطلاعات کاربر رو نشون میده. اگه این دو درخواست رو پشت سر هم بنویسید، یه waterfall ایجاد میشه — یعنی درخواست دوم منتظر تموم شدن اولی میمونه. این اشتباهیه که خیلیا مرتکب میشن:

// ❌ اشتباه: واکشی متوالی — waterfall ایجاد میشه
export default async function DashboardPage() {
  const posts = await getPosts()       // 500ms صبر
  const user = await getUser()         // 300ms صبر دیگه
  // جمعا: 800ms

  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
    </div>
  )
}

راه‌حل درست؟ استفاده از Promise.all برای اجرای موازی:

// ✅ درست: واکشی موازی — هر دو همزمان اجرا میشن
async function getPosts() {
  const res = await fetch('https://api.example.com/posts')
  if (!res.ok) throw new Error('خطا در دریافت پست‌ها')
  return res.json()
}

async function getUser() {
  const res = await fetch('https://api.example.com/user/me')
  if (!res.ok) throw new Error('خطا در دریافت اطلاعات کاربر')
  return res.json()
}

export default async function DashboardPage() {
  // هر دو درخواست همزمان شروع میشن
  const [posts, user] = await Promise.all([
    getPosts(),
    getUser(),
  ])
  // جمعا: max(500ms, 300ms) = 500ms

  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
    </div>
  )
}

با این تغییر ساده، زمان لود صفحه از 800 میلی‌ثانیه به 500 میلی‌ثانیه رسید. شاید بگید «خب فقط 300 میلی‌ثانیه فرق کرده» ولی وقتی چندین API call دارید، این فرق‌ها روی هم جمع میشن و تاثیرشون واقعا محسوسه.

چه وقت از واکشی متوالی استفاده کنیم؟

البته همیشه واکشی موازی بهتر نیست. گاهی نتیجه درخواست دوم به نتیجه اولی وابسته‌ست. مثلا اول باید ID کاربر رو بگیرید و بعد پست‌های اون کاربر رو واکشی کنید:

// واکشی متوالی — وقتی وابستگی وجود داره
export default async function UserPostsPage({
  params,
}: {
  params: Promise<{ userId: string }>
}) {
  const { userId } = await params

  // اول اطلاعات کاربر رو می‌گیریم
  const user = await getUser(userId)

  // بعد پست‌های همون کاربر رو واکشی می‌کنیم
  const posts = await getUserPosts(user.id)

  return (
    <div>
      <h1>پست‌های {user.name}</h1>
      {posts.map((post: any) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}
    </div>
  )
}

قاعده کلی: درخواست‌ها مستقل هستن؟ موازی بفرستید. وابستگی دارن؟ متوالی. تمام!

مدل جدید کشینگ در Next.js 15

خب، رسیدیم به مهم‌ترین تغییر Next.js 15. بذارید خیلی واضح بگم: در Next.js 15، تابع fetch به‌طور پیش‌فرض کش نمیشه.

این یه تغییر بزرگ نسبت به نسخه 14 هست. توی نسخه قبلی، هر fetch داخل Server Component به‌طور خودکار کش می‌شد و راستش خیلی از توسعه‌دهنده‌ها رو گیج کرده بود. من خودم چند بار سرم به دیوار خورده تا فهمیدم چرا داده‌هام آپدیت نمیشن!

فعال‌سازی صریح کشینگ با force-cache

اگه می‌خواید یه درخواست کش بشه، باید صراحتا بگید:

// ❌ بدون کش — رفتار پیش‌فرض در Next.js 15
const res = await fetch('https://api.example.com/posts')

// ✅ کش نامحدود — تا زمانی که صراحتا invalidate نشه
const res = await fetch('https://api.example.com/posts', {
  cache: 'force-cache',
})

// ✅ کش با بازاعتبارسنجی زمانی — هر یک ساعت
const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 },
})

این رویکرد «opt-in» خیلی بهتره. الان دقیقا می‌دونید چه‌چیزی کش میشه و چه‌چیزی نه. دیگه خبری از رفتارهای غیرمنتظره نیست و این یه قدم بزرگ به جلوئه.

بازاعتبارسنجی زمانی (Time-based Revalidation)

رایج‌ترین الگوی کشینگ در عمل، بازاعتبارسنجی زمانیه. یعنی داده رو کش می‌کنید و بعد از یه مدت مشخص، Next.js در پس‌زمینه داده جدید رو می‌گیره:

// app/products/page.tsx

async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    // هر 5 دقیقه بازاعتبارسنجی کن
    next: { revalidate: 300 },
  })
  return res.json()
}

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <div>
      <h1>محصولات</h1>
      <div className="grid grid-cols-3 gap-4">
        {products.map((product: any) => (
          <div key={product.id} className="border p-4 rounded">
            <h3>{product.name}</h3>
            <p>{product.price.toLocaleString('fa-IR')} تومان</p>
          </div>
        ))}
      </div>
    </div>
  )
}

این الگو از استراتژی stale-while-revalidate استفاده می‌کنه: تا وقتی 300 ثانیه نگذشته، داده کش‌شده برگردونده میشه. بعد از 300 ثانیه، ابتدا داده قدیمی برگردونده میشه (برای سرعت) و در پس‌زمینه داده جدید واکشی میشه. درخواست بعدی داده تازه رو دریافت می‌کنه. ساده و هوشمندانه.

بازاعتبارسنجی بر اساس تگ (Tag-based Revalidation)

خب حالا اگه نخواید منتظر زمان باشید چی؟ فرض کنید کاربر یه محصول رو ویرایش کرده و شما می‌خواید همون لحظه کش پاک بشه. اینجاست که تگ‌ها وارد بازی میشن:

// واکشی با تگ
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { tags: [`product-${id}`, 'products'] },
  })
  return res.json()
}

// بعدا در یک Server Action:
import { revalidateTag } from 'next/cache'

async function updateProduct(id: string, data: any) {
  'use server'
  await db.product.update({ where: { id }, data })
  // فقط کش این محصول خاص رو پاک کن
  revalidateTag(`product-${id}`)
}

// یا کل لیست محصولات رو بازاعتبارسنجی کن
async function invalidateAllProducts() {
  'use server'
  revalidateTag('products')
}

همچنین می‌تونید با revalidatePath کش یه مسیر خاص رو پاک کنید:

import { revalidatePath } from 'next/cache'

// بازاعتبارسنجی یک صفحه خاص
revalidatePath('/products')

// بازاعتبارسنجی یک صفحه داینامیک
revalidatePath(`/products/${id}`)

// بازاعتبارسنجی یک layout و تمام صفحات زیرمجموعه
revalidatePath('/dashboard', 'layout')

دستور "use cache" و dynamicIO

Next.js 15 یه قابلیت آزمایشی فوق‌العاده جالب معرفی کرده: دستور "use cache". این دستور بهتون اجازه میده هر تابع، کامپوننت یا حتی کل یه فایل رو به‌راحتی کش کنید — بدون اینکه حتما از fetch استفاده کنید.

این یعنی چی؟ یعنی می‌تونید کوئری‌های مستقیم دیتابیس، محاسبات سنگین، یا هر عملیات async دیگه‌ای رو هم کش کنید. و باور کنید این قابلیت خیلی بیشتر از چیزیه که در نگاه اول به نظر میاد.

فعال‌سازی dynamicIO

قبل از هر چیز، باید dynamicIO رو در تنظیمات Next.js فعال کنید:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    dynamicIO: true,
  },
}

export default nextConfig

استفاده در سطح تابع

ساده‌ترین حالت استفاده از "use cache" در سطح یه تابع هست:

// lib/data.ts

import { cacheTag, cacheLife } from 'next/cache'

// کش کردن یک کوئری دیتابیس
async function getCategories() {
  'use cache'
  cacheLife('hours')           // پروفایل: چند ساعت معتبره
  cacheTag('categories')       // تگ: برای بازاعتبارسنجی هدفمند

  // این کوئری مستقیم از دیتابیس هست، نه fetch
  const categories = await db.category.findMany({
    where: { active: true },
    orderBy: { name: 'asc' },
  })

  return categories
}

// کش کردن یک محاسبه سنگین
async function getDashboardStats() {
  'use cache'
  cacheLife('minutes')
  cacheTag('dashboard-stats')

  const [totalUsers, totalOrders, revenue] = await Promise.all([
    db.user.count(),
    db.order.count({ where: { status: 'completed' } }),
    db.order.aggregate({
      where: { status: 'completed' },
      _sum: { amount: true },
    }),
  ])

  return {
    totalUsers,
    totalOrders,
    revenue: revenue._sum.amount ?? 0,
  }
}

استفاده در سطح کامپوننت

می‌تونید "use cache" رو مستقیما در یه کامپوننت سرور هم بذارید. بله، درست شنیدید — کل خروجی کامپوننت کش میشه:

// app/components/Sidebar.tsx

async function Sidebar() {
  'use cache'
  cacheLife('days')
  cacheTag('sidebar')

  const categories = await db.category.findMany()
  const recentPosts = await db.post.findMany({
    take: 5,
    orderBy: { createdAt: 'desc' },
  })

  return (
    <aside>
      <h3>دسته‌بندی‌ها</h3>
      <ul>
        {categories.map((cat: any) => (
          <li key={cat.id}>{cat.name}</li>
        ))}
      </ul>
      <h3>آخرین پست‌ها</h3>
      <ul>
        {recentPosts.map((post: any) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </aside>
  )
}

export default Sidebar

پروفایل‌های cacheLife

تابع cacheLife پروفایل‌های از پیش تعریف‌شده‌ای داره:

  • 'seconds' — کش برای چند ثانیه (مناسب داده‌های خیلی پویا)
  • 'minutes' — کش برای چند دقیقه (مناسب داشبوردها)
  • 'hours' — کش برای چند ساعت (مناسب لیست‌ها و دسته‌بندی‌ها)
  • 'days' — کش برای چند روز (مناسب محتوای نسبتا ثابت)
  • 'weeks' — کش برای چند هفته (مناسب محتوای خیلی ثابت)
  • 'max' — کش نامحدود

ولی اگه این پروفایل‌های پیش‌فرض کافی نیستن، می‌تونید پروفایل سفارشی هم تعریف کنید:

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    dynamicIO: true,
    cacheLife: {
      // تعریف پروفایل سفارشی
      'product-listing': {
        stale: 300,       // 5 دقیقه — بعد از این stale میشه
        revalidate: 600,  // 10 دقیقه — بعد از این revalidate میشه
        expire: 3600,     // 1 ساعت — بعد از این کاملا حذف میشه
      },
    },
  },
}

و بعد ازش استفاده کنید:

async function getProductListing() {
  'use cache'
  cacheLife('product-listing')
  cacheTag('products')

  return await db.product.findMany({
    where: { published: true },
  })
}

بررسی عمیق Server Actions

Server Actions یکی از قدرتمندترین قابلیت‌های Next.js هستن و به نظر من یکی از بهترین ایده‌هایی بوده که تیم Next.js پیاده کرده. به زبون ساده، اینا توابع async هستن که روی سرور اجرا میشن و می‌تونید مستقیما از فرم‌ها یا کد کلاینت صداشون بزنید.

دیگه نیازی به ساختن API Route جداگانه نیست — Next.js خودش یه endpoint HTTP POST براتون می‌سازه.

تعریف Server Action

دو روش برای تعریف Server Action وجود داره:

// روش اول: فایل جداگانه (توصیه‌شده برای استفاده مجدد)
// app/actions/posts.ts
'use server'

import { db } from '@/lib/db'
import { revalidateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  await db.post.create({
    data: { title, content },
  })

  revalidateTag('posts')
}

// روش دوم: inline داخل Server Component
export default async function Page() {
  async function deletePost(formData: FormData) {
    'use server'
    const id = formData.get('id') as string
    await db.post.delete({ where: { id } })
    revalidateTag('posts')
  }

  return (
    <form action={deletePost}>
      <input type="hidden" name="id" value="123" />
      <button type="submit">حذف</button>
    </form>
  )
}

هوک useActionState از React 19

یکی از بهترین اضافه‌های React 19 هوک useActionState هست (که جایگزین useFormState قبلی شده). این هوک بهتون اجازه میده وضعیت فرم — شامل خطاها، پیام موفقیت و حالت لودینگ — رو به‌خوبی مدیریت کنید. راستش قبلا مدیریت state فرم‌ها با Server Actions یکم دردسرساز بود، ولی این هوک کار رو خیلی راحت‌تر کرده.

// app/actions/contact.ts
'use server'

import { z } from 'zod'

// اسکیمای اعتبارسنجی با Zod
const ContactSchema = z.object({
  name: z.string().min(2, 'نام باید حداقل ۲ کاراکتر باشد'),
  email: z.string().email('ایمیل معتبر نیست'),
  message: z.string().min(10, 'پیام باید حداقل ۱۰ کاراکتر باشد'),
})

export type ContactFormState = {
  errors?: {
    name?: string[]
    email?: string[]
    message?: string[]
  }
  message?: string
  success?: boolean
}

export async function submitContact(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  // اعتبارسنجی داده‌ها
  const validatedFields = ContactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  })

  // اگه اعتبارسنجی رد شد
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'لطفا خطاهای فرم را برطرف کنید.',
      success: false,
    }
  }

  try {
    // ذخیره در دیتابیس
    await db.contact.create({
      data: validatedFields.data,
    })

    return {
      message: 'پیام شما با موفقیت ارسال شد!',
      success: true,
    }
  } catch (error) {
    return {
      message: 'خطا در ارسال پیام. لطفا دوباره تلاش کنید.',
      success: false,
    }
  }
}

و حالا کامپوننت کلاینت که از این Server Action استفاده می‌کنه:

// app/contact/ContactForm.tsx
'use client'

import { useActionState } from 'react'
import { submitContact, type ContactFormState } from '@/app/actions/contact'

const initialState: ContactFormState = {
  errors: {},
  message: '',
  success: false,
}

export default function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    submitContact,
    initialState
  )

  return (
    <form action={formAction} className="space-y-4">
      {/* نمایش پیام کلی */}
      {state.message && (
        <div className={state.success ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </div>
      )}

      {/* فیلد نام */}
      <div>
        <label htmlFor="name">نام</label>
        <input
          id="name"
          name="name"
          type="text"
          className="border rounded p-2 w-full"
        />
        {state.errors?.name && (
          <p className="text-red-500 text-sm">{state.errors.name[0]}</p>
        )}
      </div>

      {/* فیلد ایمیل */}
      <div>
        <label htmlFor="email">ایمیل</label>
        <input
          id="email"
          name="email"
          type="email"
          className="border rounded p-2 w-full"
        />
        {state.errors?.email && (
          <p className="text-red-500 text-sm">{state.errors.email[0]}</p>
        )}
      </div>

      {/* فیلد پیام */}
      <div>
        <label htmlFor="message">پیام</label>
        <textarea
          id="message"
          name="message"
          rows={4}
          className="border rounded p-2 w-full"
        />
        {state.errors?.message && (
          <p className="text-red-500 text-sm">{state.errors.message[0]}</p>
        )}
      </div>

      {/* دکمه ارسال */}
      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
      >
        {isPending ? 'در حال ارسال...' : 'ارسال پیام'}
      </button>
    </form>
  )
}

یه نکته خیلی مهم اینجا وجود داره: progressive enhancement. فرم بالا حتی بدون جاوااسکریپت هم کار می‌کنه! وقتی جاوااسکریپت غیرفعاله، مرورگر فرم رو به‌صورت POST استاندارد ارسال می‌کنه. وقتی فعال باشه، Next.js درخواست رو بهینه‌تر و بدون رفرش صفحه می‌فرسته. یعنی بهترین حالت هر دو دنیا رو دارید.

اعتبارسنجی با Zod

توی مثال بالا از Zod برای اعتبارسنجی استفاده کردیم و این خیلی مهمه. چرا؟ چون Server Actions عملا endpoint‌های HTTP POST عمومی هستن. هر کسی می‌تونه با ابزاری مثل curl یا Postman بهشون درخواست بفرسته.

پس حتما باید روی سرور اعتبارسنجی کنید. هرگز — و تاکید می‌کنم هرگز — فقط به اعتبارسنجی سمت کلاینت اکتفا نکنید.

ترکیب کشینگ و Server Actions

خب، اینجاست که واقعا اوضاع جالب میشه. ترکیب Server Actions با سیستم کشینگ یکی از قدرتمندترین الگوهایی هست که در Next.js 15 می‌تونید ازش استفاده کنید. ایده ساده‌ست: وقتی کاربر یه فرم رو submit می‌کنه و Server Action اجرا میشه، کش مرتبط رو بازاعتبارسنجی می‌کنید تا UI فورا آپدیت بشه.

سیستم کامنت با بازاعتبارسنجی

بیاید یه مثال عملی ببینیم — یه سیستم کامنت‌گذاری که احتمالا توی خیلی از پروژه‌ها بهش نیاز پیدا می‌کنید:

// app/actions/comments.ts
'use server'

import { db } from '@/lib/db'
import { revalidateTag } from 'next/cache'
import { z } from 'zod'
import { auth } from '@/lib/auth'

const CommentSchema = z.object({
  content: z.string().min(1, 'متن کامنت نمی‌تواند خالی باشد').max(1000),
  postId: z.string(),
})

export async function addComment(
  prevState: any,
  formData: FormData
) {
  // بررسی احراز هویت — همیشه!
  const session = await auth()
  if (!session?.user) {
    return { error: 'برای ارسال نظر باید وارد شوید.' }
  }

  const validated = CommentSchema.safeParse({
    content: formData.get('content'),
    postId: formData.get('postId'),
  })

  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors.content?.[0] }
  }

  try {
    await db.comment.create({
      data: {
        content: validated.data.content,
        postId: validated.data.postId,
        authorId: session.user.id,
      },
    })

    // بازاعتبارسنجی کش کامنت‌های این پست
    revalidateTag(`comments-${validated.data.postId}`)

    return { success: true }
  } catch {
    return { error: 'خطا در ثبت نظر.' }
  }
}

آپدیت خوش‌بینانه با useOptimistic

یکی از بهترین قابلیت‌های React 19 هوک useOptimistic هست. ایده‌ش اینه که قبل از اینکه پاسخ سرور برسه، UI رو آپدیت کنید تا کاربر احساس کنه اپلیکیشن خیلی سریع و responsive هست. توی عمل، این تکنیک واقعا تفاوت چشمگیری در تجربه کاربری ایجاد می‌کنه:

// app/blog/[id]/CommentSection.tsx
'use client'

import { useOptimistic, useActionState, useRef } from 'react'
import { addComment } from '@/app/actions/comments'

type Comment = {
  id: string
  content: string
  author: { name: string }
  createdAt: string
}

export default function CommentSection({
  comments,
  postId,
  currentUser,
}: {
  comments: Comment[]
  postId: string
  currentUser: { name: string } | null
}) {
  const formRef = useRef<HTMLFormElement>(null)

  // مدیریت آپدیت خوش‌بینانه
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state: Comment[], newComment: Comment) => [newComment, ...state]
  )

  const [state, formAction] = useActionState(
    async (prevState: any, formData: FormData) => {
      // اضافه کردن کامنت خوش‌بینانه به UI
      addOptimisticComment({
        id: `temp-${Date.now()}`,
        content: formData.get('content') as string,
        author: { name: currentUser?.name ?? 'شما' },
        createdAt: new Date().toISOString(),
      })

      // ارسال واقعی به سرور
      const result = await addComment(prevState, formData)

      // پاک کردن فرم در صورت موفقیت
      if (result.success) {
        formRef.current?.reset()
      }

      return result
    },
    { error: null, success: false }
  )

  return (
    <div className="space-y-6">
      <h3>نظرات ({optimisticComments.length})</h3>

      {/* فرم ارسال نظر */}
      {currentUser && (
        <form ref={formRef} action={formAction} className="space-y-2">
          <input type="hidden" name="postId" value={postId} />
          <textarea
            name="content"
            placeholder="نظر خود را بنویسید..."
            className="w-full border rounded p-2"
            rows={3}
          />
          {state.error && (
            <p className="text-red-500 text-sm">{state.error}</p>
          )}
          <button
            type="submit"
            className="bg-blue-600 text-white px-4 py-2 rounded"
          >
            ارسال نظر
          </button>
        </form>
      )}

      {/* لیست کامنت‌ها */}
      <div className="space-y-4">
        {optimisticComments.map((comment) => (
          <div
            key={comment.id}
            className={`p-4 border rounded ${
              comment.id.startsWith('temp-') ? 'opacity-60' : ''
            }`}
          >
            <p className="font-bold">{comment.author.name}</p>
            <p>{comment.content}</p>
            <time className="text-sm text-gray-500">
              {new Date(comment.createdAt).toLocaleDateString('fa-IR')}
            </time>
          </div>
        ))}
      </div>
    </div>
  )
}

ببینید چه اتفاقی میفته: وقتی کاربر «ارسال نظر» رو می‌زنه، فورا کامنت جدید در لیست ظاهر میشه (با opacity کمتر برای نشون دادن وضعیت pending). همزمان درخواست واقعی به سرور ارسال میشه. اگه موفق بشه، کامنت واقعی جایگزین میشه. اگه شکست بخوره، کامنت خوش‌بینانه حذف میشه و خطا نمایش داده میشه.

تجربه کاربری فوق‌العاده‌ای ایجاد می‌کنه، مگه نه؟

پیش‌رندر جزئی (Partial Prerendering - PPR)

PPR یکی از هیجان‌انگیزترین قابلیت‌های جدید Next.js هست و وقتی اولین بار ازش شنیدم، راستش یکم بهش شک داشتم. ولی بعد از تستش باید بگم واقعا کار می‌کنه و نتیجه‌ش عالیه.

ایده اصلی ساده‌ست: یه صفحه می‌تونه هم‌زمان بخش‌های استاتیک و داینامیک داشته باشه. بخش‌های استاتیک فورا به کاربر نمایش داده میشن و بخش‌های داینامیک به‌صورت streaming لود میشن.

PPR چطور کار می‌کنه؟

فرض کنید یه صفحه داشبورد دارید. هدر، سایدبار و ساختار کلی صفحه استاتیک هستن — تغییر نمی‌کنن. ولی تعداد کاربران آنلاین، آخرین سفارشات و نمودار فروش داینامیک هستن و به داده بلادرنگ نیاز دارن.

بدون PPR، کل صفحه باید داینامیک باشه. با PPR، بخش‌های استاتیک فورا لود میشن و بخش‌های داینامیک بعدا stream میشن. نتیجه؟ صفحه خیلی سریع‌تر لود میشه.

برای فعال‌سازی PPR:

// next.config.ts
const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
}

و حالا مثال عملی:

// app/dashboard/page.tsx
import { Suspense } from 'react'

// بخش‌های استاتیک — فورا رندر میشن
function DashboardHeader() {
  return (
    <header className="border-b p-4">
      <h1>داشبورد مدیریت</h1>
      <nav>
        <a href="/dashboard">خانه</a>
        <a href="/dashboard/orders">سفارشات</a>
        <a href="/dashboard/products">محصولات</a>
      </nav>
    </header>
  )
}

// بخش‌های داینامیک — داده بلادرنگ نیاز دارن
async function RecentOrders() {
  const orders = await db.order.findMany({
    take: 10,
    orderBy: { createdAt: 'desc' },
    include: { user: true },
  })

  return (
    <div className="bg-white rounded-lg shadow p-4">
      <h2>آخرین سفارشات</h2>
      <table>
        <thead>
          <tr>
            <th>شناسه</th>
            <th>مشتری</th>
            <th>مبلغ</th>
            <th>وضعیت</th>
          </tr>
        </thead>
        <tbody>
          {orders.map((order: any) => (
            <tr key={order.id}>
              <td>{order.id}</td>
              <td>{order.user.name}</td>
              <td>{order.amount.toLocaleString('fa-IR')} تومان</td>
              <td>{order.status}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

async function DashboardStats() {
  const stats = await getDashboardStats()

  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="bg-blue-50 rounded-lg p-4">
        <p className="text-sm text-gray-600">کل کاربران</p>
        <p className="text-2xl font-bold">
          {stats.totalUsers.toLocaleString('fa-IR')}
        </p>
      </div>
      <div className="bg-green-50 rounded-lg p-4">
        <p className="text-sm text-gray-600">سفارشات تکمیل‌شده</p>
        <p className="text-2xl font-bold">
          {stats.totalOrders.toLocaleString('fa-IR')}
        </p>
      </div>
      <div className="bg-purple-50 rounded-lg p-4">
        <p className="text-sm text-gray-600">درآمد کل</p>
        <p className="text-2xl font-bold">
          {stats.revenue.toLocaleString('fa-IR')} تومان
        </p>
      </div>
    </div>
  )
}

// صفحه اصلی — ترکیب استاتیک و داینامیک
export default function DashboardPage() {
  return (
    <div>
      {/* استاتیک — فورا نمایش داده میشه */}
      <DashboardHeader />

      <main className="p-6 space-y-6">
        {/* داینامیک — با Suspense stream میشه */}
        <Suspense
          fallback={
            <div className="grid grid-cols-3 gap-4">
              {[1, 2, 3].map((i) => (
                <div key={i} className="h-24 bg-gray-100 rounded-lg animate-pulse" />
              ))}
            </div>
          }
        >
          <DashboardStats />
        </Suspense>

        <Suspense
          fallback={
            <div className="h-64 bg-gray-100 rounded-lg animate-pulse" />
          }
        >
          <RecentOrders />
        </Suspense>
      </main>
    </div>
  )
}

توی این مثال، DashboardHeader فورا نمایش داده میشه چون استاتیکه. DashboardStats و RecentOrders هر کدوم داخل Suspense هستن و مستقل از هم stream میشن.

یعنی اگه واکشی آمار 200 میلی‌ثانیه و واکشی سفارشات 500 میلی‌ثانیه طول بکشه، آمار زودتر نمایش داده میشه. کاربر لازم نیست منتظر هر دو بمونه و این دقیقا همون تجربه سریعی هست که همه دنبالش هستیم.

بهترین شیوه‌های عملکردی

خب، حالا که همه ابزارها رو یاد گرفتیم، وقتشه یه جمع‌بندی از بهترین شیوه‌ها داشته باشیم. این بخش رو حتما بوکمارک کنید چون بعدا بهش نیاز پیدا می‌کنید!

۱. از waterfall اجتناب کنید

هر جا که درخواست‌ها مستقل هستن، از Promise.all استفاده کنید. این ساده‌ترین و در عین حال موثرترین بهبود عملکردیه که می‌تونید انجام بدید.

۲. از Streaming و Suspense هوشمندانه استفاده کنید

هر بخش از صفحه که داده داینامیک داره رو داخل Suspense بذارید. از loading.tsx برای سطح صفحه و از Suspense مستقیم برای بخش‌های جزئی‌تر استفاده کنید:

// app/blog/loading.tsx — لودینگ سطح صفحه
export default function Loading() {
  return (
    <div className="space-y-4">
      {[1, 2, 3, 4, 5].map((i) => (
        <div
          key={i}
          className="h-32 bg-gray-100 rounded-lg animate-pulse"
        />
      ))}
    </div>
  )
}

۳. Server Actions رو همیشه اعتبارسنجی کنید

این رو هر چقدر تکرار کنم بازم کمه: Server Actions endpoint‌های عمومی هستن. هر کسی می‌تونه بهشون درخواست بفرسته. پس:

  • همیشه ورودی‌ها رو با Zod یا ابزار مشابه اعتبارسنجی کنید
  • همیشه احراز هویت کاربر رو بررسی کنید
  • همیشه مجوز دسترسی رو چک کنید (آیا این کاربر حق انجام این عمل رو داره؟)
  • هرگز فقط به اعتبارسنجی سمت کلاینت اکتفا نکنید

۴. next-safe-action برای Server Actions تایپ‌سیف

اگه پروژه‌تون بزرگه و Server Actions زیادی دارید، کتابخانه next-safe-action رو حتما بررسی کنید. یه لایه تایپ‌سیفتی و اعتبارسنجی خودکار روی Server Actions اضافه می‌کنه و واقعا زندگی رو راحت‌تر می‌کنه:

// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'

// ساخت کلاینت پایه
export const actionClient = createSafeActionClient()

// کلاینت با احراز هویت
export const authActionClient = createSafeActionClient({
  async middleware() {
    const session = await auth()

    if (!session?.user) {
      throw new Error('احراز هویت الزامی است')
    }

    return { user: session.user }
  },
})
// app/actions/posts.ts
'use server'

import { authActionClient } from '@/lib/safe-action'
import { z } from 'zod'

export const createPost = authActionClient
  .schema(
    z.object({
      title: z.string().min(3),
      content: z.string().min(10),
    })
  )
  .action(async ({ parsedInput, ctx }) => {
    // ctx.user از middleware میاد — تضمین شده احراز هویت شده
    const post = await db.post.create({
      data: {
        ...parsedInput,
        authorId: ctx.user.id,
      },
    })

    revalidateTag('posts')
    return { post }
  })

۵. استراتژی کشینگ مناسب رو انتخاب کنید

هر نوع داده‌ای استراتژی کشینگ مخصوص خودش رو داره. این جدول رو به‌عنوان مرجع نگه دارید:

  • داده‌های کاملا استاتیک (درباره ما، شرایط استفاده): cache: 'force-cache' — یکبار کش بشه و تا build بعدی بمونه
  • داده‌های نسبتا ثابت (دسته‌بندی‌ها، تنظیمات): next: { revalidate: 3600 } — هر ساعت بازاعتبارسنجی
  • لیست محصولات/پست‌ها: next: { tags: ['products'] } + بازاعتبارسنجی با تگ بعد از تغییرات
  • داده‌های کاربر خاص (پروفایل، سبد خرید): بدون کش — همیشه تازه واکشی بشه
  • داده‌های بلادرنگ (چت، نوتیفیکیشن): بدون کش + احتمالا WebSocket یا Server-Sent Events
  • کوئری‌های سنگین دیتابیس: "use cache" + cacheTag + cacheLife

۶. مراقب امنیت باشید

و در نهایت چند نکته امنیتی که نباید فراموش کنید:

  • هرگز داده‌های حساس (مثل secret ها، توکن‌های API) رو از Server Component به Client Component پاس ندید
  • در Server Actions همیشه ابتدا احراز هویت و سپس اعتبارسنجی انجام بدید
  • از CSRF protection استفاده کنید (Next.js به‌طور پیش‌فرض ارائه می‌ده ولی مطمئن بشید فعاله)
  • ورودی‌های FormData رو همیشه sanitize کنید
  • Rate limiting روی Server Actions‌های حساس اعمال کنید

جمع‌بندی

توی این مقاله، کل اکوسیستم واکشی داده، کشینگ و Server Actions در Next.js 15 رو با هم بررسی کردیم. بیاید یه خلاصه سریع داشته باشیم:

واکشی داده: Server Component‌ها مستقیما و بدون نیاز به useEffect داده واکشی می‌کنن. از Promise.all برای درخواست‌های موازی استفاده کنید و فقط وقتی واقعا وابستگی وجود داره سراغ واکشی متوالی برید.

کشینگ: در Next.js 15 هیچ‌چیز به‌طور پیش‌فرض کش نمیشه — و این خوبه! باید صراحتا با force-cache، revalidate، تگ‌ها، یا دستور "use cache" مشخص کنید چه‌چیزی و چطور کش بشه.

Server Actions: جایگزین قدرتمند API Routes برای mutation‌ها. همیشه اعتبارسنجی و احراز هویت انجام بدید. از useActionState برای مدیریت وضعیت فرم و از useOptimistic برای تجربه کاربری روان‌تر استفاده کنید.

PPR: صفحاتتون رو به بخش‌های استاتیک و داینامیک تقسیم کنید. بخش‌های استاتیک فورا لود میشن، بخش‌های داینامیک stream میشن. ساده و موثر.

در جدول زیر خلاصه‌ای از استراتژی‌های کشینگ رو می‌بینید:

استراتژی روش مورد استفاده
بدون کش (پیش‌فرض) fetch(url) داده‌های بلادرنگ، اطلاعات کاربر
کش نامحدود fetch(url, { cache: 'force-cache' }) داده‌های کاملا استاتیک
بازاعتبارسنجی زمانی fetch(url, { next: { revalidate: N } }) محتوایی که دوره‌ای آپدیت میشه
بازاعتبارسنجی با تگ fetch(url, { next: { tags: [...] } }) داده‌ای که بعد از mutation آپدیت میشه
use cache (تابع) "use cache" + cacheTag + cacheLife کوئری‌های دیتابیس، محاسبات سنگین
بازاعتبارسنجی مسیر revalidatePath('/path') آپدیت کل یک صفحه بعد از تغییرات

امیدوارم این راهنما بهتون کمک کنه تا الگوهای مدرن واکشی داده و کشینگ رو بهتر درک کنید و در پروژه‌هاتون ازشون استفاده کنید. اگه مقاله قبلی درباره احراز هویت با Auth.js v5 رو نخوندید، پیشنهاد می‌کنم حتما یه نگاهی بهش بندازید — ترکیب احراز هویت با الگوهای این مقاله، پایه یه اپلیکیشن حرفه‌ای و امن رو تشکیل میده.

کدنویسی خوش بگذره!

درباره نویسنده Editorial Team

Our team of expert writers and editors.