Урок 03

SEO в Next.js: Server Components vs Client Components

Что такое RSC

React Server Components (RSC) — компоненты, которые рендерятся на сервере.

Что даёт:

  • HTML на сервере.
  • SEO из коробки.
  • Быстрее — меньше JS.
  • Доступ к БД и API.

Что нет:

  • useState.
  • useEffect.
  • Обработчики событий.

Правило: по умолчанию — Server Component.

Client Components

Что это: компоненты с директивой 'use client'.

Что даёт:

  • Интерактивность.
  • State, эффекты.
  • Обработчики.
  • Браузерные API.

Что стоит:

  • JS в бандле.
  • Гидратация.
  • Плохо для SEO, если злоупотреблять.

Правило: 'use client' — только там, где нужен интерактив.

Как работают

Server Component:

  1. Рендерится на сервере.
  2. Отдаёт HTML + сериализованный payload.
  3. Не отправляет свой код в браузер.

Client Component:

  1. Рендерится и на сервере (SSR), и на клиенте.
  2. Отправляет JS.
  3. Гидратируется в браузере.

Правило: Server Component лучше для SEO.

Что важно для SEO

Server:

  • HTML приходит сразу.
  • Контент виден поисковику.
  • Метаданные работают.
  • Быстрее FCP и LCP.

Client:

  • HTML есть (SSR), но контент может быть пустым.
  • Гидратация может не успеть.
  • Поисковик видит результат после JS (через рендер).
  • Медленнее.

Пример: правильный подход

Страница (Server):

// app/tracks/[slug]/page.tsx
import type { Metadata } from 'next';
import LikeButton from './LikeButton';

export async function generateMetadata({ params }) {
    // ...
}

export default async function Page({ params }) {
    const { slug } = await params;
    const track = await getTrack(slug);

    return (
        <article>
            <h1>{track.title}</h1>
            <p>{track.description}</p>
            <LikeButton trackId={track.id} />
        </article>
    );
}

Кнопка (Client):

// app/tracks/[slug]/LikeButton.tsx
'use client';

import { useState } from 'react';

export default function LikeButton({ trackId }: { trackId: number }) {
    const [liked, setLiked] = useState(false);

    return (
        <button onClick={() => setLiked(!liked)}>
            {liked ? '❤️' : '🤍'}
        </button>
    );
}

Что здесь:

  • Контент — Server Component (SEO ок).
  • Интерактив — Client Component (маленький).

Правило: контент на сервере, интерактив на клиенте.

Проблема: всё в Client

Плохой пример:

// app/tracks/[slug]/page.tsx
'use client';

export default function Page() {
    const [track, setTrack] = useState(null);

    useEffect(() => {
        fetch(`/api/tracks/${slug}`).then((r) => r.json()).then(setTrack);
    }, []);

    if (!track) return <div>Загрузка...</div>;

    return <h1>{track.title}</h1>;
}

Что плохо:

  • Контент появляется только после JS.
  • Поисковик может не увидеть.
  • Медленный LCP.
  • Плохой SEO.

Директива 'use client'

Ставится в начало файла:

'use client';

import { useState } from 'react';

Что означает:

  • Этот файл — Client Component.
  • Все импорты — тоже Client.
  • Нельзя вернуться к Server.

Правило: ставить как можно ниже в дереве.

Композиция

Server может рендерить Client:

// Server
export default function Page() {
    return <ClientComponent />;
}

Client не может рендерить Server напрямую:

'use client';

// ❌ Не работает
import ServerComponent from './ServerComponent';

Обход через children:

// Client
'use client';

export default function ClientWrapper({ children }) {
    return <div>{children}</div>;
}

// Server
export default function Page() {
    return (
        <ClientWrapper>
            <ServerComponent />
        </ClientWrapper>
    );
}

Правило: Server Component через children — работает.

Метаданные

Только Server Components:

// Server
export const metadata: Metadata = { title: 'Page' };

Для Client — через родителя:

// app/client-page/page.tsx (Server)
export const metadata: Metadata = { title: 'Page' };

export default function Page() {
    return <ClientInteractive />;
}

Правило: оборачивать Client в Server.

SSR и Client Components

SSR рендерит Client Components на сервере тоже.

Что это значит:

  • HTML есть.
  • Гидратация добавляет интерактив.

Правило: контент в Client попадает в HTML, но зависит от JS.

SEO-чек-лист

Server Components:

  • Контент рендерится на сервере.
  • HTML полный сразу.
  • Метаданные через Metadata API.
  • Работает без JS.

Client Components:

  • Минимум контента.
  • Обёрнуты в Server.
  • Не критичны для SEO.
  • Ленивая гидратация.

Динамический импорт

Для тяжёлых:

import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
    ssr: false,
    loading: () => <div>Загрузка...</div>,
});

Что даёт: не грузится в первоначальном JS.

Правило: для интерактива — ок. Для контента — плохо.

Проверка

Как проверить:

  1. Отключить JS в браузере.
  2. Открыть страницу.
  3. Что видно?

Хорошо: весь контент. Плохо: пусто.

Также:

  • GSC → Проверка URL → Отрендеренная.
  • Screaming Frog → Rendered HTML.
  • curl — сырой HTML.

Что писать на Server

Server:

  • Контент.
  • Layouts.
  • Метаданные.
  • Данные из API/БД.
  • Формы (для SEO).
  • Списки.
  • Статьи.
  • Карточки.

Что на Client

Client:

  • Кнопки-тогглы.
  • Модалки.
  • Аккордеоны.
  • Слайдеры.
  • Формы с интерактивом.
  • Фильтры.
  • Тёмная тема.
  • Аналитика.
  • Онлайн-чаты.

Правило: не переносить контент в Client.

Пример: статья

Правильно:

// app/blog/[slug]/page.tsx (Server)
import LikeButton from './LikeButton';
import Comments from './Comments';

export async function generateMetadata({ params }) {
    const { slug } = await params;
    const post = await getPost(slug);

    return {
        title: post.title,
        description: post.excerpt,
    };
}

export default async function Page({ params }) {
    const { slug } = await params;
    const post = await getPost(slug);

    return (
        <article>
            <h1>{post.title}</h1>
            <div dangerouslySetInnerHTML={{ __html: post.content }} />
            <LikeButton postId={post.id} />
            <Comments postId={post.id} />
        </article>
    );
}

Что здесь:

  • Контент — Server.
  • Like — Client.
  • Comments — Client (динамика).

Практика

1. Проверьте свой проект.

2. Найдите 'use client' файлы.

3. Для каждого:

  • Можно ли сделать Server?
  • Что в нём интерактивного?
  • Можно ли вынести интерактив?

4. Перенесите контент в Server.

5. Оставьте Client только для интерактива.

6. Отключите JS — проверьте.

7. Прогоните Lighthouse.

8. Сравните до/после.

Частые ошибки

1. Весь проект 'use client':

Плохой SEO

Решение: минимизировать.

2. Контент через useEffect:

Поисковик не видит

Решение: fetch на сервере.

3. Метаданные в Client:

Не работает

Решение: Server.

4. Server внутри Client:

Ошибка

Решение: через children.

5. dynamic для контента:

SEO страдает

Решение: только для интерактива.

6. Не проверять без JS:

Не видеть проблем

Решение: тестировать.

7. Тяжёлая гидратация:

INP плохой

Решение: меньше Client.

8. Игнорировать Lighthouse:

Плохие метрики

Решение: регулярно.

Итог

  • Server — для контента и SEO.
  • Client — для интерактива.
  • По умолчанию — Server.
  • 'use client' — как можно ниже.
  • Метаданные — только Server.
  • Client оборачивать в Server.
  • dynamic(ssr: false) — только для интерактива.
  • Проверка без JS — обязательно.
  • Меньше Client — лучше CWV.
  • Lighthouse — регулярно.

Дальше — SEO в Next.js: Open Graph и OG-изображения.