В традиционных клиент-серверных архитектурах фронтенд изолирован от базы данных и взаимодействует с ней через прослойку REST или GraphQL API. В архитектуре Next.js App Router (React Server Components) парадигма кардинально изменилась: серверные компоненты и Server Actions исполняются непосредственно на бэкенде, что позволяет выполнять прямые запросы к базам данных без промежуточных сетевых шлюзов.
Прямой доступ к данным устраняет бойлерплейт написания API-роутов, сокращает задержки (RTT) и обеспечивает сквозную строгую типизацию от схемы базы данных до UI. Однако запуск кода в эфемерных Serverless и Edge Runtime средах вносит критические архитектурные ограничения. Классические реляционные базы данных (PostgreSQL, MySQL) рассчитаны на постоянные долгоживущие TCP-соединения, тогда как сотни параллельных бессерверных лямбд способны мгновенно вызвать Connection Pooling Exhaustion (ошибку исчерпания лимита подключений).
Понимание паттернов работы с ORM, архитектуры изоляции данных и правильного управления соединениями в Serverless — фундаментальное требование для построения высоконагруженных приложений на Next.js.
В экосистеме современного Fullstack Next.js для взаимодействия с базой данных используются два доминирующих инструмента: Prisma и Drizzle ORM. Они представляют противоположные архитектурные философии:
schema.prisma. Запросы транслируются через бинарный движок (Rust Query Engine). Обеспечивает максимальный комфорт разработки (DX), автодополнение и мощные миграции, но создает дополнительный оверхед по памяти и холодному старту в Serverless.Запросы к базе данных разрешено выполнять строго на сервере: в Server Components, Server Actions или Route Handlers. Импорт клиентов БД или файлов доступа к данным в Client Components ('use client') запрещен и блокируется с помощью пакета server-only.
Клиент отправляет FormData или вызывает экшен через React 19 хук useActionState(). Никаких ID пользователя в открытом виде не передается.
Сервер извлекает защищенную сессию через auth(). Если сессия невалидна, экшен мгновенно завершается с ошибкой авторизации.
Параметры мутации парсятся схемой Zod. Внутри блока db.$transaction гарантируется атомарность: либо выполняются все шаги, либо происходит автоматический ROLLBACK.
После успешного коммита сбрасываются соответствующие пути/теги кэша (revalidatePath) и клиенту возвращается сериализуемый статус для обновления интерфейса.
В процессе локальной разработки Next.js использует механизм Fast Refresh (HMR). При сохранении исходного кода модуль перезагружается, что при наивной инициализации new PrismaClient() создавало бы новое подключение на каждый HMR-триггер. Для предотвращения утечки соединений инстанс сохраняется в глобальном объекте globalThis.
import 'server-only';
import { PrismaClient } from '@prisma/client';
const prismaClientSingleton = () => {
return new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
};
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
};
// Переиспользуем клиент в development или создаем новый в production
export const db = globalForPrisma.prisma ?? prismaClientSingleton();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}В бессерверных средах и Edge Runtime классические TCP-соединения заменяются на трансляцию SQL-запросов через HTTP. Ниже приведен пример декларации схемы и инициализации легковесного клиента Drizzle с драйвером Neon Serverless.
import 'server-only';
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
import { eq } from 'drizzle-orm';
// Декларативная схема таблицы в чистом TypeScript
export const usersTable = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
balance: integer('balance').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Инициализация HTTP-клиента: не требует Singleton, не удерживает постоянный сокет
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
// Типобезопасная выборка данных
export async function getActiveUsers() {
return db
.select({
id: usersTable.id,
name: usersTable.name,
email: usersTable.email,
})
.from(usersTable)
.limit(50);
}Серверные компоненты асинхронны по умолчанию. Данные из базы данных извлекаются напрямую без промежуточного fetch().
import { db } from '@/lib/db';
import { Suspense } from 'react';
interface UsersPageProps {
searchParams: Promise<{ role?: string }>;
}
export default async function UsersPage({ searchParams }: UsersPageProps) {
// В Next.js 15+ searchParams являются асинхронным объектом
const { role } = await searchParams;
const users = await db.user.findMany({
where: role ? { role } : undefined,
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, email: true }, // Защита: выбираем только необходимые поля
take: 50,
});
if (users.length === 0) {
return (
<div className="p-8 text-center text-text-muted">
Пользователи не найдены.
</div>
);
}
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold text-text-primary">Список пользователей</h1>
<ul className="divide-y divide-border rounded-lg border border-border">
{users.map((user) => (
<li key={user.id} className="p-4 flex justify-between items-center">
<span className="font-medium text-text-primary">{user.name}</span>
<span className="text-xs text-text-muted font-mono">{user.email}</span>
</li>
))}
</ul>
</div>
);
}Приложение на базе Prisma/Drizzle подключается к PostgreSQL через пулер PgBouncer в режиме Transaction Mode (порт 6543). Почему при повторных запросах возникает фатальная ошибка 'prepared statement already exists' или 'does not exist'?
При выполнении операций, модифицирующих несколько связанных таблиц (например, списание баланса и создание заказа), обязательна изоляция через транзакции:
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; // Внутренний хелпер получения сессии
import { revalidatePath } from 'next/cache';
const CheckoutSchema = z.object({
amount: z.coerce.number().positive('Сумма должна быть больше нуля').max(1_000_000),
description: z.string().min(3).max(255).optional(),
});
export type CheckoutState = {
status: 'idle' | 'success' | 'error';
message?: string;
};
export async function checkoutAction(
prevState: CheckoutState,
formData: FormData
): Promise<CheckoutState> {
// 1. Извлекаем пользователя строго из сессии на сервере (защита от IDOR)
const session = await auth();
if (!session?.user?.id) {
return { status: 'error', message: 'Требуется авторизация.' };
}
// 2. Валидация входных данных через Zod
const validated = CheckoutSchema.safeParse({
amount: formData.get('amount'),
description: formData.get('description') || undefined,
});
if (!validated.success) {
return {
status: 'error',
message: validated.error.issues[0].message,
};
}
const { amount, description } = validated.data;
const userId = session.user.id;
try {
// 3. Атомарная транзакция в базе данных
await db.$transaction(async (tx) => {
// Блокируем и проверяем текущий баланс
const user = await tx.user.findUnique({
where: { id: userId },
select: { balance: true },
});
if (!user || user.balance < amount) {
throw new Error('INSUFFICIENT_FUNDS'); // Вызывает немедленный ROLLBACK
}
// Списываем баланс
await tx.user.update({
where: { id: userId },
data: { balance: { decrement: amount } },
});
// Фиксируем заказ
await tx.order.create({
data: {
userId,
amount,
status: 'COMPLETED',
description: description ?? 'Оплата заказа',
},
});
});
// 4. Инвалидация серверного кэша
revalidatePath('/dashboard/orders');
return { status: 'success', message: 'Заказ успешно оплачен!' };
} catch (err: unknown) {
const isFundsError = err instanceof Error && err.message === 'INSUFFICIENT_FUNDS';
return {
status: 'error',
message: isFundsError ? 'Недостаточно средств на балансе.' : 'Ошибка проведения платежа.',
};
}
}Размещение прямых вызовов ORM по десяткам серверных компонентов приводит к дублированию логики, утечкам прав доступа и трудностям при рефакторинге. Рекомендуемый паттерн архитектуры Next.js — выделение Data Access Layer (DAL).
DAL решает 3 ключевые задачи:
cache() обеспечивает мемоизацию запросов внутри одного жизненного цикла рендеринга страницы (Request Memoization).import 'server-only' гарантирует невозможность импорта слоя на клиент.import 'server-only';
import { cache } from 'react';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
// DTO интерфейс: строго определяет, какие поля уходят наружу
export interface UserProfileDTO {
id: string;
name: string;
email: string;
avatarUrl: string | null;
}
// React cache() кэширует результат промиса на время выполнения одного серверного рендера
export const getCurrentUser = cache(async (): Promise<UserProfileDTO | null> => {
const session = await auth();
if (!session?.user?.id) return null;
const user = await db.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
// passwordHash: ИСКЛЮЧЕНО — никогда не попадет в RSC Flight Payload
},
});
return user;
});
export const requireAdmin = cache(async () => {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { role: true },
});
if (user?.role !== 'ADMIN') {
redirect('/unauthorized');
}
});Серверный компонент выполняет запрос: const user = await db.user.findUnique({ where: { id } }); и передает объект user в клиентский компонент: <ProfileCard user={user} />. В чем заключается архитектурная уязвимость?
В Serverless-среде (Vercel, Cloudflare, Netlify) при всплеске трафика поднимаются сотни независимых микросервисов. Каждая лямбда открывает соединения с БД. Стандартная база данных Postgres выдерживает в среднем 50–100 одновременных подключений. Превышение лимита приводит к каскадному падению приложения с ошибкой Too many connections.
Решение: Обязательно подключайтесь через пулер соединений (PgBouncer, AWS RDS Proxy, Supabase Pooler) на порту 6543 в режиме Transaction Mode, либо используйте Serverless-first базы данных (Neon) с HTTP-драйверами.
При вызове Server Action клиент полностью контролирует передаваемые аргументы. Если передавать userId как аргумент checkoutAction(userId, amount), любой пользователь сможет открыть DevTools в браузере и вызвать мутацию с ID администратора или другого клиента.
Решение: Никогда не принимайте идентификатор пользователя из входных параметров мутации. Извлекайте пользователя только из защищенной серверной сессии (auth()) непосредственно внутри тела Server Action.
При подключении Prisma или Drizzle к пулеру соединений в режиме Transaction Mode СУБД сбрасывает состояние сессии после каждого коммита. Стандартные Prepared Statements, сохраняемые ORM на уровне сессии, вызывают фатальную ошибку: ERROR: prepared statement "s0" already exists или does not exist.
Решение:
?pgbouncer=true к строке подключения в DATABASE_URL, а для запуска миграций используйте прямой порт 5432 через переменную DIRECT_URL.drizzle(client, { prepare: false }).Если родительский Server Component запрашивает список постов, а каждый вложенный дочерний компонент выполняет свой отдельный await db.author.findUnique(), возникает классический водопад запросов (Waterfall). В Serverless сетевая задержка каждого запроса (10–30 мс) суммируется, приводя к критическому падению TTFB страницы.
Решение: Выполняйте агрегированные реляционные запросы на уровне страницы через findMany({ include: { author: true } }) в Prisma или реляционные выборки в Drizzle (with: { author: true }), либо параллелизуйте независимые запросы через Promise.all().
Если файл с клиентом БД или запросами случайно импортируется в компонент с директивой 'use client', это приведет либо к фатальной ошибке сборки (браузер не имеет сокетов Node.js), либо к утечке конфиденциальных реквизитов подключения в клиентский бандл.
Решение: Всегда первой строкой в модулях базы данных (lib/db.ts) и файлах DAL указывайте import 'server-only'. При попытке клиентского импорта сборщик немедленно остановит сборку с ошибкой.