Mini Messageboard: A Line-by-Line Walkthrough of server.js
This post explains the entire Node.js program for our Mini Messageboard web app. The goal is to understand how a browser request turns into database queries and HTML responses.
Artifacts referenced in this post (clickable definitions):
- Express routing
- HTTP GET vs POST
- dotenv and .env files
- URL query parameters
- HTML forms (method, action)
- MariaDB connector + connection pooling
- SQL SELECT / INSERT / JOIN
- Prepared statements and ? placeholders
- XSS and escaping HTML
Complete Program
Here is the full server.js file we will explain:
import express from 'express'; import 'dotenv/config'; import { query } from './db.js'; const app = express(); app.use(express.urlencoded({ extended: false })); const port = process.env.PORT; if (!port) { throw new Error('PORT not set. Create .env (cp .env.example .env) and set PORT=41xx'); } app.get('/', async (req, res) => { const users = await query('SELECT id, name FROM users ORDER BY name'); const selected = Number(req.query.user_id || 0); res.send(` <h1>Mini Messageboard</h1> <p><b>No login:</b> select who you are, then view/post messages.</p> <form method="GET" action="/messages"> <label for="user_id"><b>Who are you?</b></label> <select name="user_id" id="user_id" required> <option value="" ${selected ? '' : 'selected'} disabled>Select a user...</option> ${users.map(u => `<option value="${u.id}" ${u.id === selected ? 'selected' : ''}>${u.name}</option>`).join('')} </select> <button type="submit">Continue</button> </form> <hr/> <p>Tip: If you refresh and lose your selection, just pick yourself again.</p> `); }); app.get('/messages', async (req, res) => { const userId = Number(req.query.user_id || 0); if (!userId) return res.redirect('/'); const users = await query('SELECT id, name FROM users ORDER BY name'); const meRows = await query('SELECT id, name FROM users WHERE id=?', [userId]); const me = meRows[0]; if (!me) return res.redirect('/'); const posts = await query(` SELECT users.name, posts.content, posts.created_at FROM posts JOIN users ON posts.user_id = users.id ORDER BY posts.created_at DESC, posts.id DESC LIMIT 50 `); res.send(` <h1>Mini Messageboard</h1> <form method="GET" action="/messages"> <label for="user_id"><b>Posting as:</b></label> <select name="user_id" id="user_id"> ${users.map(u => `<option value="${u.id}" ${u.id === userId ? 'selected' : ''}>${u.name}</option>`).join('')} </select> <button type="submit">Switch user</button> <a href="/">Back</a> </form> <h2>Post a message</h2> <form method="POST" action="/messages"> <input type="hidden" name="user_id" value="${userId}"> <textarea name="content" rows="3" cols="60" maxlength="500" required placeholder="Write a short message (max 500 chars)"></textarea><br/> <button type="submit">Post</button> </form> <hr/> <h2>Recent messages</h2> <ul> ${posts.map(p => ` <li> <b>${escapeHtml(p.name)}</b>: ${escapeHtml(p.content)} <small>(${formatTime(p.created_at)})</small> </li> `).join('')} </ul> `); }); app.post('/messages', async (req, res) => { const userId = Number(req.body.user_id || 0); const content = String(req.body.content || '').trim(); if (!userId || !content) return res.redirect('/'); if (content.length > 500) { return res.status(400).send('Message too long (max 500 chars).'); } await query('INSERT INTO posts (user_id, content) VALUES (?, ?)', [userId, content]); res.redirect(`/messages?user_id=${userId}`); }); function escapeHtml(s) { return String(s) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function formatTime(ts) { try { const d = (ts instanceof Date) ? ts : new Date(ts); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleString(); } catch { return ''; } } app.listen(Number(port), () => { console.log(`Server running on port ${port}`); }); 1) Imports: Express, dotenv, and the Database Helper
At the top we import three things:
- Express for routing and HTTP handling
- dotenv to load configuration from a .env file
- our local query() helper from db.js (see MariaDB connector + pooling)
The key lines are:
import express from 'express'; import 'dotenv/config'; import { query } from './db.js'; What to notice:
- import ‘dotenv/config’ runs dotenv at startup so process.env is populated.
- query() hides connection pooling details so the rest of the app can just run SQL.
2) Create the Express App and Enable Form Parsing
Next we create the Express app and add middleware:
const app = express(); app.use(express.urlencoded({ extended: false })); This middleware is required because our app uses HTML forms. When the browser submits a form with POST, the form fields arrive in the request body. This middleware parses that body and gives us access to:
- req.body.user_id
- req.body.content
Artifact link: HTML forms (method, action)
3) Read the Port from the Environment (and Fail Early)
We read the port from process.env:
const port = process.env.PORT; If it is missing, we immediately crash with a clear error:
if (!port) { throw new Error('PORT not set. Create .env (cp .env.example .env) and set PORT=41xx'); } Why this matters: students run on a shared server, so every student needs a unique port. We want the app to stop immediately rather than silently using the wrong default.
Artifact link: dotenv and .env files
4) Route: GET / (Home Page)
This route builds the home page where users choose who they are (no login):
app.get('/', async (req, res) => { ... }); Artifact link: Express routing
4.1 Query the list of users
We fetch user IDs and names from the database:
const users = await query('SELECT id, name FROM users ORDER BY name'); This SQL query returns an array like:
[ { id: 1, name: "Alice" }, { id: 2, name: "Bob" } ] Artifact link: SQL SELECT / INSERT / JOIN
4.2 Read an optional query parameter
We read an optional selection from the URL query string:
const selected = Number(req.query.user_id || 0); If the user visits:
/?user_id=2 then selected becomes 2.
Artifact link: URL query parameters
4.3 Generate HTML with a dynamic dropdown
We generate the HTML page using res.send and a template string. The dropdown options are produced from the database results:
${users.map(u => `<option ...>...</option>`).join('')} What to notice:
- The map() loop creates one <option> per user.
- The join(”) removes commas and produces one clean HTML string.
- The selected user gets the HTML attribute selected.
The form uses:
- method=”GET”
- action=”/messages”
So submitting the form produces a URL like:
/messages?user_id=1 Artifact links: HTTP GET vs POST and HTML forms
5) Route: GET /messages (View + Post UI)
This route shows the message page for a chosen user:
app.get('/messages', async (req, res) => { ... }); 5.1 Validate user_id
If no user is selected, we send the browser back to the home page:
const userId = Number(req.query.user_id || 0); if (!userId) return res.redirect('/'); 5.2 Verify the chosen user exists
We query for the chosen user using a placeholder:
const meRows = await query('SELECT id, name FROM users WHERE id=?', [userId]); const me = meRows[0]; if (!me) return res.redirect('/'); Artifact link: Prepared statements and ? placeholders
5.3 Load recent posts (JOIN)
We fetch recent posts and their author names with a JOIN:
SELECT users.name, posts.content, posts.created_at FROM posts JOIN users ON posts.user_id = users.id ORDER BY posts.created_at DESC, posts.id DESC LIMIT 50 Artifact link: SQL SELECT / INSERT / JOIN
5.4 Render the messages page (and escape output)
We display posts using:
- escapeHtml(p.name)
- escapeHtml(p.content)
This prevents HTML/JavaScript injection (XSS).
Artifact link: XSS and escaping HTML
6) Route: POST /messages (Create a Message)
This route runs when the user submits the post form:
app.post('/messages', async (req, res) => { ... }); 6.1 Read and validate form input
const userId = Number(req.body.user_id || 0); const content = String(req.body.content || '').trim(); if (!userId || !content) return res.redirect('/');
if (content.length > 500) {
return res.status(400).send('Message too long (max 500 chars).');
}
6.2 Insert into the database
await query( 'INSERT INTO posts (user_id, content) VALUES (?, ?)', [userId, content] ); 6.3 Redirect back to GET /messages
res.redirect(`/messages?user_id=${userId}`); This is the classic “POST then redirect” pattern so refreshing the page does not re-submit the form.
Artifact link: HTTP GET vs POST
7) Helper: escapeHtml (Prevent XSS)
Any time we display user-controlled text inside HTML, we must escape special characters. This helper replaces characters like < and > so they display as text rather than being interpreted as HTML.
function escapeHtml(s) { return String(s) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } Artifact link: XSS and escaping HTML
8) Helper: formatTime (Readable timestamps)
Posts store a timestamp. This helper formats it into a readable string.
function formatTime(ts) { try { const d = (ts instanceof Date) ? ts : new Date(ts); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleString(); } catch { return ''; } } 9) Start the Server
Finally, we start listening on the configured port:
app.listen(Number(port), () => { console.log(`Server running on port ${port}`); }); If everything works, you should see output like:
Server running on port 4178 Summary: What This Program Demonstrates
- Express routing and request handling (Express routing)
- GET vs POST and how forms generate requests (HTTP GET vs POST, HTML forms)
- SQL queries from Node, including JOIN (SQL SELECT / INSERT / JOIN)
- Prepared statement placeholders (Prepared statements)
- Escaping output to prevent XSS (XSS and escaping HTML)
- Using .env to keep configuration out of code (dotenv)
Optional Next Steps
- Remember the selected user using a cookie (still no login)
- Add “My posts only” filter
- Add delete/edit for posts
- Replace raw HTML strings with a template system (EJS)
