Skip to main content

Шаблони серверних і клієнтських компонентів у Next.js

Шаблони серверних і клієнтських компонентів (Server/Client Components) у Next.js розподіляють роботу між Node.js і браузером: сервер відправляє готовий HTML, браузер отримує JS тільки там, де потрібна взаємодія.

Теорія

TL;DR

  • Серверні компоненти (Server Components) рендеряться на сервері та відправляють HTML - жодного JS-бандлу для них
  • Клієнтські компоненти (Client Components) з директивою "use client" гідрують у браузері та відповідають за стан, події, API браузера
  • За замовчуванням - серверний компонент; "use client" додавай тільки де потрібні хуки або DOM
  • Клієнтські компоненти мають бути листями дерева, а не обгортками цілих сторінок
  • Серверний компонент може містити клієнтський через children; зворотне - ні

Короткий приклад

tsx
// app/page.tsx - Серверний компонент, дані з сервера async function Page() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return <PostList posts={posts} />; // Відправляє HTML, жодного JS для цього файлу } // components/PostItem.tsx - Клієнтський компонент, обробляє лайки 'use client'; import { useState } from 'react'; function PostItem({ post }: { post: Post }) { const [likes, setLikes] = useState(0); return ( <li> {post.title} <button onClick={() => setLikes(likes + 1)}>👍 {likes}</button> </li> ); } // Сервер рендерить весь список як HTML. Браузер гідрує тільки кнопки лайків.

Сервер відповідає за дані та початковий HTML. Браузер підключається лише на кнопці.

Коли що використовувати

  • Отримання даних або доступ до БД: серверний компонент - прямий доступ, секрети не потрапляють у браузер
  • Статичний контент, SEO: серверний компонент - готовий HTML для пошукових роботів
  • useState, useEffect, інші хуки: клієнтський компонент - браузерні можливості React
  • Обробники подій (onClick, onChange): клієнтський компонент - потрібен DOM
  • API браузера (localStorage, window, document): клієнтський компонент - сервер не має браузерного контексту
  • Зменшити розмір JS-бандлу: серверний компонент - жодного JS для гідрації

Порівняльна таблиця

АспектСерверний компонентКлієнтський компонент
ВиконанняNode.js (SSR/RSC)Браузер (гідрація)
JS-бандлНе відправляєтьсяПовний JS + гідрація
Отримання данихПрямий async/await, БДfetch(), SWR, TanStack Query
ІнтерактивністьВідсутняПовна (стан, події)
ChildrenМоже містити клієнтськіНе може імпортувати серверні
СекретиБезпечно в envРизик потрапити в бандл
Типове застосуванняДашборд з БД, SEO-блогФорми, модалки, меню

Патерн 1: Клієнтські компоненти вниз до листів

Директива "use client" має стояти біля найменшого можливого шматка UI. Сторінка блогу - переважно статичний HTML; тільки кнопка поділитися потребує браузера.

tsx
// app/blog/[slug]/page.tsx - Серверний компонент export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await getPost(slug); // Прямий виклик до БД, API-ключ не потрапить у браузер return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> {/* Статичний HTML, жодного JS */} <ShareButton url={post.url} /> {/* Клієнтська межа тут */} </article> ); } // components/ShareButton.tsx - Клієнтський компонент 'use client'; export function ShareButton({ url }: { url: string }) { return <button onClick={() => navigator.share({ url })}>Поділитися</button>; }

Якщо поставити "use client" на весь BlogPost, доведеться завантажувати повне дерево як JS, використовувати useEffect для даних і втратити серверний HTML для SEO. Я бачив, як це опускало Lighthouse з 90+ до нижче 60 на контентних сайтах.

Патерн 2: Серверні компоненти як children

Клієнтський компонент може приймати серверні компоненти через children. Обгортка рендериться на клієнті; те, що передано всередину, досі виконується на сервері. Головне: передати їх із серверного батька, а не імпортувати всередині клієнтського файлу.

tsx
// components/Accordion.tsx - Клієнтський компонент 'use client'; import { useState } from 'react'; export function Accordion({ title, children }: { title: string; children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(!isOpen)}>{title}</button> {isOpen && <div>{children}</div>} </div> ); } // app/page.tsx - Серверний компонент export default async function Page() { const data = await fetchData(); // Виконується на сервері return ( <Accordion title="Деталі"> <ServerContent data={data} /> {/* Рендериться на сервері, передається як children */} </Accordion> ); }

Shadcn/UI використовує цей підхід: клієнтські інтерактивні обгортки, що приймають серверний контент як слоти.

Патерн 3: Дані на сервері, взаємодія на клієнті

Отримай весь набір даних у серверному компоненті та передай через серіалізовані props. Клієнтський компонент займається фільтрацією, сортуванням або пагінацією в пам'яті - без зайвих мережевих запитів.

tsx
// app/products/page.tsx - Серверний компонент export default async function ProductsPage() { const products = await getProducts(); // Один серверний запит return <ProductGrid products={products} />; } // components/ProductGrid.tsx - Клієнтський компонент 'use client'; export function ProductGrid({ products }: { products: Product[] }) { const [filter, setFilter] = useState(''); const filtered = products.filter(p => p.name.includes(filter)); return ( <div> <input value={filter} onChange={e => setFilter(e.target.value)} /> {filtered.map(p => <ProductCard key={p.id} product={p} />)} </div> ); }

Props через межу сервер-клієнт мають бути серіалізованими: рядки, числа, прості об'єкти, масиви. Функції через цю межу не проходять.

Патерн 4: Context Providers

React Context потребує клієнтського компонента. Виноси провайдери в окремий файл і підключай їх із серверного layout. Children, передані ззовні, залишаться серверними.

tsx
// app/providers.tsx - Клієнтський компонент 'use client'; import { ThemeProvider } from 'next-themes'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient(); export function Providers({ children }: { children: React.ReactNode }) { return ( <ThemeProvider> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </ThemeProvider> ); } // app/layout.tsx - Серверний компонент import { Providers } from './providers'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <body> <Providers> {children} {/* Залишаються серверними компонентами */} </Providers> </body> </html> ); }

Providers - клієнтський файл. Сам layout залишається серверним. Children, що приходять із layout, рендеряться на сервері, бо вони передані ззовні клієнтської межі, а не імпортовані всередині неї.

Типові помилки

Отримання даних у клієнтському компоненті замість серверного батька:

tsx
// Погано - подвійний запит, роздутий бандл, мерехтіння порожнього контенту 'use client'; export function PostList() { const [posts, setPosts] = useState([]); useEffect(() => { fetch('/api/posts').then(r => r.json()).then(setPosts); }, []); return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>; } // Добре - серверний батько отримує дані, передає як props export default async function Page() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return <PostList posts={posts} />; // PostList тільки рендерить, useEffect не потрібен }

"use client" у кореневому layout:

tsx
// Погано - весь застосунок стає клієнтським JS // app/layout.tsx 'use client'; // Все нижче тепер гідрується

Кореневий layout має залишатися серверним компонентом. Виноси в окремі файли лише те, що справді потребує браузера.

Передача функції як prop із сервера на клієнт:

tsx
// Погано - функції не серіалізуються через RSC-межу export default function Server() { return <ClientComp onClick={() => console.log('привіт')} />; // Помилка в рантаймі } // Добре - обробник визначається всередині клієнтського компонента 'use client'; export function ClientComp() { return <button onClick={() => console.log('привіт')}>Натиснути</button>; }

Для серверних мутацій, ініційованих клієнтом, використовуй Server Actions: позначай функцію 'use server' і передавай у action форми.

Виклик API браузера в серверному компоненті:

tsx
// Погано - сервер не має window або localStorage export default function Theme() { localStorage.setItem('theme', 'dark'); // TypeError у рантаймі на Vercel }

Будь-який виклик window, localStorage або document переноси в клієнтський компонент.

Де зустрічається

  • Vercel Dashboard: серверні компоненти тягнуть дані про деплойменти з БД; перемикачі та дропдауни - клієнтські
  • Shadcn/UI: інтерактивні компоненти (кнопки, діалоги, дропдауни) мають "use client", використовуються всередині серверних сторінок
  • NextAuth: перевірка сесії через auth() у серверних компонентах; аватар користувача з меню - клієнтський компонент
  • Payload CMS: таблиці адмінки рендеряться на сервері; редактор Lexical - клієнтський компонент

Питання на співбесіді

Q: Що станеться, якщо імпортувати серверний компонент усередині клієнтського?
A: Next.js кине помилку. Клієнтські компоненти не можуть безпосередньо імпортувати серверні. Використовуй патерн children - передавай серверний компонент із серверного батька.

Q: Чи може клієнтський компонент отримувати дані?
A: Так, через useEffect + fetch, SWR або TanStack Query. Але для початкових даних сторінки це дає зайвий мережевий запит і мерехтіння порожнього контенту, яке серверний компонент повністю усуває.

Q: Що таке RSC payload і чим він відрізняється від HTML?
A: Це бінарний потік, який сервер відправляє разом із початковим HTML. Він містить props компонентів і посилання, щоб браузерний React-reconciler міг гідрувати клієнтські піддерева без повторного запиту даних.

Q: Чим Server Actions відрізняються від API routes?
A: Server Actions - це функції з директивою 'use server', що запускаються на сервері при виклику через форму або клієнтську подію. Вони працюють без JavaScript (progressive enhancement). API routes - окремі HTTP-ендпоінти, які завжди потребують явного fetch.

Q: (Senior) Чому серверні компоненти можна передавати як children клієнтським, але не імпортувати всередині них?
A: Коли серверний компонент передається як children, сервер вже відрендерив його в RSC payload до того, як клієнтський компонент запустився. Клієнтський компонент отримує непрозоре посилання, а не функцію компонента, тому межа не порушується. Прямий імпорт затягнув би серверний модуль у клієнтський бандл, що React забороняє.

Приклади

Дашборд із серверними даними та клієнтською дією

tsx
// app/dashboard/page.tsx - Серверний компонент // Прямий доступ до БД - жодного витоку ключів, жодної зайвої обгортки export default async function Dashboard() { const metrics = await db.query('SELECT * FROM metrics ORDER BY date DESC LIMIT 30'); return ( <main> <h1>Dashboard</h1> <StatsGrid metrics={metrics} /> {/* Сервер: чистий HTML */} <ExportButton data={metrics} /> {/* Клієнт: File API браузера */} </main> ); } // components/ExportButton.tsx - Клієнтський компонент 'use client'; export function ExportButton({ data }: { data: Metric[] }) { const handleExport = () => { const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); const url = URL.createObjectURL(blob); // API тільки браузера const link = document.createElement('a'); link.href = url; link.download = 'metrics.json'; link.click(); }; return <button onClick={handleExport}>Експортувати JSON</button>; } // Сервер стримить повний дашборд як HTML. Браузер гідрує тільки кнопку.

Сторінка блогу з клієнтськими коментарями

tsx
// app/blog/[slug]/page.tsx - Серверний компонент import { getPost } from '@/lib/posts'; import { CommentSection } from './CommentSection'; export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await getPost(slug); // Виклик до БД на сервері, секрети не потрапляють у браузер return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> <CommentSection postId={post.id} /> </article> ); } // app/blog/[slug]/CommentSection.tsx - Клієнтський компонент 'use client'; import { useState } from 'react'; export function CommentSection({ postId }: { postId: string }) { const [comments, setComments] = useState<{ id: string; text: string }[]>([]); const [input, setInput] = useState(''); const handleAdd = () => { if (!input.trim()) return; setComments(prev => [...prev, { id: Date.now().toString(), text: input }]); setInput(''); }; return ( <section> <h2>Коментарі</h2> <input value={input} onChange={e => setInput(e.target.value)} placeholder="Додати коментар" /> <button onClick={handleAdd}>Опублікувати</button> <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul> </section> ); } // HTML статті серверний - для SEO. Блок коментарів гідрується незалежно.

Server Action для видалення запису

tsx
// app/posts/actions.ts - Server Action 'use server'; import { db } from '@/lib/db'; export async function deletePost(formData: FormData) { const id = formData.get('id') as string; await db.posts.delete({ where: { id } }); } // app/posts/PostList.tsx - Клієнтський компонент 'use client'; import { deletePost } from './actions'; export function PostList({ posts }: { posts: Post[] }) { return ( <ul> {posts.map(post => ( <li key={post.id}> {post.title} <form action={deletePost}> <input type="hidden" name="id" value={post.id} /> <button type="submit">Видалити</button> </form> </li> ))} </ul> ); } // deletePost серіалізує дані через FormData і виконується на сервері. // Працює навіть без увімкненого JavaScript - progressive enhancement.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?