A local-first, AI-powered note-taking application built for privacy, speed, and collaboration.
Screenshots · Features · Tech Stack · Getting Started · Configuration · Security · Contributing
Notepad AI is a full-stack, Notion-inspired note-taking application that keeps your data private by design. Notes are encrypted client-side with AES-GCM before ever touching storage — the server never sees plaintext content. When you need to collaborate, real-time co-editing over WebSockets with operational-transform conflict resolution has you covered.
Folders, tags, the markdown editor, the AI assistant panel and voice memo — in both themes.
| Light | Dark |
|---|---|
![]() |
![]() |
A shared note open at its share link: permission level, live connection status, and who else is in the room.
Captured from a local production build. The workspace shots run in the app's built-in demo session, which is why the header shows a demo countdown and a Sign In button.
- Rich-text block editor — Notion-inspired writing experience with slash commands, formatting toolbar, and keyboard-driven flow
- Template library — Five built-in templates (Project Plan, Meeting Notes, Daily Journal, Research Notes, Blank), reachable from the workspace as well as the landing page, with live preview and custom naming
- Fill in the blanks — Each template's placeholders become a form; every blank is named for the role it fills, so the tech lead and the developer are separate fields rather than one shared
[Name] - AI-drafted blanks — Describe the note in a sentence and the assistant proposes values for the blanks it can support from what you wrote, leaving the rest empty rather than inventing names or dates
- Version history — Browse, diff, and restore any previous version of a note
- Drag-and-drop reordering — Reorder notes and folders with native HTML5 drag-and-drop
- Dedicated sign-in page —
/loginfronts the OAuth flow and reports why a sign-in failed instead of dropping you back on the marketing page - Two-step verification — Standard TOTP (RFC 6238) with a scannable QR code, so any authenticator app works; enforced server-side between the OAuth callback and the workspace
- Single-use codes — Each code is refused once it has been used, so one captured in flight cannot be replayed
- Recovery codes — Ten single-use codes, shown once and stored only as hashes
- Rate-limited — Five wrong codes buys a fifteen-minute lockout
- Client-side AES-GCM encryption — Notes are encrypted in the browser before being persisted; the server never receives raw content
- Local-first architecture — Fully functional offline; cloud sync is opt-in
- Server-side persistence — Optional database-backed storage via Drizzle ORM with ownership-guarded CRUD and soft-delete (30-day recovery window)
- Recently deleted — Restore soft-deleted notes from a dedicated trash view within 30 days
- Content generation — Draft paragraphs, expand bullet points, and write summaries on demand
- Smart rewriting — Improve tone, clarity, and structure of existing content
- Q&A over notes — Ask questions about your notes and receive contextual answers
- Multi-provider support — Connect OpenAI, Anthropic, or any compatible LLM via environment variables
- Server-side by construction — Every AI call goes through a rate-limited tRPC procedure, and the browser names an operation rather than sending prompts. No provider key reaches the client bundle, and the procedures are not an open relay to a paid model
- Real-time co-editing — Concurrent edits reconciled automatically with operational transformation
- Live cursors — Color-coded cursor labels show exactly where collaborators are typing
- Presence indicators — Avatar row displaying who is currently viewing or editing
- Permission levels — Share with view-only, comment, or full-edit access
- Inline comments — Threaded commenting anchored to specific content
- Offline resilience — Automatic reconnection with exponential backoff and message queuing
- 20+ keyboard shortcuts — Across five categories: Navigation, Editing, Formatting, Search, and General
- Voice memos — Record audio directly in a note with automatic transcription
- Dark / light mode — Persistent theme preference stored in
localStorage - Full-text search — Instant search across all notes and folders
| Layer | Technology |
|---|---|
| Frontend | React 19, TypeScript, Vite 7 |
| UI | shadcn/ui, Tailwind CSS v4, Radix UI |
| Routing | Wouter |
| Server State | TanStack Query v5 |
| Backend | Node.js, Express, tRPC v11, TypeScript |
| Database | Drizzle ORM (MySQL) |
| Real-Time | WebSockets (native) |
| AI | OpenAI / Anthropic (configurable) |
| Testing | Vitest |
| Package Manager | pnpm |
NotionAI_Notepad/
├── client/
│ ├── dev/ # Development-only tooling; never copied into a build
│ ├── public/ # Copied verbatim into dist/public by a build
│ └── src/
│ ├── _core/ # Auth hook (useAuth)
│ ├── components/ # UI components (editor, sidebar, modals, …)
│ │ └── ui/ # shadcn/ui primitives — only the ones the app imports
│ ├── contexts/ # React contexts (theme, …)
│ ├── hooks/ # useNotes, useCollaboration, useKeyboardShortcuts, …
│ ├── lib/ # storage + encryption, sync, collaboration, shortcuts
│ └── pages/ # NotesApp, Landing, Login, SharedNoteView
├── server/
│ ├── _core/ # Express, tRPC context, OAuth, sessions, LLM, env
│ ├── db.ts # Drizzle connection + query functions
│ ├── routers.ts # tRPC routers (auth, notes, demo, backups)
│ ├── totp.ts # TOTP, recovery codes, secret encryption
│ ├── twoFactor.ts # Two-step verification rules
│ ├── rateLimit.ts # In-memory attempt limiter
│ ├── demoLimit.ts # Hashed per-visitor demo tracking
│ ├── aiAssist.ts # Writing assistant prompts and operations
│ ├── voiceMemo.ts # Voice transcription entry point
│ ├── templateDrafting.ts # AI drafting for template blanks
│ └── storage.ts # Encrypted S3 backups
├── shared/ # Shared types and templates (client + server)
├── drizzle/ # Schema, relations, and generated migrations
├── .github/workflows/ # CI and security checks
├── docs/ # Screenshots and the project TODO
├── SECURITY.md # Security policy and threat model
├── drizzle.config.ts
├── vite.config.ts
└── package.json
Where to start reading: server/routers.ts is the whole API surface in one
file and names every feature. Then server/db.ts for queries and
server/_core/sdk.ts for sessions. On the client, App.tsx → pages/ →
hooks/useNotes.ts → lib/storage.ts.
- Node.js 20.19+ or 22.12+ — Vite 7 declares
^20.19.0 || >=22.12.0and refuses to run below it. CI uses Node 22. - pnpm 10 — the version is pinned in
package.json'spackageManagerfield, so the reliable way to get the right one iscorepack enablerather than a global install. The lockfile is v9 format and pnpm 8 cannot read it.
# Clone the repository
git clone https://github.com/VijayKumaro7/NotionAI_Notepad.git
cd NotionAI_Notepad
# Install dependencies
pnpm install
# Configure the app
# There is no .env.example in the repository, deliberately: a checked-in
# template is a standing list of what to look for, and one careless commit
# away from holding a real value. Create .env yourself — .gitignore already
# covers it — using the variables listed under Configuration below.
touch .env# Start client + server with hot reload
pnpm devOpen http://localhost:5000 if you set PORT=5000 in
your .env. Without a .env the server falls back to port 3000.
pnpm build # Compile client and server
pnpm start # Serve the production buildpnpm test # Run all tests once
pnpm test:watch # Watch mode
pnpm smoke # Boot the built server and check it serves (needs pnpm build first)pnpm db:generate # Generate Drizzle migration files
pnpm db:push # Apply schema to the databaseConfiguration is read from the environment. In development that means a .env
file at the repository root, which is git-ignored and which you create yourself;
in production it means the host's secret store — Render's environment groups,
Netlify's environment variables, or whatever your platform provides. No secret
belongs in the repository, in a build artifact, or in anything prefixed VITE_,
which Vite compiles into the client bundle.
render.yaml enumerates every variable a deployment needs, and administrators
should follow the private setup guide (see Deployment below) for where each
credential is obtained and how it is rotated.
The essentials:
| Variable | Purpose |
|---|---|
DATABASE_URL |
MySQL connection string, for server-side notes and sharing |
JWT_SECRET |
Signs session cookies and derives the key that encrypts two-step secrets |
OAUTH_SERVER_URL |
Token exchange and user info, called server-side |
BUILT_IN_FORGE_API_KEY |
The AI provider, for the writing assistant, voice transcription and template drafting — server-side only |
VITE_OAUTH_PORTAL_URL, VITE_APP_ID |
Where the browser is sent to sign in |
PORT, NODE_ENV |
Server basics |
TRUSTED_PROXY_HOPS |
How many reverse proxies sit in front, which decides which X-Forwarded-For entry the rate limits count against. Defaults to 1 in production, 0 otherwise |
Anything prefixed VITE_ is compiled into the client bundle at build time,
not read at runtime. Changing one requires a rebuild. That is also why the AI
key has no VITE_ equivalent: every AI call goes through the server, because a
provider key read in the browser would be published with the bundle. If the two OAuth VITE_
values are missing, the client logs a warning and the Sign In button goes
nowhere.
Notes work with no database at all — they fall back to local encrypted
storage in the browser, which is the local-first design. The features that do
need DATABASE_URL are the ones that have to remember something across devices:
server-side notes and sync, sharing, two-step verification, and the per-visitor
demo limit. Without it those report themselves unavailable rather than failing
at the point of use.
The app is a single Node process: pnpm build compiles the client to
dist/public and bundles the server to dist/index.js, and pnpm start serves
the API, the WebSocket collaboration endpoint, and the client bundle together.
Any host that runs Node works.
pnpm install --frozen-lockfile
pnpm build
pnpm startOn a managed host (Render, Railway, Fly, Cloud Run, a VM):
- Build command
pnpm install --frozen-lockfile && pnpm build - Start command
pnpm start - Port — the platform sets
PORT; the server binds to exactly that and exits with an error if it is taken, rather than quietly moving to another port where health checks would never reach it. - Environment — set every variable before the first build, because the
VITE_ones are baked into the client bundle. Setting them afterwards needs a rebuild, not a restart. - Database — provision MySQL and run
pnpm db:pushonce against it.
render.yaml in the repo root is a Blueprint. In Render choose New →
Blueprint, point it at this repository, and it will prompt for every value the
file marks sync: false.
In order:
- Provision MySQL somewhere else first. Render's managed database is
PostgreSQL and this app is MySQL — PlanetScale, Aiven and Railway all work.
Have the connection string ready; the Blueprint asks for it as
DATABASE_URL. - New → Blueprint, select this repository. Render reads
render.yamland prompts for the rest.JWT_SECRETandDEMO_LIMIT_SALTare generated for you; everything else can be left blank and filled in later, because each feature reports itself unavailable rather than half-working. - Run the migrations once against that database:
pnpm db:pushlocally withDATABASE_URLpointed at it. Sign-in needs the tables that creates. - Add the sign-in methods you want. All optional, all independent:
- Google — create an OAuth client ("Web application") and register
https://<service>.onrender.com/api/auth/google/callbackexactly. - Email —
EMAIL_API_URL,EMAIL_API_KEY,EMAIL_FROMfor any JSON email API. Without all three, email sign-up says so instead of accepting registrations whose confirmation link never arrives. - reCAPTCHA —
RECAPTCHA_SITE_KEYandRECAPTCHA_SECRET_KEY.
- Google — create an OAuth client ("Web application") and register
PUBLIC_ORIGIN needs no value on Render: the server falls back to
RENDER_EXTERNAL_URL, which Render sets to the service's own URL. Set it
explicitly only for a custom domain — it is what the Google redirect URI and the
links inside email are built from, and neither is ever built from a request
header.
server/renderBlueprint.test.ts fails if the server reads a variable the
Blueprint does not declare, which is otherwise invisible: the deploy succeeds
and the feature simply behaves as though it were switched off.
Netlify is frontend-only.
netlify.tomlbuilds the client and returns 404 for/api/*on purpose, so nothing that needs the server works there — no sign-in of any kind, no synced notes, no collaboration. Either serve the whole app from Render, or keep Netlify for the frontend and replace that 404 rule with a proxy to the Render URL, as the comment innetlify.tomlshows.
The database is not in the blueprint, deliberately. Render's managed
offering is PostgreSQL; this app is MySQL (drizzle.config.ts sets
dialect: "mysql", the driver is mysql2). Provision MySQL elsewhere —
PlanetScale, Aiven, Railway, your own — and give Render the connection string as
DATABASE_URL.
Then, once:
DATABASE_URL="<your connection string>" pnpm db:pushTwo things worth knowing:
JWT_SECRETis set togenerateValue: true, so Render creates one on the first deploy. Do not change it afterwards — it signs session cookies, and rotating it signs everyone out.- The
VITE_values are compiled into the client bundle at build time. Changing one requires a redeploy, not a restart. - The free plan sleeps when idle, so the first request after a quiet period waits for a cold start.
Finally, register https://<your-render-domain>/api/oauth/callback with your
OAuth portal — getLoginUrl() derives the redirect URI from whatever origin is
serving the app, so sign-in fails until that origin is allowed.
Signed-out visitors who pick a template get 30 minutes in the workspace before being asked to sign in. The deadline is held in the browser, so clearing site data or opening a private window resets it.
Setting DEMO_LIMIT_SALT also records the deadline server-side against an HMAC
of the visitor's IP address and coarse browser family. The address is never
stored — only the hash, which cannot be reversed or matched without the salt —
and records are deleted 24 hours after the demo ends. The new demoSessions
table arrives with pnpm db:push.
This is a deterrent rather than enforcement, and it is worth being clear about why. People behind one office or mobile-carrier NAT share an address, so one visitor's demo can use up a colleague's. A phone moving between networks gets a new address and so a new demo. It stops the limit being sidestepped by reflex — a private window — and not much more. Leave the salt unset to keep the limit browser-only and record nothing.
netlify.toml in this repo builds and publishes dist/public only. That is a
frontend-only deploy: /api/* returns 404, so sign-in, server-side notes,
version history, and collaboration cannot work there — the app renders the
landing page for a signed-out visitor and nothing more.
To keep a static frontend, run the server somewhere as above and replace the
/api/* rule in netlify.toml with a proxy to it (there is a template in the
file). Otherwise serve the whole app from the Node host and retire the static
site. Note that a proxy still leaves WebSocket collaboration to be routed
separately.
Press Cmd+? (macOS) or Ctrl+? (Windows / Linux) to open the interactive shortcuts reference.
| Shortcut | Action |
|---|---|
Cmd + N |
New note |
Cmd + F |
Focus search |
Cmd + S |
Save note |
Cmd + H |
Version history |
Cmd + Shift + S |
Share note |
Cmd + B / I / U |
Bold / Italic / Underline |
Cmd + ? |
Open shortcuts help |
- All note content is encrypted client-side with AES-GCM before storage — no plaintext ever leaves the browser for locally stored notes
- Server-side notes are ownership-guarded at the query level — users can only read and modify their own data
- Local-first by default — the server is only involved when you choose to sync or collaborate
- The session cookie is
HttpOnly,SameSite=Lax, andSecureover https - No third-party analytics, telemetry, or tracking
Turn it on from Security in the workspace header. It is standard TOTP — SHA-1, six digits, thirty-second step — so Google Authenticator, 1Password, Authy and anything else that reads a setup key will work. Enrolment shows a QR code, with the setup key underneath it for anything that will not scan.
How it fits into sign-in: the OAuth portal vouches for who you are, and if the
account has a confirmed enrolment the callback issues a pending session
rather than a real one. That cookie cannot reach a single protected procedure,
so /login asks for the code before anything else happens.
Two things to know before deploying it:
- The
userTwoFactorandtwoFactorRecoveryCodestables arrive withpnpm db:push. Until they exist the Security panel errors — this feature needs a database even though notes do not. - Rotating
JWT_SECRETinvalidates every enrolment as well as every session, because it derives the key that encrypts the stored secrets.
SECURITY.md is the source of truth for the security model —
what is protected and how, the known limitations, and how to report a
vulnerability privately. It is deliberately not repeated here, so the two cannot
drift apart.
| Command | Description |
|---|---|
pnpm dev |
Start development server (client + server) |
pnpm build |
Build for production |
pnpm start |
Run production build |
pnpm check |
TypeScript type-check (no emit) |
pnpm test |
Run test suite |
pnpm test:watch |
Run tests in watch mode |
pnpm smoke |
Boot the built server and check it serves |
pnpm format |
Format all files with Prettier |
pnpm db:generate |
Generate Drizzle migration files |
pnpm db:push |
Apply schema to the database |
Contributions are welcome. Please follow these steps:
- Fork the repository and create a feature branch:
git checkout -b feat/your-feature - Make your changes, keeping commits focused and messages clear (
feat:,fix:,chore:) - Run
pnpm check && pnpm testand ensure everything passes - Open a pull request with a clear description of what changed and why
For significant changes, open an issue first to discuss the approach.
Two workflows gate the merge, and both have to pass:
| Workflow | Checks |
|---|---|
ci.yml |
pnpm check, pnpm test, pnpm build, pnpm smoke |
security.yml |
pnpm audit, dependency review |
pnpm smoke is the last step of ci.yml and the only one that runs the server.
It boots the production build, asks for a page, an asset and a path that should
not exist, and checks it gets a real answer to each. It is there because an
express 5 upgrade once passed the type-check, the whole test suite and the build
while being unable to start at all — everything short of running the process
said it was fine. See scripts/smoke.sh.
The audit fails the run on a critical or high advisory. Moderate and low are printed but do not block — a moderate advisory deep in someone else's dependency would otherwise wedge every open pull request until an upstream fix appeared, and a gate that blocks work it cannot help with is one people learn to switch off. A weekly scheduled run is what keeps moderates from piling up quietly.
Dependency review is stricter in the one place it can afford to be: it blocks a pull request that introduces a vulnerable dependency, because that is a choice the author can act on.
CodeQL also runs on every pull request, as Analyze (javascript-typescript),
but from GitHub's default setup rather than from this repository's
workflows — the two configurations cannot coexist, and default setup was already
enabled.
You can run the whole gate locally before pushing:
pnpm install --frozen-lockfile
pnpm check && pnpm test && pnpm build && pnpm smoke
pnpm audit --audit-level highThe pull request template carries a short security checklist. It is there to be read, not ticked — if a change touches user data, sessions, or anything holding a secret, say in the PR how you know it is safe.
Security policy, the intended security model, and how to report a vulnerability
privately: SECURITY.md.
Vijay Kumar — @VijayKumaro7
Released under the MIT License.



