Minimal Blog Engine Clone

Build a lightweight, server-side rendered blog in 45 minutes using Node.js and SQLite.

Welcome to another official UVF IT build guide. Today, we are constructing a minimal, functional blog engine inspired by the simplicity and performance of Ghost, but stripped down to its absolute essentials for educational purposes.

We will use a free-tier, open-source stack consisting of Node.js for the server runtime and SQLite for persistent storage. This approach requires no external database hosting or paid API keys, allowing you to run the entire application locally with zero infrastructure costs.

Prerequisites & Scope

Local Blog Architecture
LOCAL DEVELOPMENT ENVIRONMENT HTTP Requests SQL Queries Serve Static Files Web Browser Client Node.js Server Express App SQLite Database blog.db File System Static Assets
1

Phase 1: Scaffold Project & Install Dependencies

Initialize a new Node.js project and install the necessary libraries for web serving (Express), database interaction (better-sqlite3), and HTML templating (EJS).

Create the directory structure for your application, including folders for views, public assets, and the database file.

mkdir uvf-blog-clone && cd uvf-blog-clone
npm init -y
npm install express better-sqlite3 ejs
mkdir -p views public
2

Phase 2: Core Data Model & Database Setup

Set up the SQLite database connection and create the posts table to store blog content. This table includes fields for ID, title, body, and creation timestamp.

Implement helper functions to initialize the database schema if it does not already exist, ensuring the application is idempotent.

db.js

const Database = require('better-sqlite3');
const path = require('path');

const db = new Database(path.join(__dirname, 'blog.db'));

db.pragma('journal_mode = WAL');

const initDb = () => {
  db.exec(`
    CREATE TABLE IF NOT EXISTS posts (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      body TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
};

const getPosts = () => {
  return db.prepare('SELECT * FROM posts ORDER BY created_at DESC').all();
};

const getPostById = (id) => {
  return db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
};

const createPost = (title, body) => {
  const stmt = db.prepare('INSERT INTO posts (title, body) VALUES (?, ?)');
  const info = stmt.run(title, body);
  return info.lastInsertRowid;
};

module.exports = { db, initDb, getPosts, getPostById, createPost };
3

Phase 3: API Endpoints & Server Logic

Create the main server file using Express. Configure it to serve static files from the public directory and use EJS for rendering HTML templates.

Implement routes for listing all posts (GET /), viewing a single post (GET /post/:id), and creating a new post (POST /post). Use express.urlencoded middleware to parse form data.

index.js

const express = require('express');
const { initDb, getPosts, getPostById, createPost } = require('./db');

const app = express();
const PORT = 3000;

// Middleware
app.set('view engine', 'ejs');
app.use(express.static('public'));
app.use(express.urlencoded({ extended: true }));

// Initialize Database
initDb();

// Routes
app.get('/', (req, res) => {
  const posts = getPosts();
  res.render('index', { posts });
});

app.get('/post/:id', (req, res) => {
  const post = getPostById(req.params.id);
  if (!post) {
    return res.status(404).send('Post not found');
  }
  res.render('post', { post });
});

app.post('/post', (req, res) => {
  const { title, body } = req.body;
  if (!title || !body) {
    return res.status(400).send('Title and body are required');
  }
  createPost(title, body);
  res.redirect('/');
});

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});
4

Phase 4: Frontend Templates & UI

We will now create the visual layer of our blog using EJS templates. This allows us to inject dynamic data from our SQLite database directly into the HTML structure, bridging the gap between our backend logic and the user interface.

Start by defining the homepage template, which will serve as both the post listing view and the entry point for creating new content. We will include a simple HTML form that submits to our /post endpoint, enabling users to add new blog entries without needing a separate admin dashboard.

Next, we create the single post view template. This file will render the full content of a specific post when a user clicks on a title from the homepage. Finally, we add a basic CSS stylesheet to ensure the text is readable and the layout is structured, giving our minimal clone a polished appearance.

views/index.ejs

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>UVF Blog</title>
    <link rel="stylesheet" href="/style.css">
</head>
<body>
    <header>
        <h1>UVF Blog</h1>
        <p>A minimal blog engine built with Node.js and SQLite.</p>
    </header>

    <main>
        <section class="create-post">
            <h2>Create New Post</h2>
            <form action="/post" method="POST">
                <input type="text" name="title" placeholder="Post Title" required>
                <textarea name="body" placeholder="Post Content" required></textarea>
                <button type="submit">Publish</button>
            </form>
        </section>

        <section class="post-list">
            <h2>Recent Posts</h2>
            <% if (posts.length === 0) { %>
                <p>No posts yet. Create one above!</p>
            <% } else { %>
                <ul>
                    <% posts.forEach(post => { %>
                        <li>
                            <a href="/post/<%= post.id %>">
                                <h3><%= post.title %></h3>
                                <small><%= new Date(post.created_at).toLocaleDateString() %></small>
                            </a>
                        </li>
                    <% }); %>
                </ul>
            <% } %>
        </section>
    </main>
</body>
</html>

views/post.ejs

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><%= post.title %> - UVF Blog</title>
    <link rel="stylesheet" href="/style.css">
</head>
<body>
    <header>
        <h1><a href="/">UVF Blog</a></h1>
    </header>

    <main>
        <article>
            <h1><%= post.title %></h1>
            <time><%= new Date(post.created_at).toLocaleDateString() %></time>
            <div class="content">
                <%= post.body %>
            </div>
        </article>
        <a href="/">&larr; Back to Home</a>
    </main>
</body>
</html>

public/style.css

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    line-height: 1.6;
    color: #333;
    max-width: 800px;
    margin: 0 auto;
    padding: 20px;
    background-color: #f9f9f9;
}

header {
    margin-bottom: 40px;
    border-bottom: 2px solid #333;
    padding-bottom: 20px;
}

header h1 {
    margin: 0;
    font-size: 2.5rem;
}

header a {
    color: #333;
    text-decoration: none;
}

.create-post {
    background: #fff;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    margin-bottom: 40px;
}

input, textarea {
    width: 100%;
    padding: 10px;
    margin-bottom: 10px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 1rem;
}

button {
    background-color: #333;
    color: #fff;
    padding: 10px 20px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
}

button:hover {
    background-color: #555;
}

.post-list ul {
    list-style: none;
    padding: 0;
}

.post-list li {
    background: #fff;
    margin-bottom: 10px;
    padding: 15px;
    border-radius: 4px;
    box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}

.post-list a {
    text-decoration: none;
    color: #333;
}

.post-list h3 {
    margin: 0 0 5px 0;
}

.post-list small {
    color: #777;
}

article {
    background: #fff;
    padding: 30px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

article h1 {
    font-size: 2rem;
    margin-top: 0;
}

article time {
    color: #777;
    display: block;
    margin-bottom: 20px;
}

.content {
    white-space: pre-wrap;
}
5

Phase 5: Run Locally & Verify

With the templates and styles in place, your application is ready to run. We will start the Node.js server using the node command, which will initialize the SQLite database and begin listening for HTTP requests on port 3000.

Once the server is running, open your web browser and navigate to http://localhost:3000. You should see the UVF Blog homepage with an empty post list and the creation form.

Test the full workflow by filling out the form with a title and body, then clicking 'Publish'. The page should redirect back to the homepage, displaying your new post. Click on the post title to verify that the single post view renders the content correctly.

node index.js

Verifying the Stack

CheckCommand / Why it matters
Server starts without errorsRun node index.js and check for 'Server running at http://localhost:3000' in the terminal.
Homepage loads correctlyOpen http://localhost:3000 in a browser and verify the 'UVF Blog' header and empty post list are visible.
Post creation worksFill out the form on the homepage and click 'Publish'; verify the page redirects to the homepage with the new post listed.
Single post view worksClick on the newly created post title and verify the full title and body are displayed on the detail page.

Next Steps

Congratulations! You have built a minimal, functional blog engine. To extend this project, consider adding Markdown support for post bodies, implementing user authentication, or migrating the database to PostgreSQL for production readiness.