EN
UAH

Node.js Mini Blog with Express & SQLite

What You’ll Get

Blog A mini Express application that:

  • Mini blog engine: list posts / view / add / delete
  • Storage in SQLite (a single data.sqlite file)
  • Ready to deploy on shared hosting with ISPmanager (port from process.env.PORT, static files, simple forms)
  • Basic protection: field validation, HTML escaping, rate limiting on POST

Suitable for quick start, testing, and simple landing pages/mini-services.

Project structure

mini-blog/
+-- package.json
+-- app.js
+-- db.js
+-- .env # Optional for localhost
+-- public/
| +-- style.css
+-- views/
    +-- layout-start.html
    +-- layout-end.html
    +-- index.html # Post list
    +-- view.html # View post
    +-- new.html # Add form

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"
  }
}
	
	

If compiling sqlite3 on shared hosting doesn’t work, try a different sqlite3 minor version, or switch to a prebuilt Node.js release (typically LTS) in 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 (?, ?, ?)`,
      ['Welcome', 'This is the first post in the mini-blog using Express + SQLite.', now]
    );
  });
  db.close();
  console.log('Seed inserted.');
}

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

module.exports = { connect, DB_PATH };
	
	
Node.js
Fast & Reliable Node.js Hosting
Start Your Project with Just a Few Clicks!
Free SSL CertificatPowered by Modern Servers7-Day Free Trial
View Plans

app.js — server and routes


// 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';

// Security, logging, parsing
app.use(helmet());
app.use(morgan(isProd ? 'combined' : 'dev'));
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));

// Simple "templates": include the common layout via files
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'),
};

// Helpers
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() })); // Home: posts list (10 per page) 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('

Failed to load

')); } 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('') || '

No posts available.

') .replace('{{PAGINATION}}', ` `); res.send(page(html)); }); } ); }); // View post 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('

Post not found

')); } 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)); }); }); // Create post form app.get('/new', (req, res) => { const html = readView(views.form).replace('{{ERRORS}}', ''); res.send(page(html)); }); // POST rate limiter (anti-spam) const postLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, }); // Create post app.post( '/new', postLimiter, body('title').trim().isLength({ min: 2, max: 200 }).withMessage('Enter a title'), body('content').trim().isLength({ min: 5, max: 20000 }).withMessage('Enter content'), (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('

Failed to save

')); res.redirect(`/post/${this.lastID}`); } ); } ); // Delete post (example uses a GET link; in production use POST/DELETE with a token) 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('

Failed to delete

')); 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}`); });

Templates (views/…)

views/layout-start.html


<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8" />
  <title>Mini-blog on 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">New post</a></nav>
  </header>
  <main class="container">	
	

views/layout-end.html


  </main>
  <footer class="container footer">
    <p>Mini-blog on Node.js (Express) + SQLite.. <a href="/health">/health</a></p>
  </footer>
</body>
</html>
views/index.html
<section>
  <h1>Blog posts</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('Delete post?');">Remove</a></p>
  <p><a href="/">На главную</a></p>
</article>	
	

views/new.html


<section class="card">
  <h1>New post</h1>
  {{ERRORS}}
  <form method="post" action="/new" novalidate>
    <label>Title
      <input type="text" name="title" required minlength="2" maxlength="200" placeholder="Example: First post." />
    </label>
    <label>Content
      <textarea name="content" required minlength="5" maxlength="20000" rows="10" placeholder="Post content..."></textarea>
    </label>
    <button type="submit">Submit</button>
  </form>
</section>	
	

public/style.css — minimal styles


: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 for local setup, not needed in production/hosting


PORT=3000
NODE_ENV=development
	
	

Setup and run

Locally


npm install
npm run initdb     # creates data.sqlite and the first post
npm start          # http://localhost:3000
	
	

In ISPmanager (shared hosting)

  • Set up a domain/website
  • Upload the project to the site directory
  • In terminal/SSH: run npm install, then npm run initdb (once).
  • In the Node.js section, specify:
    • Node.js version (LTS)
    • Entry file: app.js (or run npm start)
    • Environment variables (optional): NODE_ENV=production
    • Domain/subdomain binding (the panel will configure the proxy automatically).
  • Enable HTTPS (Let’s Encrypt)

All set: homepage - list of posts, /new - add form, /post/:id - post view.

FAQ (Frequently Asked Questions)

The data.sqlite file is located in the project root. Make regular backups of the project directory.
Add the routes GET /post/:id/edit and POST /post/:id/edit with a form, and use UPDATE posts SET ... WHERE id = ?.
Yes. The simplest option is a basic auth check (password in an environment variable). For production, use session-based authentication.
Try a different minor version of Node.js in ISPmanager (LTS), or reinstall sqlite3 with another version. As a temporary workaround, you can store posts in JSON/a file (but that’s not a real database).

Mini production checklist

The app listens on process.env.PORT.
DB initialized (npm run initdb).
HTTPS enabled, /post/:id/delete is protected by authentication (production).
Backup the project directory (including data.sqlite)

Contact details: Ukraine, 61202, Kharkiv, Ludviga Svobody st. 26-298.
FO-P Kharitinov Oleg Sergeevich
IBAN UA073052990000026001005905889
tax.number 2961615658
PrivatBank
mail:
Documentation:
Support service: телефон + 380 57 7209279
Create a ticket