RU
UAH

Мини-блог на Node.js: Express + SQLite

Что вы получите

Blog Мини-приложение на Express, которое:

  • Мини-движок блога: список записей / просмотр / добавление / удаление
  • Хранение в SQLite (один файл data.sqlite)
  • Готов к деплою на shared-хостинге с ISPmanager (порт из process.env.PORT, статика, простые формы)
  • Базовая защита: валидация полей, экранирование HTML, rate-limit на POST

Подходит для быстрого старта, тестов и простых лендингов/мини-сервисов.

Структура проекта

mini-blog/
+-- package.json
+-- app.js
+-- db.js
+-- .env # опционально для локалки
+-- public/
| +-- style.css
+-- views/
    +-- layout-start.html
    +-- layout-end.html
    +-- index.html # список постов
    +-- view.html # просмотр поста
    +-- new.html # форма добавления

package.json


{
  "name": "mini-blog",
  "version": "1.0.0",
  "description": "Simple CRUD blog on Express + SQLite for shared hosting (ISPmanager).",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "start:prod": "NODE_ENV=production node app.js",
    "initdb": "node db.js --init"
  },
  "engines": {
    "node": ">=18.x"
  },
  "dependencies": {
    "dotenv": "^16.4.5",
    "express": "^4.19.2",
    "express-rate-limit": "^7.4.0",
    "express-validator": "^7.2.0",
    "morgan": "^1.10.0",
    "sqlite3": "^5.1.7",
    "helmet": "^7.1.0"
  }
}
	
	

Если сборка sqlite3 на шареде вдруг не пройдёт, попробуйте другой минор sqlite3 или используйте предсобранную Node-версию (обычно LTS) в ISPmanager.


// db.js
const fs = require('fs');
const path = require('path');
const sqlite3 = require('sqlite3').verbose();

const DB_PATH = path.join(__dirname, 'data.sqlite');

function connect() {
  return new sqlite3.Database(DB_PATH);
}

function init() {
  const db = connect();
  db.serialize(() => {
    db.run(`
      CREATE TABLE IF NOT EXISTS posts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        content TEXT NOT NULL,
        created_at INTEGER NOT NULL
      )
    `);
    db.run(`CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC)`);
  });
  db.close();
  console.log('Database initialized:', DB_PATH);
}

function seed() {
  const db = connect();
  const now = Date.now();
  db.serialize(() => {
    db.run(
      `INSERT INTO posts (title, content, created_at) VALUES (?, ?, ?)`,
      ['Добро пожаловать', 'Это первый пост в мини-блоге на Express + SQLite.', now]
    );
  });
  db.close();
  console.log('Seed inserted.');
}

if (process.argv.includes('--init')) {
  init();
  seed();
}

module.exports = { connect, DB_PATH };
	
	
Node.js
Node.js Хостинг
Запусти проект за пару кликов!
Бесплатный SSLСовременные серверы7 дней теста бесплатно
Перейти к тарифам

app.js — сервер и маршруты


// app.js
require('dotenv').config();
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const { body, validationResult } = require('express-validator');
const sqlite3 = require('sqlite3').verbose();
const { connect } = require('./db');

const app = express();
const PORT = process.env.PORT || 3000;
const isProd = process.env.NODE_ENV === 'production';

// Безопасность, логи, парсинг
app.use(helmet());
app.use(morgan(isProd ? 'combined' : 'dev'));
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));

// Простые "шаблоны": включим общий layout через файлы
const layoutStart = path.join(__dirname, 'views', 'layout-start.html');
const layoutEnd = path.join(__dirname, 'views', 'layout-end.html');
const views = {
  index: path.join(__dirname, 'views', 'index.html'),
  view:  path.join(__dirname, 'views', 'view.html'),
  form:  path.join(__dirname, 'views', 'new.html'),
};

// Хелперы
const fs = require('fs');
function readView(filePath) {
  return fs.readFileSync(filePath, 'utf8');
}
function page(html) {
  return readView(layoutStart) + html + readView(layoutEnd);
}
function escapeHtml(str = '') {
  return String(str)
    .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function nl2br(str='') { return escapeHtml(str).replace(/\n/g, '
'); } // Health app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() })); // Главная: список постов (пагинация по 10) app.get('/', (req, res) => { const db = connect(); const pageNum = Math.max(1, parseInt(req.query.page || '1', 10)); const limit = 10; const offset = (pageNum - 1) * limit; db.all( `SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT ? OFFSET ?`, [limit, offset], (err, rows) => { if (err) { db.close(); return res.status(500).send(page('

Ошибка загрузки

')); } db.get(`SELECT COUNT(*) AS cnt FROM posts`, [], (err2, rowCnt) => { db.close(); const total = rowCnt ? rowCnt.cnt : 0; const totalPages = Math.max(1, Math.ceil(total / limit)); const html = readView(views.index) .replace('{{POSTS}}', rows.map(r => ` `).join('') || '

Постов пока нет.

') .replace('{{PAGINATION}}', ` `); res.send(page(html)); }); } ); }); // Просмотр поста app.get('/post/:id', (req, res) => { const db = connect(); db.get(`SELECT * FROM posts WHERE id = ?`, [req.params.id], (err, row) => { db.close(); if (err || !row) { return res.status(404).send(page('

Пост не найден

')); } const html = readView(views.view) .replace('{{TITLE}}', escapeHtml(row.title)) .replace('{{DATE}}', new Date(row.created_at).toLocaleString()) .replace('{{CONTENT}}', nl2br(row.content)) .replace('{{DELETE_LINK}}', `/post/${row.id}/delete`); res.send(page(html)); }); }); // Форма создания поста app.get('/new', (req, res) => { const html = readView(views.form).replace('{{ERRORS}}', ''); res.send(page(html)); }); // Ограничитель на POST (anti-spam) const postLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, }); // Создание поста app.post( '/new', postLimiter, body('title').trim().isLength({ min: 2, max: 200 }).withMessage('Введите заголовок'), body('content').trim().isLength({ min: 5, max: 20000 }).withMessage('Введите содержание'), (req, res) => { const errors = validationResult(req); const { title = '', content = '' } = req.body; if (!errors.isEmpty()) { const msg = errors.array().map(e => e.msg).join(', '); const html = readView(views.form).replace('{{ERRORS}}', `

${escapeHtml(msg)}

`); return res.status(400).send(page(html)); } const db = connect(); db.run( `INSERT INTO posts (title, content, created_at) VALUES (?, ?, ?)`, [title, content, Date.now()], function (err) { db.close(); if (err) return res.status(500).send(page('

Ошибка сохранения

')); res.redirect(`/post/${this.lastID}`); } ); } ); // Удаление поста (для примера — GET-ссылка; в проде лучше POST/DELETE с токеном) app.get('/post/:id/delete', (req, res) => { const db = connect(); db.run(`DELETE FROM posts WHERE id = ?`, [req.params.id], function (err) { db.close(); if (err) return res.status(500).send(page('

Ошибка удаления

')); res.redirect('/'); }); }); // 404 и 500 app.use((req, res) => res.status(404).send(page('

404 Not Found

'))); app.use((err, req, res, next) => { console.error('Unhandled error:', err); res.status(500).send(page('

500 Internal Server Error

')); }); app.listen(PORT, () => { console.log(`Mini-blog running on port ${PORT}`); });

Шаблоны (views/…)

views/layout-start.html


<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8" />
  <title>Мини-блог на Express + SQLite</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link href="/style.css" rel="stylesheet" />
</head>
<body>
  <header class="container">
    <a class="logo" href="/">MiniBlog</a>
    <nav><a class="btn" href="/new">Новая запись</a></nav>
  </header>
  <main class="container">	
	

views/layout-end.html


  </main>
  <footer class="container footer">
    <p>Мини-блог на Node.js (Express) + SQLite. <a href="/health">/health</a></p>
  </footer>
</body>
</html>
views/index.html
<section>
  <h1>Записи блога</h1>
  {{POSTS}}
  {{PAGINATION}}
</section>	
	

views/view.html


<article class="post">
  <h1>{{TITLE}}</h1>
  <div class="meta">{{DATE}}</div>
  <div class="content">{{CONTENT}}</div>
  <p><a class="danger" href="{{DELETE_LINK}}" onclick="return confirm('Удалить запись?');">Удалить</a></p>
  <p><a href="/">На главную</a></p>
</article>	
	

views/new.html


<section class="card">
  <h1>Новая запись</h1>
  {{ERRORS}}
  <form method="post" action="/new" novalidate>
    <label>Заголовок
      <input type="text" name="title" required minlength="2" maxlength="200" placeholder="Например: Первая запись" />
    </label>
    <label>Содержание
      <textarea name="content" required minlength="5" maxlength="20000" rows="10" placeholder="Текст поста..."></textarea>
    </label>
    <button type="submit">Опубликовать</button>
  </form>
</section>	
	

public/style.css — минимальные стили


:root{
  --bg:#0f172a; --card:#111827; --text:#e2e8f0; --muted:#94a3b8;
  --primary:#22c55e; --primary-hover:#16a34a; --danger:#ef4444;
}
*{box-sizing:border-box}
html,body{margin:0;font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:var(--bg);color:var(--text)}
.container{max-width:900px;margin:0 auto;padding:20px}
header.container{display:flex;justify-content:space-between;align-items:center}
.logo{font-weight:800;text-decoration:none;color:var(--text)}
.btn{background:var(--primary);color:#052e16;padding:10px 14px;border-radius:10px;text-decoration:none;font-weight:700}
.btn:hover{background:var(--primary-hover)}
.card{background:var(--card);border-radius:16px;padding:20px;box-shadow:0 10px 30px rgba(0,0,0,.25)}
.post{background:var(--card);border-radius:16px;padding:18px;margin:14px 0}
.meta{color:var(--muted);font-size:14px;margin-bottom:8px}
.content{white-space:normal}
label{display:grid;gap:6px;margin:10px 0}
input,textarea{width:100%;padding:10px 12px;border:1px solid #1f2937;border-radius:10px;background:#0b1220;color:var(--text)}
button{padding:12px 16px;border:0;border-radius:10px;background:var(--primary);color:#052e16;font-weight:700;cursor:pointer}
button:hover{background:var(--primary-hover)}
.pagination{display:flex;gap:12px;align-items:center;margin-top:16px}
.pagination a{color:var(--text)}
.muted{color:var(--muted)}
a.danger{color:var(--danger);text-decoration:none}
.footer{color:var(--muted)}
h1,h2{margin-top:0}
	
	

.env для локального запуска, не обязателен на хостинге


PORT=3000
NODE_ENV=development
	
	

Инициализация и запуск

Локально


npm install
npm run initdb     # создаст data.sqlite и первый пост
npm start          # http://localhost:3000
	
	

В ISPmanager (shared-хостинг)

  • Создайте домен/сайт
  • Загрузите проект в каталог сайта
  • В терминале/SSH: npm install, затем npm run initdb (один раз)
  • В разделе Node.js укажите:
    • Версию Node.js (LTS)
    • Стартовый файл: app.js (или команда npm start)
    • Переменные окружения (опционально): NODE_ENV=production
    • Привязку к домену/поддомену (панель настроит прокси)
  • Включите HTTPS (Let’s Encrypt)

Готово: на главной откроется список постов, /new — форма добавления, /post/:id — просмотр.

Частые вопросы

Файл data.sqlite в корне проекта. Делайте регулярные бэкапы каталога проекта.
Добавьте маршруты GET /post/:id/edit и POST /post/:id/edit с формой и UPDATE posts SET ... WHERE id = ?.
Да. Самый простой способ — базовая auth-проверка (пароль в переменной окружения). Для продакшена лучше авторизация с сессиями.
Попробуйте другую минорную версию Node.js в ISPmanager (LTS), либо переустановите sqlite3 другой версией. Как временный вариант — хранить записи в JSON/файле (но это не БД).

Мини-чек-лист продакшена

Приложение слушает process.env.PORT.
DB инициализирована (npm run initdb).
Включён HTTPS, скрыт /post/:id/delete под авторизацией (для боевого).
Бэкапы каталога (включая data.sqlite).

Реквизиты: Украина, 61202, Харьков, пр. Людвига Свободы 26/298.
ФО-П Харитинов Олег Сергеевич
IBAN: UA073052990000026001005905889
МФО 305299
ИНН 2961615658
ПАТ КБ "ПриватБанк"
mail:
Документы:
Служба поддержки: телефон + 380 57 7209279
создать тикет

Выберите язык