База данных
Статьи из массива в app/data.server.ts пропадают при каждом перезапуске сервера. Их нужно хранить в базе данных. React Router не навязывает конкретную базу: загрузчики и действия — обычный серверный код, из которого можно обращаться к чему угодно. В курсе используется SQLite — база в одном файле, которую не нужно отдельно устанавливать, и пакет better-sqlite3.
Установка
npm install better-sqlite3
npm install -D @types/better-sqlite3Первая команда ставит драйвер базы, вторая — его типы для TypeScript.
Подключение и таблицы
Создайте файл app/db.server.ts:
// app/db.server.ts
import Database from "better-sqlite3";
export const db = new Database("blog.db");
db.exec(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL REFERENCES posts(id),
author TEXT NOT NULL,
text TEXT NOT NULL
);
`);При первом запросе к серверу в корне проекта появится файл blog.db с двумя таблицами. CREATE TABLE IF NOT EXISTS не трогает таблицы, которые уже есть. В большом проекте вместо этого используют миграции, но для начала так проще.
Функции данных
Перепишите app/data.server.ts, оставив имена функций прежними:
// app/data.server.ts
import { db } from "./db.server";
export type Post = { id: number; title: string; body: string };
export type Comment = { id: number; postId: number; author: string; text: string };
export async function getPosts() {
return db.prepare("SELECT id, title, body FROM posts ORDER BY id DESC").all() as Post[];
}
export async function getPost(id: number) {
const row = db.prepare("SELECT id, title, body FROM posts WHERE id = ?").get(id);
return (row as Post | undefined) ?? null;
}
export async function createPost(title: string, body: string) {
const result = db.prepare("INSERT INTO posts (title, body) VALUES (?, ?)").run(title, body);
return { id: Number(result.lastInsertRowid), title, body };
}
export async function getComments(postId: number) {
return db
.prepare("SELECT id, post_id AS postId, author, text FROM comments WHERE post_id = ?")
.all(postId) as Comment[];
}
export async function addComment(postId: number, author: string, text: string) {
db.prepare("INSERT INTO comments (post_id, author, text) VALUES (?, ?, ?)").run(postId, author, text);
}Загрузчики и действия ничего не заметили: они вызывают те же функции. Добавьте статью через форму /posts/new, перезапустите npm run dev — статья на месте.
Методы better-sqlite3
| Метод | Возвращает |
|---|---|
prepare(sql).all(...) | Массив строк |
prepare(sql).get(...) | Одну строку или undefined |
prepare(sql).run(...) | Сведения о записи: changes, lastInsertRowid |
Пакет работает синхронно, поэтому без await; функции модуля остаются async, чтобы базу можно было сменить. Для PostgreSQL или MySQL часто берут ORM — Prisma или Drizzle; подход тот же: подключение в файле .server.ts, вызовы из loader и action.
Частая ошибка
Подставлять значения прямо в текст запроса:
// app/data.server.ts
// Неправильно:
db.prepare(`SELECT * FROM posts WHERE id = ${id}`).get();
// Правильно:
db.prepare("SELECT * FROM posts WHERE id = ?").get(id);Значение пришло из адреса, и в него можно вписать кусок SQL — это SQL-инъекция. Передавайте значения только через знаки ? и аргументы get, all, run.