Шаблони серверних і клієнтських компонентів у 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; зворотне - ні
Короткий приклад
// 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; тільки кнопка поділитися потребує браузера.
// 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. Обгортка рендериться на клієнті; те, що передано всередину, досі виконується на сервері. Головне: передати їх із серверного батька, а не імпортувати всередині клієнтського файлу.
// 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. Клієнтський компонент займається фільтрацією, сортуванням або пагінацією в пам'яті - без зайвих мережевих запитів.
// 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, передані ззовні, залишаться серверними.
// 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, рендеряться на сервері, бо вони передані ззовні клієнтської межі, а не імпортовані всередині неї.
Типові помилки
Отримання даних у клієнтському компоненті замість серверного батька:
// Погано - подвійний запит, роздутий бандл, мерехтіння порожнього контенту
'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:
// Погано - весь застосунок стає клієнтським JS
// app/layout.tsx
'use client'; // Все нижче тепер гідруєтьсяКореневий layout має залишатися серверним компонентом. Виноси в окремі файли лише те, що справді потребує браузера.
Передача функції як prop із сервера на клієнт:
// Погано - функції не серіалізуються через 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 браузера в серверному компоненті:
// Погано - сервер не має 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 забороняє.
Приклади
Дашборд із серверними даними та клієнтською дією
// 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. Браузер гідрує тільки кнопку.Сторінка блогу з клієнтськими коментарями
// 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 для видалення запису
// 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.Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.