How to build a Notion alternative
How to build a Notion alternative with nested markdown pages, a private tree, and search. Follow the prompts and skip databases and live editing.
How to build a Notion alternative is a tree of markdown pages and a search box. Notion is that product. The catalog calls it “You need a team wiki, not a new operating system.”
You pay $12–$24 a person a month. That is $144–$288 a year per person, and the price scales with the team. The verdict is “Scope is everything”. The first estimate is 10 hours: markdown pages, nesting, search.
The first-project post is why this is a poor tonight build. The block teardown separates the wiki from the block graph. This guide is the 10-hour screens, rules, and prompts.
The audience is a team of up to 20 people writing internal docs. If that workspace is already the source of truth, keep paying. The same v1 also replaces Slite, Coda, and Craft when a nested wiki is the job. A public blog is the Ghost guide, at $9–$35 a month, about 12 hours.
How to build a Notion alternative as a private wiki
The Notion page splits the work into three rows.
The first row is the one you want. Nested markdown pages. A slash-free editor with a preview. Search across titles and body text. That is the 10-hour v1.
The second row is 1 week, 40 hours. Revisions, mentions, stale-page tracking, and company OAuth. The third row is 3 months and up, 480 hours. Databases, live collaboration, granular permissions. The non-goal already names databases, formulas, canvases, public sites, and real-time collaborative editing.
If you tell an agent to “clone Notion”, it aims at the third row. The product-page prompt also lists owners, review dates, and revisions. Those sit on the 40-hour row. Paste the prompts below, and keep each one on pages, nesting, and search.
The catalog names the project Teambook. The root page is Company. Under it sits Engineering handbook, slug handbook. Under that sits Incident notes. The body starts “Page the primary. Then write the incident note.” Flavio writes it. Jordan searches for it. Both are members of the one workspace.
The parts and where a save goes
Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Notion is Astro, HTMX, and D1. This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why databases wait.
Teambook stores rows. A save is a form post that writes the title, the slug, the parent, and the markdown together. A move is that same write, plus a cycle check in the transaction. Search queries those rows on the request. That is what D1 is for.
This row has no upload, no cron, and no websocket. Mentions can wait, so email can wait. TanStack Start keeps the same screens, with server functions and a SQL database with migrations. The hosting post is the D1 binding when you leave localhost.
Members and pages hit one app. Every page, search, and mutation checks the session on the server. A signed-out visitor gets a login wall. Pages stay private.
The data one page carries
The card lists Workspace, User, Page, and PageRevision. This v1 needs the first three. PageRevision waits for the 40-hour row, when two saves collide.
Teambook is the only workspace. Flavio and Jordan are members. Seed passwords come from the environment, and the database stores hashes. The session cookie is httpOnly.
A page carries a stable id, a title, a slug, markdown, a parent id, and who saved it last. Company has no parent. The handbook points at Company. Incident notes points at the handbook. The address uses the id, so a move leaves the link alone. The slug is the short name in the breadcrumb.
Slugs are unique among siblings. Engineering and Marketing can each have a Setup page. A second Setup under the same parent is refused, and the form names the owner. The row stores markdown. The server sanitizes HTML on the request. The preview and the page share that renderer.
A move sets a new parent. Walk from that parent up to the root, and reject the write if you meet the page you are moving. Run the walk in the same transaction as the update. The block teardown is where that rule comes from. A page with children cannot be deleted. A deleted leaf leaves the tree and the search results.
Here is the shape of the handbook after you save it.
const page = {
id: 'page-handbook',
parentId: 'page-company',
slug: 'handbook',
title: 'Engineering handbook',
}
The body stays markdown in the row. Incident notes uses the same fields, with parentId set to page-handbook. Search reads these rows. At this size the page table is the index.
The screens you ship
Three surfaces. Keep them boxes and labels. If the agent starts matching Notion’s block editor, send it back to these boxes.
The workspace shell is /. The tree lists Company and its children. The pane shows the title, the breadcrumb, and the rendered body. New page asks for a title and a parent, then opens the editor.
The editor is one page, at /pages/page-handbook/edit. It shows the title, the parent, the slug, the markdown, and a preview. The parent list omits choices that would cycle. Save returns to the page. Delete shows up only for a leaf. Company has no delete control.
Search lives at /search. Each hit shows the title, the breadcrumb, and a snippet. incident note finds Incident notes under Company / Engineering handbook. No match says nothing matched. A private window gets the login wall, and that HTML has no page titles.
The path from a blank tree to a search hit
One page moves through states you can point at. Saved under a parent. Visible in search. Moved, with the same id and a new breadcrumb. A move that would hide the root is refused.
- Flavio signs in. The tree shows Company. A private window that opens
/gets the login wall. - He creates Engineering handbook under Company. The slug starts as
handbook. The tree indents it. - He creates Incident notes under the handbook and writes “Page the primary. Then write the incident note.” The preview and the page show the same sentence.
- Jordan searches
incident noteand opens the hit. The breadcrumb is Company / Engineering handbook / Incident notes. The address holds the page id. - Flavio tries to delete the handbook while Incident notes is still under it. The form refuses, and the row stays.
- He sets Company’s parent to Incident notes. The form refuses. Company stays the root.
- He moves Incident notes so its parent is Company. The id is unchanged. Jordan searches again and lands on the same page. The handbook is now a leaf, so delete removes the handbook. Incident notes stays.
Prompt 1. Sign in and a root page
Build Teambook, a private wiki
for one team.
Use Node.js and Astro.
Server-rendered pages.
Ordinary HTML forms.
Cloudflare D1 for the rows.
If you use TanStack Start
instead, use server functions
and a SQL database with
migrations. Same screens.
Same rules. No job runner.
Seed a workspace named
Teambook. Seed two members,
Flavio and Jordan. Passwords
come from env, and the
database stores hashes.
Sign-in sets an httpOnly
session cookie.
Seed one root page titled
Company. It has no parent.
Every other page will sit
under it.
Every page, search, and
mutation checks the session
on the server. A signed-out
visitor gets a login wall.
The wall HTML contains no
page titles and no markdown.
One workspace. No billing.
No public pages. No OAuth.
Done when Flavio can sign
in, the tree shows Company,
and a private window cannot
read the page.
What to check after login exists
Sign in as Flavio. / shows Company and a way to add a page.
Open / in a private window. You get the login wall. View source does not contain “Company”. Sign in as Jordan and confirm he sees the same root. Signing out and using the back button still cannot show the page body.
Prompt 2. Nest pages and refuse a cycle
The parent rule is already on the card. Moves cannot create tree cycles, and slugs are unique among siblings.
Add nested pages to Teambook.
New page asks for a title
and a parent. The parent
defaults to the page you
were viewing. The slug is
the title in lowercase
words joined by hyphens.
The writer can edit the
slug. Empty slugs are
refused. A slug a sibling
already has is refused,
and the form names that
sibling. The same slug
under a different parent
is allowed.
The editor shows title,
parent, slug, markdown,
and a preview. Save writes
all of them in one
transaction and returns to
the page. The page view
shows the title, the
breadcrumb, and the
rendered body.
The address uses the stable
page id. Changing the slug
or the parent does not
change that address.
The parent control lists
only legal parents. Saving
a parent walks from that
parent up to the root. If
the walk meets the page
being saved, the write is
rejected and the tree is
unchanged. Run the walk
and the update in one
transaction.
Render markdown on the
server. Sanitize the HTML.
The preview uses that same
renderer. A pasted script
does not run. Strip unsafe
link protocols.
Delete is refused while
the page has children.
Company cannot be deleted.
A leaf delete removes the
row.
No cover image. No blocks
table. No database
properties. No comments.
Done when the handbook
nests under Company, a
cycle is refused, and two
Setup pages can exist
under different parents.
What to check after the first nest
Create the handbook under Company, then Incident notes under the handbook. Both show in the tree, indented.
Change the handbook slug to eng-handbook and reload the id URL. The page still opens.
Save Setup under the handbook and Setup under Marketing. Both stick. A second Setup under Marketing is refused, and the form names the first one.
Set Company’s parent to Incident notes. Reload /. Company is still the root.
Paste a script tag into the markdown. The preview shows it as text or drops it. Reload. The script does not run.
Delete the handbook while it still has a child. The row stays. Move Incident notes under Company, then delete the handbook. Search stops listing it.
Prompt 3. Search the titles and the bodies
Add /search to Teambook.
The box takes a phrase.
Search titles and markdown
bodies for the signed-in
member's workspace. Each
hit shows the title, the
breadcrumb from the root,
and a snippet around the
match. The link uses the
page id.
An empty query shows no
hits and asks for a word.
A query with no match says
nothing matched. Matching
is case-insensitive. A hit
in the body still shows
the title.
Search checks the session
on the server. A signed-out
request gets the login wall
and does not include titles
or snippets in the HTML.
Do not add a separate
search service. Read the
page rows. No filters for
owner, date, or database
properties.
Done when Jordan can find
"incident note" in the
body, open the page, and
a private window learns
nothing from /search.
What to check on the search page
Search incident note as Jordan. One hit shows the title, the breadcrumb, and the snippet. The link opens the id Flavio copied.
Search HANDBOOK. The title matches. Search zzzz. The page says nothing matched.
Move Incident notes under Company and search again. The hit stays, with the new breadcrumb. Delete that leaf and the hit is gone.
Open /search?q=incident in a private window. View source does not contain “Incident notes”.
Failures the agent ships anyway
A tree that folds into a loop
Move Company under Incident notes and reload /. An empty sidebar, or titles that repeat, means the cycle check never ran. If the form refuses and another tab still shows the bad parent, the check ran outside the transaction. The walk and the update belong together.
A slug that is unique in the wrong place
Setup under Engineering and Setup under Marketing should both save. A refusal means the unique rule covers the whole workspace. Two Setup pages under Marketing means the sibling check is missing. The form names the sibling that owns the slug.
A page the login wall already leaked
Search while signed out and view source. The incident sentence in that HTML means the query ran before the session check. A guessed id URL has the same bug when the title sits above the login form.
A script that runs
Paste a script into the body. A dialog on the preview or the page means the HTML was trusted. The preview and the page share one sanitizer.
A block database on day one
The teardown covers block ids, the CRDT, and the permission graph. This row is one page, a parent, and a markdown column. Stop when the plan mentions a blocks table, a properties schema, or live cursors.
Open the editor as Flavio and as Jordan. If Jordan’s save erases Flavio’s sentence, that is the gap in this v1. The mini-prompt “Two editors, one page, zero silent overwrites” is the 40-hour fix. The CRDT stays on the later row.
Databases and a public handbook
Customers and rollups are Airtable, $24–$54 a person a month, about 3 days for typed fields and a table view. A public essay is the Ghost guide. Assignees are the Linear guide. Local files plus sync are Obsidian Sync, $5–$10 a month, about 2 days.
Deploy the wiki
Teambook wants Astro pages and a D1 database, on a Worker so the forms have somewhere to run. Put it on a subdomain you already own, such as wiki.flaviocopes.com. The deploy is the pages, the D1 binding, the password seeds, and the domain.
The hosting post is that path: the adapter, then the D1 binding, then the custom domain.
Create the database. Run the migration. Set the passwords. Sign in as Flavio, write Incident notes, and search for the sentence on a phone as Jordan. Try the cycle move and confirm Company stays the root. Take a D1 backup before you cancel the Notion plan.
Use the handbook for a week. Revisions, mentions, a stale date, and company OAuth are the 40-hour row on the Notion page. If the team already lives in the shared workspace, keep the subscription.
What to skip on purpose
Leave out databases, formulas, canvases, public pages, real-time editing, the block graph, and per-page guests.
The revision mini-prompt and the backlink mini-prompt are on the Notion page. Both wait until two people share the handbook. When you add them, every save carries the revision id it started from. A stale save is rejected, and the unsaved textarea stays put.
More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.
Start now
Go to Build your own Notion. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate only. Markdown pages, nesting, search. Ten hours.
Write Incident notes and find that sentence from Jordan’s account before you add a database.