Markdown is the nicest way to write notes: plain text that turns into headings, lists, and links. This guide builds a small notes app on the Node.js application — you POST Markdown, it saves it in SQLite, and it renders each note to HTML on the server. The important part isn't the rendering; it's doing it safely, because turning user text into HTML is exactly how cross-site scripting (XSS) happens.
| At a glance | |
|---|---|
| You need | A server running the Node.js application |
| Build with | Express, better-sqlite3, marked (render) + sanitize-html (make it safe) |
| Plan | Any — free runs while your session timer has time, premium runs 24/7 |
| Time | About twenty-five minutes |
The honest truth about marked
marked turns Markdown into HTML. It does not strip dangerous HTML. If a note contains <script>alert(1)</script>, marked passes it straight through into the output — and if a browser ever renders that, the script runs. Older versions of marked had a built-in sanitize option; it was removed. So the safe pattern is two steps:
markdown → marked → raw HTML → sanitize-html → safe HTML → browser
Never send marked's output to a browser without sanitizing it first. This build does exactly that.
Step 1 — Create the project files
package.json:
{
"name": "markdown-notes",
"version": "1.0.0",
"private": true,
"dependencies": {
"express": "^4.19.2",
"better-sqlite3": "^11.1.2",
"marked": "^13.0.2",
"sanitize-html": "^2.13.0"
}
}
index.js — the whole app:
const express = require('express');
const Database = require('better-sqlite3');
const { marked } = require('marked');
const sanitizeHtml = require('sanitize-html');
const path = require('node:path');
const db = new Database(path.join(__dirname, 'data', 'notes.db'));
db.exec(`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);`);
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// marked turns Markdown into HTML. It does NOT strip dangerous HTML — so we run
// its output through sanitize-html before ever sending it to a browser.
function renderMarkdown(md) {
const rawHtml = marked.parse(md, { async: false });
return sanitizeHtml(rawHtml, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2']),
allowedAttributes: { a: ['href'], img: ['src', 'alt'] },
});
}
app.post('/api/notes', (req, res) => {
const { title, body } = req.body || {};
if (!title || !body) return res.status(400).json({ error: 'title and body required' });
const info = db.prepare('INSERT INTO notes (title, body) VALUES (?, ?)').run(title, body);
res.status(201).json({ id: info.lastInsertRowid });
});
app.get('/api/notes', (req, res) => {
res.json(db.prepare('SELECT id, title, created_at FROM notes ORDER BY id DESC').all());
});
app.get('/api/notes/:id', (req, res) => {
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(req.params.id);
if (!note) return res.status(404).json({ error: 'not found' });
res.json({ ...note, html: renderMarkdown(note.body) });
});
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));
Create an empty data/ folder in the File Manager so the database has a home. (better-sqlite3 creates the .db file, but not the folder.)
Step 2 — Start it
Press Start. npm install runs, then Listening on port … — your online signal. better-sqlite3 ships prebuilt binaries, so it installs with no compiler step.
Step 3 — Try it
Create a note:
curl -X POST http://YOUR_ADDRESS:PORT/api/notes \
-H "Content-Type: application/json" \
-d '{"title":"First","body":"# Hello\n\nSome **bold** text."}'
List them (GET /api/notes) and fetch one back (GET /api/notes/1). The single-note response includes a rendered html field:
{ "id": 1, "title": "First", "body": "# Hello\n\nSome **bold** text.",
"html": "<h1>Hello</h1>\n<p>Some <strong>bold</strong> text.</p>\n" }
Proving the sanitizer works
Store a note whose body contains <script>alert(1)</script> and an <img src=x onerror=alert(2)>. When you fetch it back, the html field has the Markdown formatting but the <script> tag and the onerror attribute are gone — sanitize-html stripped exactly the dangerous parts and kept the safe ones (<h1>, <strong>, links, images). Your original text is preserved in body; only the rendered HTML is cleaned.
⚠️ Heads up: The allow-list is the security boundary.
allowedTagsandallowedAttributessay precisely what may appear in output — anything else is dropped. Widen it carefully: allowing an attribute likeonclick, or a tag likescript, defeats the whole point.
Why render on the server
Rendering Markdown on the server (not in the browser) means the client only ever receives already-sanitized HTML. There's no way for a visitor's browser to run a note author's script, because the script never survives the trip. It also keeps the browser simple — it just drops the html into the page.
Everything about Markdown beyond this — tables, task lists, syntax highlighting, custom renderers — is standard marked; its official docs at marked.js.org cover it. Just keep the sanitize step in place no matter what you add.
Extend it
- Edit and delete: add
PUT /api/notes/:id(anUPDATE) andDELETE /api/notes/:id. - A front end: put an
index.htmlinpublic/with a textarea to write and a panel to preview the renderedhtml. - Search:
SELECT ... WHERE body LIKE ?for a basic search over note text.
Troubleshooting
SqliteError: unable to open database file— thedata/folder doesn't exist. Create it in the File Manager, then restart.Cannot find module 'marked'(or any package) — the install didn't finish. Read the console'snpm installoutput, or install from the Packages page and restart.- A note renders as plain text with visible
#and `** — the body wasn't valid Markdown, or you're displayingbodyinstead of the renderedhtmlfield. Usehtml`. - Page won't load — the
listencall must useSERVER_PORTand0.0.0.0. See I can't reach my app.
🎯 Good to know: Your notes live in
data/notes.dbinside/home/container. That survives restarts, but a reinstall or an application switch wipes the whole folder — take a backup before anything drastic, or move to a managed database for data you can't lose.
Next steps
- SQLite in depth
- Sessions and the honest security minimum
- Build an Express website + API
- Markdown's full feature set is documented at marked.js.org.