server-only، Context و اشتباهات رایج

مرز Server و Client چیزی است که با یک خط "use client" کشیده می‌شود، اما رعایت‌کردنش به دقت نیاز دارد. بزرگ‌ترین خطر این است که کدی که فقط باید روی سرور بماند (مثل دسترسی به پایگاه داده یا استفاده از رازها) ناخواسته به مرورگر برسد. در این درس با ابزار server-only از این اتفاق جلوگیری می‌کنیم، می‌فهمیم Context در معماری Server/Client چگونه کار می‌کند و خطاهای متداول این مرز را یکی‌یکی می‌شناسیم. در پایان لایه‌ی داده‌ی DevNotes را با server-only محافظت می‌کنیم.

چرا نشت کد سرور خطرناک است؟

هر ماژولی که از یک Client Component import شود، وارد بسته‌ی JavaScript مرورگر می‌شود (درس «Client Components و دستور use client»). اگر آن ماژول کد پایگاه داده، منطق داخلی یا مقدارهای محرمانه داشته باشد، این‌ها در اختیار هر کاربری قرار می‌گیرند که ابزار توسعه‌دهنده‌ی مرورگر را باز کند. مشکل این است که این اتفاق ممکن است بی‌صدا رخ دهد؛ مثلاً کسی در آینده یک تابع کمکی را از lib/posts.ts داخل یک Client Component import کند و هیچ خطایی نبیند.

بسته‌ی server-only

بسته‌ی server-only یک محافظ ساده و مؤثر است. فقط کافی است آن را در بالای فایلی import کنید که نباید به مرورگر برسد. اگر بعداً هر Client Component (مستقیم یا غیرمستقیم) آن فایل را import کند، Build یا اجرا با خطا متوقف می‌شود و به‌جای نشت بی‌صدا، همان لحظه مطلع می‌شوید.

bash
npm install server-only
ts
// lib/example.ts
import "server-only";

export async function getSecretReport() {
  // کد حساس سمت سرور
}
  • این import فقط به‌عنوان نشانه است؛ چیزی از آن استفاده نمی‌کنید و نامی هم نمی‌گیرد.
  • بسته‌ی همتای آن client-only است برای ماژول‌هایی که فقط باید در مرورگر استفاده شوند (مثلاً وابسته به window). کاربردش کمتر است و در این دوره به آن نیاز نداریم.
  • server-only مانع نوشتن کد نادرست نمی‌شود؛ فقط ورود آن به گراف مرورگر را قطع می‌کند و پیام خطا می‌دهد.

عملی: محافظت از لایه‌ی داده‌ی DevNotes

فایل lib/posts.ts لایه‌ی دسترسی به داده است و در فصل «پایگاه داده با Prisma و SQLite» به پایگاه داده وصل می‌شود. از همین الان آن را محافظت می‌کنیم تا عادت درستی شکل بگیرد.

فایل‌های متأثر: package.json (با نصب بسته) و lib/posts.ts (یک خط در ابتدا).

ابتدا بسته را نصب کنید:

bash
npm install server-only

سپس بالاترین خط lib/posts.ts را به این شکل کنید (بقیه‌ی فایل بدون تغییر می‌ماند):

ts
// lib/posts.ts
import "server-only";

// لایه‌ی موقت داده برای آموزش؛ در محیط Production مناسب نیست.
// داده در حافظه‌ی برنامه است و هیچ‌چیز ذخیره نمی‌شود.
// در فصل «پایگاه داده با Prisma و SQLite» پیاده‌سازی با Prisma جایگزین می‌شود.

export type Post = {
  slug: string;
  title: string;
  excerpt: string;
  body: string;
  publishedAt: string;
};

// ... بقیه‌ی فایل بدون تغییر

حالا پروژه را بررسی کنید. تمام استفاده‌های فعلی از این فایل در Server Componentها هستند (صفحه‌ها، LatestPosts، RelatedPosts)، پس همه‌چیز بدون تغییر کار می‌کند. یک نکته: PostCard از lib/posts فقط نوع Post را با import type می‌گیرد و چون نوع‌ها هنگام کامپایل حذف می‌شوند، مشکلی ایجاد نمی‌شود. اگر Client Componentی هم بخواهد فقط نوع را استفاده کند، با import type مجاز است.

آزمایش: دیدن خطای محافظ

برای اینکه مطمئن شوید محافظ کار می‌کند، یک نشت عمدی بسازید. این‌ها فقط موقتی هستند.

  1. فایل موقت components/LeakyClient.tsx را بسازید:
tsx
// components/LeakyClient.tsx (فقط برای آزمایش؛ بعداً حذف می‌شود)
"use client";

import { getAllPosts } from "@/lib/posts";

export default function LeakyClient() {
  console.log(typeof getAllPosts);
  return <p>آزمایش</p>;
}
  1. آن را موقتاً در app/(public)/about/page.tsx رندر کنید (با import و <LeakyClient />).
  2. صفحه‌ی /about را باز کنید.

رفتار مورد انتظار: Next.js خطایی شبیه «You're importing a component that needs "server-only". That only works in a Server Component but one of its parents is marked with "use client"...» نشان می‌دهد و ماژول را به مرورگر نمی‌رساند. اگر server-only نبود، بدون هیچ پیامی کد لایه‌ی داده در بسته‌ی مرورگر قرار می‌گرفت.

بعد از دیدن خطا، LeakyClient را از about/page.tsx بردارید و فایل components/LeakyClient.tsx را حذف کنید.

الگوی عملی

قرارداد ساده‌ای که از این به بعد در DevNotes رعایت می‌کنیم: هر فایلی در lib که به پایگاه داده، نشست کاربر، رازها یا منطق سروری دسترسی دارد، با import "server-only" شروع شود. فایل‌هایی مثل lib/format.ts که توابع خالص هستند و هر دو سمت می‌توانند استفاده کنند، این خط را نمی‌گیرند.

متغیرهای محیطی و Client Component

متغیرهای محیطی هم از همین قاعده پیروی می‌کنند، اما با سازوکار دیگری. در Next.js فقط متغیرهایی که نامشان با NEXT_PUBLIC_ شروع شود هنگام Build داخل کد مرورگر قرار می‌گیرند. بقیه فقط در سرور در دسترس‌اند و در Client Component مقدار undefined دارند. دو نتیجه:

  • هرگز برای مقدار محرمانه (کلید API، رمز پایگاه داده) از پیشوند NEXT_PUBLIC_ استفاده نکنید؛ آن مقدار برای همه قابل مشاهده است.
  • اگر در Client Component مقدار یک متغیر بدون پیشوند را بخوانید، خطا نمی‌گیرید بلکه undefined می‌گیرید؛ این معمولاً نشانه‌ی این است که آن کد باید روی سرور باشد.

جزئیات پیکربندی را در درس‌های «متغیرهای محیطی و فایل‌های .env» و «متغیرهای عمومی و محرمانه» می‌بینیم.

Context در Server و Client Components

Context در React روشی برای رساندن یک مقدار به چند کامپوننت عمیق بدون Props دادن مرحله‌به‌مرحله است. در App Router قواعد زیر را دارد:

  • createContext و useContext فقط در Client Component کار می‌کنند. Server Component نمی‌تواند Context بسازد یا مصرف کند.
  • Provider (کامپوننتی که مقدار را فراهم می‌کند) باید خودش Client Component باشد.
  • Server Component می‌تواند Provider را رندر کند و محتوا را به‌صورت children به آن بدهد؛ همان الگوی درس «ترکیب Server و Client Components».
  • تنها کامپوننت‌های Client که داخل Provider هستند به مقدار Context دسترسی دارند. Server Componentهایی که به‌عنوان children داده شده‌اند آن را نمی‌بینند.

نمونه‌ی یک Provider

این یک نمونه‌ی مفهومی است و در DevNotes فعلاً به Context نیاز نداریم، پس ساخته نمی‌شود. اما الگوی درست آن را بدانید:

tsx
// components/ThemeProvider.tsx (نمونه‌ی مفهومی)
"use client";

import {
  createContext,
  useContext,
  useState,
  type ReactNode,
} from "react";

type Theme = "light" | "dark";

type ThemeContextValue = {
  theme: Theme;
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  function toggleTheme() {
    setTheme((prev) => (prev === "light" ? "dark" : "light"));
  }

  return (
    <ThemeContext value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext>
  );
}

export function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext);

  if (!context) {
    throw new Error("useTheme باید داخل ThemeProvider استفاده شود.");
  }

  return context;
}
  • همه‌ی فایل Client است، چون هم createContext و هم useState دارد.
  • در React 19 می‌توان خود Context را مستقیم به‌عنوان Provider رندر کرد (<ThemeContext value=...>)؛ شکل قدیمی <ThemeContext.Provider> هم هنوز کار می‌کند.
  • مقدار پیش‌فرض null و بررسی داخل useTheme باعث می‌شود استفاده‌ی خارج از Provider با پیام روشن خطا بدهد، نه با یک undefined مبهم.
  • تابع toggleTheme درون خود ماژول Client ساخته شده و از مرز Server/Client عبور نمی‌کند؛ پس محدودیت «تابع به‌عنوان Prop» اینجا مشکلی ایجاد نمی‌کند.

استفاده در Layout

tsx
// app/layout.tsx (نمونه‌ی مفهومی)
import { ThemeProvider } from "@/components/ThemeProvider";

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="fa" dir="rtl">
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

RootLayout Server Component می‌ماند و صفحه‌ها (که ممکن است Server باشند) درون Provider رندر می‌شوند. فقط خودِ Provider و مصرف‌کننده‌های Context در مرورگر اجرا می‌شوند.

توصیه‌ی عملی: Provider را تا جای ممکن پایین در درخت بگذارید. مثلاً اگر فقط یک بخش از سایت به Context نیاز دارد، Provider را در Layout همان بخش بگذارید، نه Root Layout. این کار به Next.js اجازه می‌دهد بخش‌های سروری بالاتر را بهتر بهینه کند.

خطاهای متداول در مرز Server/Client

این فهرست خطاهایی است که بیشتر از همه پیش می‌آید، همراه با نشانه‌ی هر خطا و راه رفع:

۱. استفاده از Hook یا رویداد در Server Component

نشانه: پیام خطا درباره‌ی useState، useEffect یا «Event handlers cannot be passed to Client Component props». راه رفع: بخش تعاملی را در یک Client Component کوچک جدا کنید (درس «Client Components و دستور use client»).

۲. "use client" بالاتر از حد لازم

نشانه: صفحه‌ای که async بود نمی‌تواند داده بخواند، یا metadata کار نمی‌کند. راه رفع: مرز را به برگ‌ها ببرید و محتوای سروری را با children داخل پوسته‌ی Client بدهید (درس «ترکیب Server و Client Components»).

۳. import کردن کد سروری در Client Component

نشانه: خطای «server-only» (اگر محافظ را گذاشته‌اید)، یا خطاهایی مثل «Module not found: Can't resolve 'fs'» وقتی کتابخانه‌ی مخصوص Node در مرورگر import شده است. راه رفع: داده را در Server Component بخوانید و به‌صورت Props بدهید؛ فایل‌های سروری را با server-only علامت بزنید.

۴. دادن تابع یا نمونه‌ی کلاس به‌عنوان Prop

نشانه: «Functions cannot be passed directly to Client Components». راه رفع: درس «Props، انتقال داده و مرز Server/Client».

۵. نشت داده‌ی اضافی از طریق Props

نشانه: بدون خطا؛ فقط در سورس صفحه یا پاسخ شبکه فیلدهای اضافی دیده می‌شوند. راه رفع: فقط فیلدهای لازم را بدهید.

۶. استفاده از Context در Server Component

نشانه: خطای «createContext only works in Client Components». راه رفع: Provider را در یک فایل "use client" بسازید و در Layout با children رندر کنید.

۷. Provider بالاتر یا خارج از محدوده‌ی مصرف‌کننده

نشانه: useContext مقدار پیش‌فرض (null) می‌گیرد یا خطای سفارشی ما مثل «باید داخل ThemeProvider استفاده شود» دیده می‌شود. راه رفع: مصرف‌کننده را داخل Provider قرار دهید.

۸. کتابخانه‌ی شخص ثالث بدون "use client"

بعضی کتابخانه‌ها از Hookها یا APIهای مرورگر استفاده می‌کنند ولی دستور "use client" ندارند. وقتی در Server Component از آن‌ها استفاده کنید، خطا می‌گیرید. راه رفع: یک پوشش (Wrapper) Client بسازید:

tsx
// components/Carousel.tsx (نمونه‌ی مفهومی)
"use client";

import { Carousel } from "some-carousel-library";

export default Carousel;

حالا می‌توانید components/Carousel را در Server Component رندر کنید. نام کتابخانه فرضی است؛ الگو مهم است.

۹. ناهمخوانی Hydration

نشانه: هشدار «Hydration failed because the server rendered HTML didn't match the client». علت متداول: استفاده از Math.random()، Date.now()، window یا قالب‌بندی تاریخ با منطقه‌ی زمانی متفاوت در بدنه‌ی رندر. راه رفع: مقدار را در useEffect یا رویداد بگیرید، یا قالب‌بندی را ثابت کنید (همان کاری که با timeZone: "UTC" در formatDate کردیم).

۱۰. async کردن Client Component

نشانه: خطای «async/await is not yet supported in Client Components». راه رفع: داده را در Server Component بخوانید و Props بدهید؛ یا در Client با Hookها و روش‌هایی که در درس «دریافت داده در Client Components و موارد استفاده» می‌آید.

چک‌لیست قبل از Client‌کردن یک کامپوننت

سؤال اگر جواب «نه» است
واقعاً به State، Effect، رویداد یا API مرورگر نیاز دارم؟ Server Component بماند.
می‌توانم فقط بخش تعاملی را جدا کنم؟ اگر بله، فقط همان بخش را Client کن.
این فایل و importهایش چیز حساس یا سنگین سروری دارند؟ اگر بله، آن‌ها را به Server بسپار و server-only بگذار.
Propsی که می‌دهم قابل ارسال و حداقلی هستند؟ اگر نه، اصلاحشان کن.

جمع‌بندی

  • هر ماژولی که از Client Component import شود به مرورگر می‌رود؛ برای جلوگیری از نشت کد سروری، import "server-only" را در ابتدای آن فایل بگذارید تا import اشتباه خطا بدهد.
  • فقط متغیرهای محیطی با پیشوند NEXT_PUBLIC_ به مرورگر می‌رسند؛ مقدار محرمانه هرگز نباید این پیشوند را بگیرد.
  • Context فقط در Client Component ساخته و مصرف می‌شود؛ Provider را در یک فایل Client می‌سازیم، با children در Layout رندر می‌کنیم و تا جای ممکن پایین در درخت می‌گذاریم.
  • خطاهای متداول مرز Server/Client شامل Hook در Server، "use client" بالاتر از حد لازم، import کد سروری در Client، Props نامعتبر، Context در Server، کتابخانه‌ی بدون "use client"، ناهمخوانی Hydration و async در Client هستند.
  • در DevNotes اکنون lib/posts.ts با server-only محافظت شده است.
  • در درس بعد، «انتخاب محل اجرای کد و کاهش JavaScript سمت کلاینت»، آنچه در این فصل یاد گرفتیم را برای تصمیم‌گیری درباره‌ی محل اجرای هر بخش و کم‌کردن حجم JavaScript به کار می‌بریم.