В современной веб-разработке Next.js 16 с архитектурой App Router стер искусственную границу между фронтендом и бэкендом. По умолчанию каждый компонент является React Server Component. Больше нет необходимости вручную создавать сотни шаблонных контроллеров REST API, синхронизировать DTO-типы и использовать useEffect для загрузки данных. Серверные компоненты запрашивают данные напрямую из PostgreSQL через ORM Prisma, а любые мутации данных выполняются через Server Actions.
Традиционный подход с разделением на отдельный SPA (React) и отдельный REST API (Express/NestJS) порождает следующие трудности:
NEXT_PUBLIC_) приводит к фатальным компрометациям безопасности.Архитектура Next.js 16 делит дерево компонентов на серверную и клиентскую среды:
[ Серверный контур Node.js (RSC Environment) ]
│
┌───────────────────┴───────────────────┐
▼ ▼
[ RootLayout (Server) ] [ DashboardPage (Server) ]
- Без клиентского JS - Прямой вызов prisma.user.findMany()
- Чтение headers/cookies - SQL выполняется прямо на сервере
│ │
└───────────────────┬───────────────────┘
│
▼ Граница клиента: 'use client'
┌────────────────────────────────────────────────────────┐
│ [ InteractiveButton (Client Component) ] │
│ - Отправляется в браузер │
│ - Поддерживает useState, onClick, useEffect │
│ - Вызывает Server Action (RPC вызов) ──────────────┐ │
└──────────────────────────────────────────────────────│─┘
│
▼
[ 'use server' Action ]
- Выполняется на сервере
- Мутирует базу данных
- Вызывает revalidatePath()Создание чистого проекта с TypeScript, App Router, Tailwind CSS и директорией src/.
Определение сущностей в prisma/schema.prisma и создание синглтона PrismaClient для предотвращения утечек соединений.
Создание серверных компонентов разметки layout.tsx, отображения page.tsx и скелетона loading.tsx.
Получение данных из PostgreSQL напрямую через await prisma.post.findMany() прямо в теле серверного компонента.
Создание серверных функций с 'use server', валидацией Zod и мгновенной инвалидацией кэша страницы через revalidatePath.
Разработчик написал асинхронный компонент `export default async function Page() { const data = await prisma.user.findMany(); return <div>...</div> }` без директивы 'use client'. Где физически выполняется код подключения к базе данных и сколько килобайт кода Prisma попадет в бандл браузера?
Next.js анализирует URL, сопоставляет сегменты маршрута и собирает цепочку серверных макетов layout.tsx и page.tsx.
Компоненты layout не перерисовываются при навигации между дочерними страницами (Preserved State).
Асинхронные серверные компоненты выполняют прямые запросы к СУБД с минимальной задержкой (RTT внутри локальной сети дата-центра менее 1 мс).
Полное отсутствие публичных эндпоинтов REST API для чтения данных.
Сервер транслирует отрендеренное виртуальное дерево в бинарный поток Flight Payload параллельно с генерацией первичного HTML.
Клиентские компоненты получают данные как готовые пропсы без повторных сетевых вызовов.
Пользователь отправляет форму: браузер делает POST-запрос на специальный URL Server Action, бэкенд мутирует базу и инвалидирует кэш через revalidatePath.
Интерфейс обновляется без полной перезагрузки страницы (Soft Navigation).
Ниже приведены файлы эталонного полнофункционального стека.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Article {
id String @id @default(uuid())
title String @db.VarChar(200)
slug String @unique @db.VarChar(220)
content String @db.Text
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([slug])
@@map("articles")
}import { PrismaClient } from '@prisma/client';
// Предотвращаем создание множественных экземпляров при горячей перезагрузке Next.js в dev-режиме
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}'use server';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const CreateArticleSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 chars').max(200),
content: z.string().min(20, 'Content must be at least 20 chars'),
});
export type ActionState = {
success: boolean;
message?: string;
errors?: Record<string, string[]>;
};
export async function createArticleAction(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
};
const validated = CreateArticleSchema.safeParse(rawData);
if (!validated.success) {
return {
success: false,
message: 'Validation failed',
errors: validated.error.flatten().fieldErrors,
};
}
const { title, content } = validated.data;
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '') + '-' + Date.now().toString().slice(-4);
try {
await prisma.article.create({
data: {
title,
slug,
content,
published: true,
},
});
// Инвалидируем кэш маршрута, чтобы сервер отдал обновленные данные
revalidatePath('/articles');
return {
success: true,
message: 'Article published successfully!',
};
} catch (err: unknown) {
console.error('Failed to create article:', err);
return {
success: false,
message: 'Database error occurred while creating article',
};
}
}import React from 'react';
import { prisma } from '@/lib/prisma';
import { CreateArticleForm } from './ui/create-article-form';
export const dynamic = 'force-dynamic'; // Всегда запрашивать актуальные данные
export default async function ArticlesPage() {
// Прямой доступ к PostgreSQL без fetch() и REST API
const articles = await prisma.article.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
});
return (
<div className="mx-auto max-w-4xl px-4 py-12">
<header className="mb-8 border-b border-slate-800 pb-6">
<h1 className="text-3xl font-extrabold text-white">Управление статьями</h1>
<p className="text-slate-400 mt-2">Next.js 16 Server Components + PostgreSQL</p>
</header>
<div className="grid gap-8 md:grid-cols-2">
<section>
<h2 className="text-xl font-bold text-slate-100 mb-4">Опубликовать статью</h2>
<CreateArticleForm />
</section>
<section>
<h2 className="text-xl font-bold text-slate-100 mb-4">Все статьи ({articles.length})</h2>
<div className="space-y-4">
{articles.length === 0 ? (
<p className="text-slate-500 italic">Статей пока нет.</p>
) : (
articles.map((item) => (
<article
key={item.id}
className="rounded-xl border border-slate-800 bg-slate-900/60 p-5 transition hover:border-violet-500/40"
>
<h3 className="font-semibold text-lg text-violet-300">{item.title}</h3>
<p className="mt-2 text-sm text-slate-300 line-clamp-3">{item.content}</p>
<time className="mt-4 block text-xs text-slate-500 font-mono">
{item.createdAt.toLocaleDateString('ru-RU')}
</time>
</article>
))
)}
</div>
</section>
</div>
</div>
);
}'use client';
import React, { useActionState } from 'react';
import { createArticleAction, ActionState } from '@/app/actions/articles';
const initialState: ActionState = {
success: false,
};
export const CreateArticleForm: React.FC = () => {
const [state, formAction, isPending] = useActionState(createArticleAction, initialState);
return (
<form action={formAction} className="rounded-xl border border-slate-800 bg-slate-900/40 p-6 space-y-4">
{state.message && (
<div
className={`p-3 rounded-lg text-sm ${
state.success
? 'bg-emerald-950/40 border border-emerald-500/30 text-emerald-300'
: 'bg-red-950/40 border border-red-500/30 text-red-300'
}`}
>
{state.message}
</div>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium text-slate-300 mb-1">
Заголовок статьи
</label>
<input
id="title"
name="title"
type="text"
required
placeholder="Введение в React Server Components"
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-white placeholder-slate-500 focus:border-violet-500 focus:outline-none"
/>
{state.errors?.title && (
<p className="mt-1 text-xs text-red-400">{state.errors.title.join(', ')}</p>
)}
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium text-slate-300 mb-1">
Содержимое статьи
</label>
<textarea
id="content"
name="content"
rows={5}
required
placeholder="Подробный архитектурный разбор механизма рендеринга..."
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-white placeholder-slate-500 focus:border-violet-500 focus:outline-none"
/>
{state.errors?.content && (
<p className="mt-1 text-xs text-red-400">{state.errors.content.join(', ')}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-lg bg-violet-600 px-4 py-2.5 font-medium text-white transition hover:bg-violet-700 disabled:opacity-50"
>
{isPending ? 'Публикация на сервере...' : 'Создать статью'}
</button>
</form>
);
};Если по ошибке поместить директиву 'use client' в верхнюю строчку корневого app/layout.tsx, все дочерние страницы и компоненты автоматически превратятся в Client Components. Вы полностью лишитесь серверной выборки данных, увеличите JS-бандл на мегабайты и потеряете преимущества кэширования Next.js. Держите директиву 'use client' как можно ниже в дереве компонентов («листовые компоненты»).
В режиме разработки (next dev) сборщик перекомпилирует код при каждом сохранении файла. Если инициализировать const prisma = new PrismaClient() напрямую, каждый ребилд будет открывать новый пул подключений к PostgreSQL. Спустя 10-15 сохранений база данных упадет с ошибкой FATAL: remaining connection slots are reserved for non-replication superuser connections. Всегда используйте синглтон, привязанный к globalThis.
Поскольку общение между RSC и Client Components происходит через сериализованный протокол React Flight, пропсы обязаны быть JSON-сериализуемыми. Попытка передать экземпляр класса с методами (например, new Date() без приведения к ISO-строке, Map, Set или функции обратного вызова) вызовет фатальную ошибку сериализации Functions cannot be passed directly to Client Components.
Помните: любая Server Action с директивой 'use server' является публично доступным HTTP POST-эндпоинтом! Злоумышленник может отправить произвольный запрос с любым полезным пейлоадом напрямую на этот RPC-эндпоинт без использования вашей веб-формы. Всегда проверяйте права текущего пользователя (сессию) и валидируйте входные данные схемой Zod внутри тела Server Action.
Разработчик создал файл с функцией 'use server' export async function deleteArticle(id: string) { await prisma.article.delete({ where: { id } }); }. Форма удаления доступна только в скрытом меню администратора. В чем заключается критическая уязвимость безопасности?