I don't actually go back and read notes I take in Google Docs or Word. They're not aesthetic, and the layout never feels right for how I think about notes. When I picture keeping track of something, I picture a physical notebook, not a document.
The problem is most of what I want to save is digital: YouTube videos, links, articles. A paper notebook can't hold a timestamp or a hyperlink. So I built Notebook Shelf: a notebook that's actually digital. Open the app to a shelf of notebooks, open one to lined paper in a nice font, write notes, and drop in links and YouTube timestamps. Cover color, page color, and font are all customizable, just within a curated set of options so it doesn't turn into a whole design tool.
The shelf: a forward-facing grid of notebooks with curated cover colors, a hover-to-reveal topic, and search.
Inside a notebook: lined paper in a warm handwriting font, a bookmark ribbon, a sticky tab, and a YouTube link that unfurled into a preview card.
Notebook Shelf started from a full product requirements doc and was built
module by module (auth, then data model, then editor, then links, then tabs,
then search, then polish and a security pass) with an AI coding assistant
guiding each step. It's a real, working full-stack app: Supabase-backed auth
and Postgres with Row Level Security, a custom notebook page editor with
autosave and safe rendering of untrusted input, and a documented security
review with real fixes applied afterward (see docs/SECURITY_CHECKLIST.md
and docs/SECURITY_LESSONS.md).
It's laptop-first and built for a single primary user, but the data model, auth, and RLS policies are all multi-user ready from day one.
- Next.js (App Router) + React + TypeScript
- Tailwind CSS
- Supabase (Auth, Postgres, Row Level Security)
Requirements: Node.js 18.18+ (Node 20+ recommended).
# 1. Install dependencies
npm install
# 2. Create your local env file (real values, never committed)
cp .env.example .env.local
# then fill in your Supabase project URL and anon key
# NEXT_PUBLIC_SUPABASE_URL=...
# NEXT_PUBLIC_SUPABASE_ANON_KEY=...
# 3. Run the dev server
npm run devOpen http://localhost:3000. The landing page loads at /, and the
authenticated shelf loads at /app.
npm run dev: start the dev servernpm run build: production buildnpm run start: run the production buildnpm run lint: lint
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase project URL (client-safe) |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Supabase anon key (client-safe, protected by RLS) |
Only the anon key is ever used on the client. The service-role key must never appear in frontend code or the repo.
docs/ PRD, schema.sql, security checklist and lessons, review notes
src/
app/
layout.tsx Root layout and fonts
page.tsx Landing page (/)
app/page.tsx Authenticated shelf (/app), gated, greets user
app/notebooks/[id]/ Notebook detail page and editor
app/search/ Search page
login/page.tsx Email/password login (/login)
signup/page.tsx Email/password signup (/signup)
globals.css Tailwind and warm notebook theme
components/ Notebook shelf, editor, links, tabs, search UI
lib/ Data access, server actions, validation helpers
proxy.ts Route protection and session refresh (Next.js proxy)
supabase/migrations/ Numbered, checked-in SQL migrations
- Create a free project at https://supabase.com, then click New project.
- In the project, open Settings > API and copy the Project URL and the anon / public key.
- Put them in
.env.local(see.env.example). - For instant login without email confirmation (fine for personal use), open Authentication > Providers > Email and turn off "Confirm email."
- Restart
npm run dev.
SQL lives in supabase/migrations/ as numbered files, committed to the repo.
Apply each one by pasting it into the Supabase SQL Editor and running it
(no CLI needed). docs/schema.sql is the full reference for all tables.
0001_notebooks.sql: notebooks table and RLS0002_notes.sql: notes table, RLS, and search column0003_links.sql: links table and RLS0004_sticky_tabs.sql: sticky_tabs table and RLS0005_tighten_sticky_tabs_policy.sql: security fix verifying notebook_id ownership and note-to-notebook match on writes (see the security section below)0006_note_content_length_limit.sql: caps note content at 50,000 characters
- Email/password auth with route protection
- Forward-facing notebook shelf with curated cover colors, page colors, and fonts
- Notebook page editor: lined paper, autosave, render-only pagination
- Hyperlinks and YouTube preview cards with thumbnails and timestamps
- Sticky tabs attached to specific notes, with a jump-to-note review strip
- Search across notebooks, notes, and links
- Physical-notebook details: an opening animation, a bookmark ribbon that remembers your place, paper texture, and book-edge shading
- Auth: Supabase email/password;
/app/*gated bysrc/proxy.ts. - RLS: enabled on every table; owner-only policies
(
(select auth.uid()) = user_id) withWITH CHECK, including parent-ownership checks on notes, links, and sticky tabs.user_idis filled by a database default, never trusted from the client. - Keys: only the anon/publishable key is used on the client; the service-role key is never referenced in app code.
- Headers: a Content-Security-Policy scoped to Supabase and the YouTube
thumbnail host, plus
X-Frame-Options: DENY,nosniff,Referrer-Policy, andPermissions-Policy. Seenext.config.ts. - Untrusted input: note content is stored and rendered as plain text
(never
dangerouslySetInnerHTML); pasted URLs are restricted tohttp/https; external links open withtarget="_blank" rel="noopener noreferrer"; link previews store metadata only (no page HTML, no video). - Fail-closed: the proxy and
/appboth refuse to render, rather than silently allowing access, if Supabase env vars are missing in production. - Limits: note content is capped at 50,000 characters (app and database constraint); the signup form requires 8+ character passwords. Note that this is a client-side check only; Supabase under Authentication > Policies can enforce the same minimum server-side, worth doing if this app ever supports more than one real user.
A full, project-agnostic security checklist and a set of generalized
vulnerability patterns (with plain-language explanations) are included in
docs/SECURITY_CHECKLIST.md and docs/SECURITY_LESSONS.md. They were written
after a dedicated security review of this project and cover Row Level
Security patterns, fail-closed auth, input limits, and defense-in-depth
rendering checks that apply to any Supabase or Postgres-backed app.
- Fixed a gap where
sticky_tabsRLS didn't verifynotebook_idownership, which would have let a caller pair their own note with an arbitrary notebook id. See0005_tighten_sticky_tabs_policy.sql. - The proxy and
/appnow fail closed (HTTP 500) in production if Supabase env vars are missing, instead of silently rendering without auth. - Added a server- and database-level cap on note content length.
LinkCardre-validates the URL scheme and YouTube video id at render time, not just at write time, as defense in depth.- Narrowed a broad
try/catcharound link syncing so only "table not created yet" errors are silently ignored; other failures are now logged by error code only.
Run through this before calling the MVP done:
- Sign up, log in, log out
- Logged-out
/app(and/app/notebooks/...,/app/search) redirect to/login - Create, edit, and delete a notebook (delete asks for confirmation)
- Topic never shows on the cover; appears on shelf hover and notebook header
- Open a notebook: lined paper uses the chosen page color and font
- Type notes; they persist after refresh, in order
- Backspace at a note's start merges it into the note above; "Delete note" removes one
- A new page appears past the bottom; page arrows and keyboard arrow keys navigate pages
- Reopening a notebook lands you where you left off (the ribbon)
- Pasting a normal URL creates a link card; pasting a YouTube URL creates a
thumbnail card; a
?t=90or?t=1m30sparam adds a timestamp chip - Adding a sticky tab (color and label) shows it as a side flag and a top tile; clicking jumps to the note; the x removes it
- Search finds notebooks, notes, and links; results jump to the note
- External links open in a new tab safely
-
.env.localis git-ignored;.env.examplehas placeholders only; no secrets committed

