У традиційних клієнт-серверних архітектурах фронтенд суворо ізольований від інфраструктури бази даних і взаємодіє з нею через проміжний шар REST або GraphQL API. В архітектурі Next.js App Router (React Server Components) парадигма кардинально змінилася: серверні компоненти та Server Actions виконуються безпосередньо на бекенді, що дозволяє робити прямі запити до баз даних без проміжних мережевих шлюзів.
Прямий доступ до даних усуває шаблонний код написання API-маршрутів, скорочує час затримки (RTT) та забезпечує наскрізну строгу типізацію від схеми бази даних до інтерфейсу користувача. Проте запуск коду в ефемерних середовищах 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 є асинхронним об'єктом Promise
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'. При спробі клієнтського імпорту збирач негайно зупинить білд із помилкою.