intermediate multi-day notes-knowledge
Build your own notes workspace (a personal Notion)
You'll build a local notes workspace: Markdown pages with [[wiki links]] and backlinks (pages that link back to the current one), simple typed tables embedded in pages, full-text search over everything, and a nightly export to plain files, all backed by one SQLite database. This is a multi-day build for an intermediate learner. Notion keeps its customers because teams need a shared space: real-time collaboration across devices, per-user permissions, and a deeply refined block editor that nontechnical teammates can trust. Your build proves the personal core is reproducible; the team layer is the product.
What you'll learn
- Modeling a notes app in SQLite: a pages table, JSON documents for databases, and an FTS5 virtual table for search
- Rendering and sanitizing user Markdown safely with marked
- Parsing [[wiki-style]] links and computing backlinks with SQL queries
- Building small inline-editable tables stored as JSON columns
- Automating plain-file exports so your notes stay readable outside the app
Before you start
- Node.js 20 or newer installed; `node --version` prints v20+
- Comfort editing JavaScript and using a terminal
- A code editor such as VS Code
- Git installed if you want to version your work
- No paid accounts or API keys needed; everything runs on localhost
The build
Set up a Node + Express project with better-sqlite3 for storage and server-rendered HTML, no frontend framework and no build step. Create the pages table, wire list/create/read routes, bind to 127.0.0.1, and confirm the page list loads in a browser. Hand the whole skeleton to your assistant, then review the diff before running it.
step prompt
Build the project skeleton for a local notes web app. Requirements: - Node 20+, Express 4, better-sqlite3, HTML rendered server-side with EJS or template literals, no frontend framework, no build step. - A db.js module that opens notes.db and creates a pages table: id INTEGER PRIMARY KEY, title TEXT UNIQUE, body TEXT, updated_at TEXT. - An Express server on port 3000 bound to 127.0.0.1 only, routes: GET / (page list), GET /pages/new, POST /pages, GET /pages/:id. - A shared layout whose header holds a search form posting to /search (stub the route) plus Home and New page links. - Config in .env loaded with dotenv; add .env and notes.db to .gitignore. - Acceptance: npm start serves the page list at http://127.0.0.1:3000 and creating a page persists it across restart. - Out of scope: auth, hosting config, CSS frameworks. - Pain warning: better-sqlite3 installs a native binary, if node-gyp errors check your Node version before debugging anything else.
Upgrade the page form from the scaffold into a split editor: a textarea beside a preview pane that renders Markdown as you type. Save raw Markdown to the pages.body column you created earlier, and sanitize HTML on the server when displaying saved pages. Drive the assistant through the tricky parts, especially the sanitizer.
step prompt
Add a Markdown editor with live preview to the pages app from step 1. Requirements: - On the edit screen, show the existing textarea on the left and a preview pane on the right inside the current layout. - Render Markdown in the browser with a vendored copy of marked.min.js served locally, no CDN and no npm browser bundler. - Debounce input events so the preview rerenders at most every 150 ms while typing. - Saving posts raw Markdown into pages.body from step 1; never persist rendered HTML. - Server-side display converts body with marked and strips script and iframe tags before inserting into the page. - Acceptance: a page with headings, a list, bold text and a code block previews identically after save and reload. - Out of scope: block dragging, slash commands, formatting toolbars. - Pain warning: pasting rich text can inject raw HTML into the textarea, prove the sanitizer strips it.
Make pages reference each other with [[Page Name]] tokens that become clickable links, and add a backlinks section showing which pages point at the current one. Missing links open a prefilled edit form instead of erroring, so links never dead-end. This step shapes your data model, so iterate with the assistant on the matching logic.
step prompt
Add [[wiki-style]] links and automatic backlinks to the Markdown pages from steps 1-2. Requirements: - When rendering a saved page, replace every [[Page Name]] token with a link to /pages/<slug>, using one shared slug function that lowercases and hyphenates titles. - A wiki link to a missing page opens the edit form with that title prefilled; saving creates it, so links never 404. - Append a Backlinks section under every rendered page listing pages whose body contains [[Current Title]], queried with LIKE against pages.body. - Style links whose target does not exist differently so gaps are visible at a glance. - Acceptance: seed Home, Projects, Ideas with Home linking to the others; Projects' backlinks section shows Home. - Out of scope: autocomplete dropdowns, graph views. - Pain warning: titles containing regex characters break naive matching, escape them in the LIKE pattern.
Give any page an inline table: a fenced code block declares typed columns (text, number, date, select), stored as one JSON document per database in SQLite, rendered as an editable table. Delegate this as one precise chunk, then verify rows survive a restart. Filters, sorts, and multiple views stay out of scope; those are Notion's ground.
step prompt
Add simple inline databases to the workspace built in steps 1-3. Requirements:
- A page may contain a fenced block labeled db whose first line names typed columns, types limited to text, number, date, select.
- Store each database as a single JSON document in a new dbs table: id INTEGER PRIMARY KEY, page_id INTEGER REFERENCES pages(id), data TEXT holding {"columns":[{"name,type,options"}],"rows":[]}.
- Page view renders the block as an HTML table with editable cells; cell edits POST to /api/dbs/:id/rows which updates the JSON and returns it.
- Select columns render as dropdowns populated from the column definition.
- Acceptance: a Books page with title(text), rating(number), finished(date), genre(select) and two rows survives a server restart intact.
- Out of scope: filters, formulas, relations between databases.
- Pain warning: two tabs editing one row overwrite each other, last write wins, note that in the README.
Add a search box in the header that queries every page instantly using FTS5, SQLite's full-text extension. Keep the index synced with triggers, fall back to LIKE for symbol-heavy queries, and show highlighted snippets on the results page. Tune the snippet behavior with the assistant until results read naturally.
step prompt
Wire full-text search across all pages using SQLite FTS5. Requirements: - Create virtual table pages_fts with fts5(title, body, content='pages', content_rowid='id') plus AFTER INSERT, UPDATE, DELETE triggers on pages to keep it synced. - GET /search?q= queries pages_fts with MATCH, falls back to a LIKE scan over pages.body when q is only symbols, renders results with snippet() excerpts and the query terms wrapped in mark tags. - Point the header search form from step 1 at this route. - Acceptance: a word appearing in three seeded pages returns all three with excerpts; deleting a page removes it from results. - Out of scope: fuzzy matching, typo tolerance, indexing database rows. - Pain warning: the FTS5 tokenizer treats brackets as separators, so [[Page Name]] matches oddly, test quoted phrases against bare words.
Close the loop on data ownership: a script that writes every page to ./export/ as a .md file with front matter, rewriting [[wiki links]] to relative Markdown links between files. Schedule it nightly with launchd or cron and document it in the README. Delegate the whole script, then diff two consecutive runs.
step prompt
Add a nightly export of every page to plain Markdown files. Requirements: - scripts/export.js reads all rows from pages (step 1) and writes one .md file per page into ./export/, named with the slug function from step 3, with YAML front matter carrying title and updated_at. - Rewrite [[Wiki Links]] in exported bodies to relative Markdown links targeting the sibling .md files. - Clear ./export/ at the start of each run, then log the count of files written and the destination path. - Add npm run export to package.json and README instructions to schedule it nightly via launchd on macOS or cron on Linux. - Acceptance: running npm run export twice yields byte-identical files, one per page, no leftovers from deleted pages. - Out of scope: uploading exports anywhere, converting database blocks to CSV. - Pain warning: titles differing only in case collide on macOS filesystems, append a numeric suffix to duplicates.
What you won't get
- Pages are Markdown documents edited in one pane, not a drag-and-drop block canvas
- Your notes live in one SQLite file on one machine; using them on another device means copying that file
- There are no user accounts or per-page permissions; the workspace belongs to you alone
- Databases are flat editable tables; filtered, sorted, and calendar views remain Notion territory
- There is no template gallery or integration directory; extending the app means writing the code yourself
Why people still pay — and what that teaches you
collaboration: Notion wins because teammates can edit the same page at once with permissions that admins trust. A builder learns that single-user storage is a weekend project, while merging simultaneous edits across devices (operational transforms or CRDTs) is product-scale engineering.
execution-polish: Notion's block editor feels effortless after years of iteration on drag handles, keyboard shortcuts, and paste handling. A builder learns the interface is the moat: the same features shipped rough read as unfinished rather than useful.
integrations: Notion wins by connecting to Slack, GitHub, calendars, and a public API, making it the hub people already live in. A builder learns each connector multiplies surface area and maintenance load, so a personal tool stays lean by choosing zero integrations on purpose.
Stretch goals
- Add an ask-my-notes page that retrieves matching paragraphs and sends them to an LLM API, key stored in .env
- Turn a page structure you repeat into a one-click template button on the home screen
- Generate a small static site from the ./export/ folder and publish selected pages publicly
All steps done — did it work?
Congratulations. Tell someone what you built.
About Notion
Notion costs $12/month. They pay for a shared workspace that nontechnical teammates can trust and understand.
Sources & further reading
- AppFlowy (GitHub) — An open-source Notion-like workspace worth reading to see how docs and databases coexist at scale.
- AFFiNE (GitHub) — Another open-source workspace combining docs and canvases, useful for comparing data-model choices.
- SiYuan (GitHub) — Stores workspace blocks as JSON files on disk, a directly relevant contrast to your single SQLite file.
- Notion pricing — Shows what the paid tiers charge for, helpful context for which features you consciously skipped.
Finished alternatives (if you'd rather not build)
Keep building
New lessons and honest build notes, by email. No spam, one-click out.
Signups open when the site goes live.