Пошук уроків, статей та іншого контенту
Навчитеся підключати серверний код до бази даних і безпечно виконувати CRUD-операції.
У Next.js код, який звертається до бази даних, має виконуватися на сервері. Це важливо з двох причин:
дані для підключення до бази не потрапляють у браузер;
операції з базою можна контролювати та перевіряти до їх виконання.
У цьому уроці використаємо:
SQLite — просту файлову базу даних для навчального проєкту;
Prisma — ORM для роботи з базою даних;
Route Handlers Next.js — серверні HTTP-маршрути для CRUD-операцій.
CRUD — це чотири основні операції:
Create — створення запису;
Read — читання записів;
Update — оновлення запису;
Delete — видалення запису.
У вже створеному Next.js-проєкті встановіть Prisma:
npm install @prisma/client
npm install --save-dev prismaІніціалізуйте Prisma з SQLite:
npx prisma init --datasource-provider sqliteПісля цього з’являться:
папка prisma;
файл prisma/schema.prisma;
файл .env.
У .env буде рядок підключення:
DATABASE_URL="file:./dev.db"SQLite зберігатиме дані у файлі dev.db. Цей файл з’явиться після виконання міграції.
Файл
.envне потрібно додавати до репозиторію, якщо він містить секрети або підключення до справжньої бази даних.
Відкрийте prisma/schema.prisma і замініть його вміст на такий:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Task {
id Int @id @default(autoincrement())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
}Модель Task описує таблицю завдань:
id — унікальний числовий ідентифікатор;
title — текст завдання;
completed — ознака виконання;
createdAt — дата створення.
Створіть таблицю та згенеруйте Prisma Client:
npx prisma migrate dev --name init
npx prisma generateКоманда migrate dev створює міграцію та застосовує її до локальної бази даних.
Створіть файл lib/prisma.ts:
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}PrismaClient використовується для виконання запитів до бази даних.
У режимі розробки Next.js може багаторазово перезавантажувати серверний код. Глобальне збереження клієнта допомагає не створювати нове підключення під час кожного перезавантаження.
Файл lib/prisma.ts імпортується тільки серверним кодом. Не імпортуйте його в клієнтські компоненти.
Створіть файл:
app/api/tasks/route.tsДодайте до нього обробники GET і POST:
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export const runtime = 'nodejs';
export async function GET() {
const tasks = await prisma.task.findMany({
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json(tasks);
}
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: 'Некоректний JSON' },
{ status: 400 },
);
}
if (
typeof body !== 'object' ||
body === null ||
!('title' in body) ||
typeof body.title !== 'string'
) {
return NextResponse.json(
{ error: 'Поле title має бути рядком' },
{ status: 400 },
);
}
const title = body.title.trim();
if (title.length === 0 || title.length > 100) {
return NextResponse.json(
{ error: 'Довжина title має бути від 1 до 100 символів' },
{ status: 400 },
);
}
const task = await prisma.task.create({
data: {
title,
},
});
return NextResponse.json(task, { status: 201 });
}Тепер маршрут підтримує:
GET /api/tasks
POST /api/tasksМетод GET отримує всі завдання з бази.
Метод POST:
читає JSON із запиту;
перевіряє, що поле title існує і має тип string;
видаляє зайві пробіли на початку та в кінці;
перевіряє довжину тексту;
створює запис у базі.
Для роботи з конкретним завданням створіть файл:
app/api/tasks/[id]/route.tsСимволи [id] означають динамічну частину URL. Наприклад:
/api/tasks/3У файл додайте обробники PATCH і DELETE:
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export const runtime = 'nodejs';
type RouteContext = {
params: Promise<{ id: string }>;
};
function getTaskId(id: string) {
const taskId = Number(id);
if (!Number.isInteger(taskId) || taskId <= 0) {
return null;
}
return taskId;
}
export async function PATCH(
request: Request,
{ params }: RouteContext,
) {
const { id } = await params;
const taskId = getTaskId(id);
if (taskId === null) {
return NextResponse.json(
{ error: 'Некоректний ідентифікатор' },
{ status: 400 },
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: 'Некоректний JSON' },
{ status: 400 },
);
}
if (typeof body !== 'object' || body === null) {
return NextResponse.json(
{ error: 'Тіло запиту має бути об’єктом' },
{ status: 400 },
);
}
const data: {
title?: string;
completed?: boolean;
} = {};
if ('title' in body) {
if (typeof body.title !== 'string') {
return NextResponse.json(
{ error: 'Поле title має бути рядком' },
{ status: 400 },
);
}
const title = body.title.trim();
if (title.length === 0 || title.length > 100) {
return NextResponse.json(
{ error: 'Довжина title має бути від 1 до 100 символів' },
{ status: 400 },
);
}
data.title = title;
}
if ('completed' in body) {
if (typeof body.completed !== 'boolean') {
return NextResponse.json(
{ error: 'Поле completed має бути логічним значенням' },
{ status: 400 },
);
}
data.completed = body.completed;
}
if (Object.keys(data).length === 0) {
return NextResponse.json(
{ error: 'Немає даних для оновлення' },
{ status: 400 },
);
}
const existingTask = await prisma.task.findUnique({
where: { id: taskId },
});
if (!existingTask) {
return NextResponse.json(
{ error: 'Завдання не знайдено' },
{ status: 404 },
);
}
const task = await prisma.task.update({
where: { id: taskId },
data,
});
return NextResponse.json(task);
}
export async function DELETE(
_request: Request,
{ params }: RouteContext,
) {
const { id } = await params;
const taskId = getTaskId(id);
if (taskId === null) {
return NextResponse.json(
{ error: 'Некоректний ідентифікатор' },
{ status: 400 },
);
}
const existingTask = await prisma.task.findUnique({
where: { id: taskId },
});
if (!existingTask) {
return NextResponse.json(
{ error: 'Завдання не знайдено' },
{ status: 404 },
);
}
await prisma.task.delete({
where: { id: taskId },
});
return new Response(null, { status: 204 });
}Тепер доступні всі основні CRUD-операції:
GET /api/tasks
POST /api/tasks
PATCH /api/tasks/:id
DELETE /api/tasks/:idУ сучасних версіях Next.js параметри динамічного маршруту потрібно отримати через await params.
Запустіть сервер розробки:
npm run devОтримати список завдань:
curl http://localhost:3000/api/tasksСтворити завдання:
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Вивчити Prisma"}'Оновити завдання з ідентифікатором 1:
curl -X PATCH http://localhost:3000/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"completed":true}'Змінити назву завдання:
curl -X PATCH http://localhost:3000/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title":"Повторити CRUD-операції"}'Видалити завдання:
curl -X DELETE http://localhost:3000/api/tasks/1Prisma формує запити до бази даних на основі переданих параметрів. Значення з title або id не вставляються безпосередньо в SQL-рядок.
Наприклад, не потрібно самостійно будувати SQL так:
// Небезпечний підхід: не вставляйте введені користувачем дані в SQL-рядок
const query = `SELECT * FROM Task WHERE title = '${title}'`;Замість цього використовуйте методи Prisma:
const tasks = await prisma.task.findMany({
where: {
title,
},
});Крім цього, серверний код має перевіряти дані навіть тоді, коли на клієнті вже є перевірка. Клієнтські перевірки можна обійти, надіславши запит безпосередньо до API.
Рядок підключення до бази даних зберігається в .env:
DATABASE_URL="file:./dev.db"Серверний код може читати DATABASE_URL через process.env.DATABASE_URL.
Не додавайте префікс NEXT_PUBLIC_ до змінних, які не повинні бути доступні браузеру. Змінні з таким префіксом Next.js може включити до клієнтського коду.
Правильно:
DATABASE_URL="file:./dev.db"Неправильно для підключення до бази даних:
NEXT_PUBLIC_DATABASE_URL="file:./dev.db"У реальному застосунку потрібно також обмежити доступ до CRUD-маршрутів автентифікованими користувачами. У прикладі маршрути відкриті, щоб зосередитися на підключенні до бази та самих операціях.
PrismaClient має використовуватися тільки на сервері. Не імпортуйте lib/prisma.ts у компонент із директивою 'use client'.
Серверний код можна розміщувати:
у Route Handlers;
у серверних компонентах;
в інших серверних модулях.
Якщо Prisma повідомляє, що таблиця не існує, виконайте:
npx prisma migrate dev --name initПісля зміни моделі також потрібно створити нову міграцію:
npx prisma migrate dev --name add_fieldПеревірка форми в браузері не є достатньою. Користувач може відправити запит через інший інструмент.
Перевіряйте на сервері:
типи полів;
обов’язкові поля;
довжину рядків;
допустимі значення;
ідентифікатори записів.
Значення з URL завжди приходить як рядок. Перед передаванням до Prisma його потрібно перетворити на число та перевірити:
const taskId = Number(id);
if (!Number.isInteger(taskId) || taskId <= 0) {
// Повернути помилку клієнту
}У режимі розробки це може призвести до великої кількості підключень. Для цього використовуйте один екземпляр PrismaClient, збережений у globalThis, як у файлі lib/prisma.ts.
У Next.js робота з базою даних виконується на сервері. Для цього потрібно:
встановити Prisma та драйвер клієнта;
описати моделі в schema.prisma;
створити міграцію;
створити спільний екземпляр PrismaClient;
використовувати Route Handlers для CRUD-операцій;
перевіряти всі дані на сервері;
зберігати рядок підключення в змінних середовища;
не передавати Prisma Client у клієнтський код.
У результаті API застосунку може читати, створювати, оновлювати та видаляти записи в базі даних без ручного складання небезпечних SQL-запитів.