Next.js давно перестал быть просто «React с серверным рендерингом». С выходом App Router фреймворк превратился в полноценную платформу для сборки веб-приложений любой сложности. В этой статье мы пройдём по ключевым концепциям: от структуры маршрутизации до стратегий рендеринга и деплоя на собственный сервер.
App Router vs Pages Router
До версии 13 Next.js использовал Pages Router: каждый файл в папке pages/ становился маршрутом, а getServerSideProps / getStaticProps отвечали за получение данных. Подход простой, но имеет ограничения — логику данных нельзя было разместить прямо в компоненте.
App Router (стабилен с Next.js 13.4) меняет модель кардинально:
| Характеристика | Pages Router | App Router |
|---|---|---|
| Папка маршрутов | pages/ | app/ |
| Получение данных | getServerSideProps | async Server Component |
| Layouts | _app.tsx | layout.tsx |
| Streaming | ❌ | ✅ через Suspense |
| Вложенные layouts | ❌ | ✅ |
| Server Actions | ❌ | ✅ |
Оба роутера могут сосуществовать в одном проекте. Это позволяет мигрировать постепенно, не переписывая всё за один раз.
Серверные и клиентские компоненты
В App Router все компоненты по умолчанию — серверные (Server Components). Они рендерятся на сервере, не попадают в JS-бандл и могут напрямую обращаться к базе данных.
// app/products/page.tsx — Server Component
import { db } from '@/lib/db'
export default async function ProductsPage() {
const products = await db.product.findMany()
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name} — {p.price} ₽</li>
))}
</ul>
)
}
Нет useEffect, нет лишних HTTP-запросов с клиента, нет состояния. Просто асинхронная функция.
Client Components нужны тогда, когда требуется браузерное API, обработчики событий или хуки. Они помечаются директивой 'use client':
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
Правило композиции
Серверный компонент может импортировать клиентский. Обратное — невозможно напрямую. Если нужно передать серверный компонент внутрь клиентского, используйте паттерн children:
// ServerWrapper.tsx (Server Component)
import { ClientShell } from './ClientShell'
export default function ServerWrapper() {
return (
<ClientShell>
<HeavyServerContent /> {/* рендерится на сервере */}
</ClientShell>
)
}
Стратегии рендеринга
SSR (Server-Side Rendering)
Страница генерируется на каждый запрос. В App Router — это поведение по умолчанию для компонентов, использующих динамические данные:
// Отключаем кеш — страница всегда рендерится свежей
export const dynamic = 'force-dynamic'
export default async function Dashboard() {
const data = await fetch('/api/stats', { cache: 'no-store' })
// ...
}
Используйте SSR для страниц с персонализированным контентом, корзиной покупок, личным кабинетом.
SSG (Static Site Generation)
Страница генерируется один раз при сборке. Подходит для блогов, лендингов, документации:
// generateStaticParams — список всех путей
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map(p => ({ slug: p.slug }))
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return <article>{post.content}</article>
}
ISR (Incremental Static Regeneration)
Золотая середина: страница статична, но обновляется через заданный интервал:
export default async function ProductPage({ params }) {
const product = await fetch(`/api/products/${params.id}`, {
next: { revalidate: 3600 } // обновляем раз в час
})
// ...
}
Или по тегу — когда изменились конкретные данные:
// Запрос с тегом
const data = await fetch('/api/posts', { next: { tags: ['posts'] } })
// Инвалидация из Server Action или Route Handler
import { revalidateTag } from 'next/cache'
revalidateTag('posts')
PPR (Partial Prerendering)
Экспериментальная функция Next.js 14+. Позволяет статически отрендерить «оболочку» страницы и стримить динамические части через Suspense:
// next.config.ts
const config = {
experimental: { ppr: true }
}
// page.tsx
export default function Page() {
return (
<>
<StaticHeader /> {/* рендерится при сборке */}
<Suspense fallback={<Skeleton />}>
<DynamicFeed /> {/* стримится на клиент */}
</Suspense>
</>
)
}
Слои кеширования
Next.js имеет четыре независимых слоя кеша:
| Слой | Где хранится | Инвалидация |
|---|---|---|
| Request Memoization | In-memory, один запрос | Автоматически |
| Data Cache | Файловая система / CDN | revalidate, revalidateTag |
| Full Route Cache | Файловая система | Деплой или revalidatePath |
| Router Cache | Браузер | Навигация, router.refresh() |
Частая ошибка — думать, что cache: 'no-store' в fetch сбрасывает все кеши. Он отключает только Data Cache; Router Cache на клиенте живёт своей жизнью.
Оптимизация изображений
Компонент <Image> из next/image автоматически:
- конвертирует в WebP/AVIF
- задаёт правильные
widthиheightдля предотвращения CLS - реализует lazy loading
- генерирует
srcsetдля разных размеров экрана
import Image from 'next/image'
export function ProductCard({ product }) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
sizes="(max-width: 768px) 100vw, 400px"
priority={false}
/>
)
}
Для изображений выше линии сгиба (hero, логотип) добавляйте priority={true} — это убирает lazy loading и добавляет <link rel="preload">.
Внешние домены
Чтобы <Image> мог загружать изображения с внешних URL, нужно прописать домены в конфиге:
// next.config.ts
const config = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' }
]
}
}
Деплой на VPS: PM2 и Docker
Вариант 1 — PM2
Подходит для быстрого старта. Собираем проект и запускаем через PM2:
npm run build
pm2 start npm --name "nextjs-app" -- start
pm2 save
pm2 startup
ecosystem.config.js для управления переменными окружения:
module.exports = {
apps: [{
name: 'nextjs-app',
script: 'node_modules/.bin/next',
args: 'start',
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
}
Nginx проксирует запросы к порту 3000:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Вариант 2 — Docker
Многоэтапная сборка (multi-stage build) существенно уменьшает размер финального образа. Подробнее об оптимизации Docker-образов — в статье Оптимизация Dockerfile.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Для standalone-режима добавьте в next.config.ts:
const config = {
output: 'standalone'
}
Частые ошибки и как их избежать
1. Слишком много Client Components. Выносите интерактивность «на листья» дерева компонентов — оставляйте максимум логики на сервере.
2. Дублирование запросов. Если несколько серверных компонентов запрашивают одни данные — оберните запрос в функцию с cache() из React:
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } })
})
3. Игнорирование метаданных. Для SEO используйте Metadata API вместо ручного <head>:
export async function generateMetadata({ params }) {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: { images: [post.coverImage] }
}
}
Итог
App Router — не просто новый способ писать маршруты. Это смена парадигмы: данные ближе к компонентам, меньше клиентского JS, гибкое кеширование. Начните с простых страниц на серверных компонентах, добавляйте клиентские только там, где нужна интерактивность, и выбирайте стратегию рендеринга под конкретный сценарий.
Если вы хотите ускорить разработку с помощью AI-инструментов — читайте статью Скорость разработки с AI.

