مسمومیت 'use client' در Next.js: چطور یک خط باندل کلاینت را دو برابر میکند
نویسنده: آرش لطیفی
یک 'use client' در جای اشتباه کل درخت را به کلاینت میکشد: باندل ۲×، هیدریشن کند و آبشاری از importهای سروری روی مرورگر. الگوی مرزبندی، کد قبل/بعد و بنچمارک واقعی.
خلاصه برای وقتندارها: هر جا بالای فایل
'use client'بگذاری، کل اون فایل با هر چیimportکرده میره تو باندل مرورگر — حتیdate-fnsوzodو حتیlib/db.ts. راه حل؟ فایل رو سروری نگه دار، فقط تیکهی کلیکخور و تعاملیش رو ببر تو یه فایل کوچیک*Shell.tsxکلاینتی. همین. باندل ~۲۸٪ سبکتر، بدون تغییر تو UI.
یه روز next build رو میزنی و بدون هیچ اروری میبینی باندل باد کرده:
هفتهی پیش همین /dashboard روی 198 kB بود. چی عوض شد؟ فقط به Card یه onClick اضافه کردی و برای اینکه کار کنه، بالای فایل نوشتی 'use client'. حالا date-fns و zod و حتی lib/db.ts افتادن تو باندل مرورگر. یه خط، کل Route رو مسموم کرد.
تشبیه آشپزخانه — اگه اینو بگیری کل RSC رو گرفتی
اپ Next.js رو مثل یه رستوران در نظر بگیر:
- Server Component = آشپزخانه. به فر، یخچال و چاقو دسترسی داره (دیتابیس، فایلسیستم، کتابخونههای سنگین). مشتری آشپزخانه رو نمیبینه — فقط بشقاب آماده (HTML) بهش میرسه.
- Client Component (
'use client') = میز مشتری. فقط چیزی که روی میزه به مرورگر میره و به صورت JavaScript دانلود میشه. مشتری میتونه باهاش تعامل کنه — کلیک کنه، تایپ کنه، مودال باز کنه.
قانون بازی: وقتی بالای فایلی 'use client' مینویسی، داری به Next.js میگی «کل این فایل با هر چی import کرده رو ببر سر میز مشتری.» نه فقط اون onClick — همه چی. حتی اون import { db } from '@/lib/db' که یادت رفته پاک کنی. حتی date-fns که فقط برای فرمت تاریخ استفاده کرده بودی.
به خاطر همین بهش میگیم مسمومیت (Poisoning) — یه 'use client' کل درخت import رو آلوده میکنه:
تو فقط یه useState برای isOpen میخواستی. ولی چون zod تو همون فایله، اونم سوار میشه. باندلر دونهدونه جدا نمیکنه — کل فایل کلاینتی میشه.
به زبون آشپزخانه: فقط چون مشتری چنگال خواست، کل آشپزخونه (فر و یخچال) رو بردی سر میزش!
راه حل — کار آشپزخانه رو تو آشپزخانه نگه دار
کل Card رو کلاینتی نکن. کار سنگین (فرمت تاریخ، اعتبارسنجی) رو بذار سرور بمونه، فقط پوستهی کلیکخور رو جدا کن.
۱) قبل (مسموم) vs بعد (سالم) — همون Card، درست انجام شده
قبل — مسموم (همه چی میره مرورگر):
بعد — فقط پوستهی کلیکخور کلاینتیه:
همون onClick موند، ولی date-fns دیگه به مرورگر نرفت. فقط یه رشتهی ساده به عنوان prop رد و بدل شد.
۲) ترفند composition — بذار سرور children رو بسازه
اگه یه Client Component چیزی رو wrap میکنه، نذار خودش محتواش رو بسازه. children رو از والد سروری بهش پاس بده:
محتوایی که روی سرور رندر شده به صورت HTML استریم میشه — نیازی به سریالایز دستی نیست.
۳) تابع نمیشه پاس داد؟ از Server Action استفاده کن
پاس دادن onSelect={() => ...} از سرور به کلاینت خطا میده — تابع سریالایز نمیشه. به جاش Server Action:
۴) چیز سنگین لازم داری؟ تنبل لودش کن (lazy)
اگه واقعا framer-motion یا recharts لازم داری، نذار روز اول لود شه:
۴ تا تله که نصف شب میزننت زمین
۱. barrel file مسموم. یه app/ui/index.ts داری که export * from './Card' و export * from './Button' میکنه. اگه یکیش 'use client' داشته باشه و تو با import { Card } from '@/app/ui' تو Server Component صدا بزنی، کل barrel ممکنه کلاینتی حساب شه.
راه حل: barrel رو بیخیال شو، مستقیم import کن — import { Card } from '@/app/ui/Card'.
۲. کتابخونههای به ظاهر «ایزومورفیک». date-fns و zod و clsx هر کدوم کوچیکن ولی با هم 80–120 kB gz میشن. بدترش moment، کل lodash، prisma/client اگه بره کلاینت، باندل رو میترکونه یا build رو میشکنه (fs is not defined).
راه حل: پکیج server-only رو نصب کن تا نشتی همون موقع build بگیره:
۳. propهای غیرسریالایز. پاس دادن Date، Map، Set یا تابع از سرور به کلاینت خطا میده: Functions cannot be passed directly to Client Components.
راه حل: Date → string (ISO)، Map/Set → آرایه، تابع → Server Action.
۴. آبشاری شدن 'use client'. یه Layout.tsx رو کلاینتی میکنی چون usePathname() میخوای، یهو کل layout و بچههاش کلاینتی میشن.
راه حل: منطق usePathname رو ببر تو یه برگ کوچیک ActiveLink.tsx و فقط همونو کلاینتی کن.
واقعا فرق میکنه؟ عدد و رقم
تست روی یه اپ واقعی (Next.js 15.3, App Router، صفحهی /dashboard با ۱۲ تا کارت):
| سناریو | First Load JS (gz) | JS کلاینت /dashboard | TTI روی 4G |
|---|---|---|---|
| مسموم — Card.tsx با 'use client' + date-fns + zod | 412 kB | 576 kB | ~3.4s |
| سالم — Card سروری + CardShell برگ کلاینتی | 298 kB | 412 kB | ~2.1s |
| سالم + dynamic برای chart | 274 kB | 388 kB | ~1.9s |
فقط با جابهجایی مرز، ~۲۸٪ از First Load JS کم شد. برای سایتی با ۵۰۰k بازدید در ماه، میشه 40–60 GB ترافیک کمتر در ماه — مستقیم از هزینهی Vercel کم میشه.
جدول سریع — کی کلاینتی، کی سروری؟
| چی لازم داری | کجا بذارم | چرا |
|---|---|---|
| useState, useEffect, onClick, usePathname | برگ کوچیک کلاینتی (*Shell.tsx) | کمترین JS |
| فرمت تاریخ، اعتبارسنجی zod، کوئری DB | سرور — نتیجه رو string/JSON پاس بده | صفر JS |
| انیمیشن سنگین، chart تعاملی | کلاینتی + dynamic + ssr:false | لود تنبل |
| کل page.tsx یا layout.tsx | هرگز مگر مجبور باشی | کل Route رو مسموم میکنه |
چکلیست — همین امروز درستش کن
- الان اندازه بگیر:
npm run build→ جدول Route Sizes رو یادداشت کن. یه بارnpx @next/bundle-analyzerبزن. - مرزها رو پیدا کن:
grep -r "'use client'" app --include="*.tsx" | head -20— هر فایلی که'use client'داره و import سنگین داره مشکوکه. - الگوی Shell رو بزن: برای هر کامپوننت مسموم، یه
*Shell.tsxکلاینتیِ 10–20 خطی بساز؛ منطق سنگین رو تو والد سروری نگه دار (Card→CardShell). server-onlyنصب کن رویlib/db.tsوlib/auth.ts— تا نشتی همون موقع build لو بره.- propها رو درست کن:
Date→string،Map→ آرایه، تابع → Server Action. باnpm run buildچک کن ارور نگیری. - دوباره اندازه بگیر و نگهش دار: سایز
First Load JSرو تو CI لاگ کن، اگه از حد گذشت PR رو بلاک کن (bundlesizeیاsize-limit).
کی بیخیال این ترفند شم؟
- اپت SPA قدیمی با
pages/routerه؟ این مدل به کارت نمیاد — همه چی کلاینتیه. - کامپوننت کلا تعاملیه (مثل یه Canvas Editor پر از
useState/useEffect)؟ نصف-سروری کردنش فقط پیچیدهش میکنه — همون یه فایل کلاینتیِ تمیز بهتره. - کتابخونه ذاتا کلاینتیه و همه جا لازمه (مثل
framer-motionسرتاسر سایت)؟ جابهجایی مرز سودی نداره — باdynamicو code-splitting بهینهش کن.
سمت کشِ همین ماجرا رو تو کالبدشکافی کش در Next.js 15 ببین — اونجا میگیم use cache و PPR چطور همین منطق سرور/کلاینت رو به لایهی داده میبرن.
نمونهی واقعیش رو تو پروژهها ببین — بیشترشون با همین مرزبندی ساخته شدن تا باندل زیر 300 kB بمونه.
من آرش لطیفی هستم؛ اگه باندلت باد کرده یا هیدریشنت کنده، از تماس پیام بده تا با هم مرزش رو درست کنیم.
برای مطالب فنی بیشتر، آرشیو تک رو دنبال کن.