Build a Markdown notes app

A notes app that stores Markdown in SQLite and renders it to safe HTML with marked plus sanitize-html — including the honest truth about why marked alone is not safe.

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. allowedTags and allowedAttributes say precisely what may appear in output — anything else is dropped. Widen it carefully: allowing an attribute like onclick, or a tag like script, 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 (an UPDATE) and DELETE /api/notes/:id.
  • A front end: put an index.html in public/ with a textarea to write and a panel to preview the rendered html.
  • Search: SELECT ... WHERE body LIKE ? for a basic search over note text.

Troubleshooting

  • SqliteError: unable to open database file — the data/ 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's npm install output, 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 listen call must use SERVER_PORT and 0.0.0.0. See I can't reach my app.

🎯 Good to know: Your notes live in data/notes.db inside /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

Was this guide helpful?