telegra.ph, but it feels like paper.
Ruled lines. A red margin. Handwritten titles. Ink that dries as you publish. No accounts, no sign-up. Pick up a page, write, tear it off, share the link.
- What is this?
- Features
- Tech stack
- Getting started
- Routes
- Page format
- Security model
- Design system
- Backend
- Project structure
- Scripts
- Deployment
- Contributing
Notebook is a minimalist publishing app in the spirit of telegra.ph, with one big difference: it looks and feels like a real paper notebook.
There are no accounts and no profiles. You open the app, write on a ruled page, publish, and get a short link you can share with anyone. Every page you publish lands in the notebook's table of contents, newest first.
- Paper-first design. Ruled lines, a red margin line, handwritten titles set in Caveat, and a "drying ink" publish effect.
- Zero friction. No sign-up, no login. Write and publish.
- Rich editor. Built on TipTap v3 with a toolbar and custom
Aside,DetailsandSummarynodes. - Image uploads. Drop images into a page; they are stored through Convex file storage (8 MB cap).
- Shareable short links. Every page gets an 8-character slug at
/p/:slug. - Safe by construction. Content is sanitized on both the client and the server, and rendered without
innerHTML. - "Tear out" to delete. Pages can be removed through a confirmation step using
?edit=1. - A 404 that fits the theme. Missing pages are "torn out" of the notebook.
| Layer | Choice |
|---|---|
| Frontend | Vite, TypeScript, React 19 |
| Editor | TipTap v3 (@tiptap/* 3.x) with custom Aside, Details and Summary nodes |
| Backend | Convex (pages table plus an uploadImage HTTP action) |
| Styling | Plain CSS theme in src/notebook.css, layered over Tailwind in src/index.css |
| Tooling | bun, ESLint, Prettier |
| Hosting | Vercel |
Note: keep the Tailwind directives in
src/index.css. The notebook theme is layered on top of them.
# 1. Clone the repo
git clone https://github.com/daddymaou/notebook.git
cd notebook
# 2. Install dependencies
bun install
# 3. Connect Convex and push the backend functions
# (also regenerates the Convex types)
bunx convex dev --onceRun the Convex dev server and the Vite dev server side by side:
# terminal 1: backend
bunx convex dev
# terminal 2: frontend
bun run devbun tsc -b --noEmit| Route | Page |
|---|---|
/ |
Table of contents (newest first) |
/new |
Editor (TipTap plus toolbar) |
/p/:slug |
Reader (sanitized node rendering) |
/p/:slug?edit=1 |
Reader with a "tear out" link and delete confirmation |
/about |
About the notebook |
* |
404, the "torn out" page |
Pages are stored as Telegraph-style JSON node trees, not HTML:
[
{ "tag": "p", "children": ["hello ", { "tag": "strong", "children": ["world"] }] },
{ "tag": "img", "attrs": { "src": "https://...convex.site/..." } }
]The allowed tags are whitelisted in two places that must stay in sync:
| File | Role |
|---|---|
src/lib/nodes.ts |
Client whitelist, sanitizer, and TipTap-to-tree converter |
src/convex/pages.ts |
Server-side sanitizer applied at publish time |
If you add a new tag or node type, update both files.
- The reader builds the DOM with
document.createElementandcreateTextNodeonly.innerHTMLis never used. - URLs are restricted to
http(s)or same-origin paths. - The server re-sanitizes every page before inserting it, so a hand-crafted request cannot bypass the client whitelist.
- Image uploads are capped at 8 MB.
- There are no auth routes, because the app has no accounts.
The visual language is locked. Please keep contributions inside it.
| Token | Value |
|---|---|
--line |
32px, the ruled-line grid. Spacing snaps to multiples of 32 |
--paper |
#fdfbf5 |
--ink |
#1a1a1a |
| Margin line | Red, at 40px (28px on mobile) |
| Titles | Caveat (handwritten) |
| Chrome | Neobrutalist: square corners, 2px black borders, --chrome-shadow offset shadows |
| Buttons | "Stamped" style that invert on hover |
creategenerates an 8-character slug with collision retry and sanitizes content before insert.getBySlugfetches a single page.listreturns pages for the table of contents.removedeletes a page.
POST /uploadImageaccepts multipart uploads (8 MB cap) and stores them viactx.storage.store.
Gotcha:
httpActionmust be imported from./_generated/server.
notebook/
├── public/ # static assets
├── src/
│ ├── convex/ # backend: pages.ts, http.ts
│ ├── lib/
│ │ └── nodes.ts # whitelist, sanitizer, TipTap → tree converter
│ ├── index.css # Tailwind foundation (keep the directives)
│ └── notebook.css # the paper theme
├── components.json
├── convex.json
├── eslint.config.js
├── index.html
├── vercel.json
└── vite.config.ts
| Command | What it does |
|---|---|
bun install |
Install dependencies |
bunx convex dev --once |
Push functions and regenerate types |
bunx convex dev |
Run the Convex dev server |
bun tsc -b --noEmit |
Typecheck the project |
The frontend is configured for Vercel (see vercel.json) and the backend runs on Convex.
- Deploy the Convex backend with
bunx convex deploy. - Import the repo into Vercel.
- Set your production Convex URL as the environment variable the frontend expects, then deploy.
Contributions are welcome.
- Fork the repo and create a branch:
git checkout -b feat/your-idea - Keep the design tokens intact and run Prettier and ESLint before committing.
- If you touch the node whitelist, update both
src/lib/nodes.tsandsrc/convex/pages.ts. - Make sure
bun tsc -b --noEmitpasses. - Open a pull request describing what changed and why.
Pick up a page. Write. Tear it off. Share the link.