Written by Bilal Amjad. (Claude assisted)
A todo application where a todo is a document: it carries attachments, formatted text, a branded printable record, and a shape that fits the industry you work in.
It is one Next.js project. The UI is Next.js 14 (App Router, TypeScript, Tailwind) and the REST API is Express + Mongoose, both served from a single custom server on a single port. A small Rust service renders the PDFs, and it is the only part that is not JavaScript.
Version 2.0 — see the changelog for what is new.
Organise — due dates with overdue/today/soon badges · five priority levels · free-form tags with autocomplete · subtask checklists with progress · pinning · archive · duplicate · recurring todos (daily/weekly/monthly)
Find — full-text search across titles, descriptions and tags with highlighted matches · combined status/priority/tag/due filters · sorting on five fields · filters live in the URL, so any view is a shareable link
Act — multi-select with bulk status, priority, tag, pin, archive and delete · optimistic updates with undo · trash with restore · empty trash
Structure — projects with their own todos and analytics · start dates and effort estimates · a built-in timer per todo · comments · an activity trail · dependencies, with cycle detection and blocked todos that cannot be completed
Reuse — saved views · templates (create one from scratch or from an existing todo) · a tag manager that renames, merges and deletes across every todo
See — list, grid, drag-and-drop board, month calendar and table views · today, upcoming and weekly-review pages · analytics with completion trends, status and priority breakdowns, top tags, a contribution heatmap and a daily streak
Make it yours — 50 colour themes in three collections (men, women and other), each with a light and a dark version · any Google Font by name, fetched through the app itself · five text sizes · compact mode. See Theming and fonts.
Control — Ctrl/Cmd+K command palette · keyboard shortcuts (n, /, g d,
?) · installable as a PWA, and a reload works offline · a skip link, live
regions and focus management throughout
Account — change password with a strength meter · avatar picker · two-factor authentication with recovery codes · per-device sessions you can revoke · an audit log · export and import (JSON or CSV, with a dry run) · rate-limited sign-in · validation shared between client and server
Attach — images, video, audio and documents on any todo, project or template, plus a personal drive · streaming uploads with byte-exact progress · automatic compression that never makes a file bigger · quotas and tiers · Range-aware downloads, so a video seeks properly. See Files and storage.
Write — Word-style formatting on titles and descriptions: bold, italic, underline, colour, font, size, alignment, headings, lists, quotes, code and links. Sanitised on the server against a fixed allowlist. See Rich text.
Print — a branded, versioned PDF of any todo or project, rendered by a Rust + Typst service, stored like any other file and listed on the record itself — with an honest "up to date" or "changed since v3". See PDF documents.
Fit your work — run the app as General, School, Law Enforcement, Hospital or Restaurant. Each has its own vocabulary, its own fields and its own pages — a gradebook, an evidence trail, a shift handover, an order sheet. See Application types.
node server.js
│
├── /api/* → Express app (server/) — auth, todos, projects, files, PDFs, account
├── /* → Next.js handler (src/app/) — pages, assets (HMR in development only)
│
└── HTTP → services/pdf (Rust) — renders PDFs
Docker: its own container at pdf:8787
yarn dev: a spawned child on 127.0.0.1:8787
Pages and API share an origin, so there is no CORS layer and the JWT session
cookie is a plain same-site HttpOnly cookie. The browser calls the API with
relative paths — nothing has a hardcoded host or port.
The PDF renderer is a separate Rust service. Under Docker it runs in its own
container, reachable only over an internal compose network; for a host run with
yarn dev, server.js spawns a locally built binary as a child process on
loopback instead. PDF_SERVICE_SPAWN and PDF_SERVICE_URL are the entire
difference between the two — see
example-seperate-service-internal-network-only.txt.
server.js custom server: connects to MongoDB, prepares Next, mounts Express at /api,
spawns the PDF renderer
server/ the API
app.js Express sub-app (helmet, cookies, parsing, logging, errors)
config/ storage tiers and caps; the application-type registry loader
routes/ user, todo, trash, stats, project, library, account, font, file
controller/ all Mongoose access (including files, storage and PDFs)
models/ User, Todo, Trash, Project, Comment, Activity, SavedView,
Template, AuditLog, StoredFile, GeneratedPdf
services/ compression, upload progress, the boot sweep, the PDF client
middleware/ auth, validate, rate-limit, logger, error handler
utils/ GridFS, magic-byte typing, the HTML sanitiser, rich-text helpers
validation/ zod schemas — the CommonJS mirror of src/lib/validation.ts
__tests__/ API test suite
services/pdf/ the Rust renderer (Cargo, not Yarn)
src/ HTTP + auth, the request contract, markup, rendering
templates/ common.typ plus one .typ per application type
fonts/ the faces baked into the binary
src/
app/ pages
app/themes.css generated — the 50 palettes (never edit by hand)
components/ui/ the design system
components/todo/ cards, board, calendar, filters, charts, PDF versions
components/files/ uploader, grid, previews, quota meter
components/variant/ the one generic renderer for application-type fields
hooks/ data + filter + keyboard hooks
lib/ api client, helpers, validation, dates, themes, variants
shared/ data both halves read: themes.json, google-fonts.json, variants/
scripts/ generators for the icons, the themes, the fonts and the variants
Every colour in the app is a semantic CSS variable — --bg, --surface,
--primary, --fg-muted and so on — mapped into Tailwind as
rgb(var(--token) / <alpha-value>). A theme is nothing more than a different set
of values for those same variables, which is why adding fifty of them changed no
component at all.
shared/themes.json holds one compact seed per theme (three hues, two chroma
amounts, a default font and a collection). yarn themes expands each seed into
a light and a dark block:
[data-theme="rose-quartz"] { --bg: 255 248 251; --primary: 168 56 118; … }
.dark[data-theme="rose-quartz"] { --bg: 21 7 14; --primary: 234 136 184; … }Colours are computed in OKLCH, so one lightness ramp reads the same across every hue, and the generator fails the build if a theme misses its contrast floors (7:1 for body text, 4.5:1 for muted text and button labels). An unreadable theme cannot ship. Status and priority hues are deliberately fixed across all fifty — red, amber and green identify state on every badge in the app, and a theme must not turn an encoding into decoration.
Theme and light/dark are orthogonal: the theme picks the personality, the topbar toggle picks the mode, and every theme has both. Pick one under Settings → Appearance.
src/app/themes.cssandsrc/lib/themes.generated.tsare generated. Editshared/themes.jsonand runyarn themesinstead.
Type a family name in Settings → Appearance and it is applied. Suggestions come from a committed list of every Google family, but the field is not limited to it — any correctly spelled name is checked against Google and used.
Fonts are proxied, not linked. The app's Content-Security-Policy is
font-src 'self', so the browser cannot reach fonts.googleapis.com or
fonts.gstatic.com at all. Instead the API fetches the stylesheet, rewrites every
font URL to one of its own, and serves the files itself:
browser → GET /api/fonts/css?family=Sora
→ server fetches fonts.googleapis.com
→ rewrites gstatic URLs to /api/fonts/file/<id>
browser → GET /api/fonts/file/<id>
→ server fetches and caches the woff2
Three things follow: the CSP stays shut, the service worker caches the files like
any other same-origin request so a chosen font still works offline, and no user's
browser ever makes a request to Google. /api/fonts/file/:id takes an opaque id
rather than a URL — ids exist only after parsing a stylesheet Google sent us,
which is what keeps the endpoint from being turned into a proxy for anything else.
Inter and JetBrains Mono are self-hosted by next/font and remain the defaults.
Files live in one GridFS bucket, addressed through a StoredFile record —
quota, ownership and lifecycle live there, so nothing in the app ever queries the
bucket directly. Attach them to a todo, a project or a template, or keep them in
the personal drive at Files.
Four things are worth knowing:
- Nothing is buffered. An upload is streamed to a temp file, identified, then streamed into the bucket. A 250 MB upload grows the process by about 26 MB.
- The bytes decide what a file is, never the browser's
Content-Type. The extension is rebuilt from the detected type, and anything outside the allowlist is refused. - Compression is best-effort and honest.
sharp,ffmpegandqpdfare probed at runtime; a machine without one simply stores the original. It never fails an upload, never makes a file larger, and records why when it does not apply — a silent fallback is indistinguishable from compression never running. - Progress is real. One SSE stream per tab carries byte-exact upload and storage progress. Tools that report nothing show a sliding band rather than an invented percentage.
Quotas are enforced by a reserve / reconcile / release cycle, with the quota comparison inside the same atomic update that increments the counter — which is what stops two concurrent uploads both fitting into the same free space. Tiers are self-serve for now; billing is not wired up.
Tiers are defined in one place, server/config/storage.js, and come in two
independent kinds:
-
Storage plans (
TIERS) — the account's total quota: Free (1 GB), then 10, 25, 50, 100, 250 and 500 GB, and 1 TB. -
Per-file tiers (
PER_FILE_TIERS) — the largest single file of each kind:Tier Images Videos Audio PDFs Standard ( base)15 MB 100 MB 50 MB 50 MB Extended ( plus)25 MB 600 MB 100 MB 1 GB Premium ( premium)100 MB 4 GB 100 MB 3 GB
GET /api/files/quota sends both lists with their labels and sizes, and the Files
page renders exactly what it receives — add, rename or resize a tier in that file
and the page follows, with no frontend change. Changing a plan from the page
requires ALLOW_SELF_SERVE_TIERS=true; otherwise the plan is shown read-only.
Uploads must complete within Node's default 5-minute request timeout, which is
what bounds the largest practical file on a slow connection.
Titles and descriptions are stored as a pair: sanitised HTML and a plaintext mirror. Four rules hold it together:
- Sanitising happens on the server, on write. The client never sanitises; a second copy would suggest its version counted.
- The mirror is derived in exactly one place, which is what search, CSV export and quick add read.
- When both halves arrive, the markup wins — otherwise a client could pair real HTML with a forged mirror and poison what search sees.
- Titles take inline marks only. A title renders inside one-line cells in the table view, the command palette and search results, so headings and lists are flattened there.
style is allowed but every value is validated — a hex or named colour, a
unit-bearing size, a plain family list, one of four alignments — and the allowed
class list is four alignment classes, so a paste cannot borrow the app's own
chrome to impersonate it.
Any todo or project can be rendered to a branded PDF, versioned, and kept beside the record that produced it. The renderer is a small Rust service (Axum + Typst) that the Node server spawns as a child process.
It is deliberately the dumbest component in the system: no database, no filesystem outside its own binary, no URLs. Everything it needs arrives in one JSON body — and it never sees HTML, because the sanitised markup is converted to a closed tree of nodes first. That keeps an HTML parser, and therefore attacker-controlled markup, out of the renderer entirely. Its fonts are baked into the binary, so a document does not depend on what the host happens to have installed.
On the versioning side:
- Version numbers are allocated atomically, so two clicks a moment apart can
never both produce a
v4. - A deleted version never has its number reused — a file already in someone's downloads folder stays unambiguous.
- Filenames sort naturally and say what they came from:
TODO-6f3a1b-v03-20260912-1430-quarterly-safety-review.pdf. - The list says "Up to date with the current todo" or "changed since v3", computed from a hash of the content that went into the render rather than guessed from timestamps.
- The document uses your theme's light palette — a PDF is printed far more often than it is read on a screen.
Versions are never pruned automatically: keeping ten is your call, and so is losing one.
PDFs need the Rust binary. Without it everything else works and PDF generation reports itself unavailable.
One account runs the app as General, School, Law Enforcement, Hospital or Restaurant, chosen under Settings → Application. A project can override the account's choice, so a School course can sit beside a General project.
| Type | Records are | Pages it adds |
|---|---|---|
| General | Todos, Projects | — |
| School | Assignments, Courses | Courses, Gradebook |
| Law Enforcement | Cases, Operations | Cases, Evidence |
| Hospital | Tasks, Wards | Wards, Shift handover |
| Restaurant | Prep, Stations | Stations, Prep list, Orders |
Exactly three things vary: the words on screen, the extra fields a record carries, and presentation (icon, PDF template, suggested templates). Everything else — the views, the filters, the shortcuts, the colours that encode status — is identical for all five.
The rule that makes this work: no component branches on the type. Components
ask a registry what things are called and which fields exist, and render
generically. A type is shared/variants/<id>.json plus a .typ template, not a
patch across the codebase; pages are declared by kind (group, scoreboard,
files, stock) and rendered by one route.
Switching type hides fields; it never deletes them. Data belonging to a type you are not using stays on the record and is listed on its detail page, so nothing is ever lost by changing your mind.
A few things each type brings:
- School — weighted grades that report no grade rather than 0% for work that has not come back.
- Law Enforcement — a chain of custody on every stored file that outlives the file, and PDFs with a handling banner on every page plus an evidence manifest carrying each exhibit's checksum.
- Hospital — a patient reference, never a name, and a standing notice on screen and on every printed sheet: this is a task manager, not an EHR and not a medical device, and file reads are not audited.
- Restaurant — par levels and stock counts, an Orders page showing only what is short, and a station sheet that prints the prep list and the order it generates on one page.
Required
- Node.js 24+
- Yarn, any version — the repository pins Yarn 4.18.0, and a global Yarn 1 or
Corepack's
yarnhands off to it - MongoDB (or Docker, which brings one up for you)
Optional — each one degrades gracefully if missing
- A Rust toolchain, to build the PDF renderer. Without it, everything else works and PDF generation reports itself unavailable.
ffmpeg(video and audio compression) andqpdf(PDF compression).sharpships its own binaries, so images are always covered. Missing tools are detected at startup and the original file is stored instead.
The Docker setup includes all of them: the renderer in its own image, ffmpeg and
qpdf in the app image.
cp .env.example .env # then edit MONGO_URL, JWT_SECRET and PDF_SERVICE_KEY
yarn install
# optional: build the PDF renderer (first build takes a few minutes)
cargo build --release --manifest-path services/pdf/Cargo.tomlThe project uses Yarn 4, pinned: .yarnrc.yml points yarnPath at the
committed .yarn/releases/yarn-4.18.0.cjs, so whichever yarn is on your PATH
— a global 1.x, or Corepack's — runs that exact release. There is no
package-lock.json; commit yarn.lock, and change dependencies with yarn add
and yarn remove.
nodeLinker: node-modules— a plainnode_modulestree. Plug'n'Play breaks Next.js and the native modules.enableScripts: false— no dependency runs install scripts.bcryptandsharpload their bundled prebuilt binaries without one, and the test suite'smongodis downloaded on the firstyarn testrather than at install.resolutionsinpackage.jsonpinsqsandpostcssabove the versionsexpressandnextask for (npm called theseoverrides).
| Variable | What it does |
|---|---|
MONGO_URL |
the only database setting the app reads — required |
JWT_SECRET |
signs session tokens — required |
PORT / HOSTNAME |
where the single server listens (default 3000 / localhost) |
PDF_SERVICE_KEY |
shared secret between the app and the renderer; at least 16 characters, or the renderer refuses to start |
PDF_SERVICE_SPAWN |
0 to run the renderer as its own service instead of a child process |
PDF_SERVICE_URL |
where to find it when it is not a child process |
UPLOAD_TMP_DIR |
scratch space for uploads in flight — give it a volume in production |
DISABLE_RATE_LIMIT |
for local testing only |
# development (hot reload)
yarn dev
# production
yarn build
yarn start
# tests — the API suite, then the frontend one
yarn test
yarn test:api
yarn test:web
# lint
yarn lint
# regenerate the theme CSS after editing shared/themes.json
yarn themes
# regenerate the application types after editing shared/variants/*.json
yarn variants
# refresh the bundled Google Fonts family list
yarn fonts
# the PDF renderer: build it, test it, run it by hand
cargo build --release --manifest-path services/pdf/Cargo.toml
cargo test --manifest-path services/pdf/Cargo.tomlThe whole application — pages and API — is then on http://localhost:3000
(set PORT to change it).
For people who just want to use Modern Todo on their own computer: installer/ is a
small Rust GUI program, shipped as installer.exe for Windows and installer.deb for Linux.
It needs no Docker and no administrator rights. It downloads Node.js, MongoDB and main
into one folder, builds the app there, and fetches the PDF renderer prebuilt by
.github/workflows/pdf-renderer.yml. From then on, one window opens, updates, closes and
uninstalls Modern Todo. See installer/README.md.
Three containers, each from its own image:
| Service | Image | What it is | Reachable from |
|---|---|---|---|
app |
modern-todo-app (~1.66 GB) |
Next.js pages + Express API, production build | the host, on PORT |
pdf |
modern-todo-pdf (~175 MB) |
the Rust renderer | app only |
mongodb |
mongo:8.0 |
the database | app only |
The app and the renderer build from separate contexts (. and services/pdf/),
so they build in parallel and a change to one never rebuilds the other. The
renderer's first build compiles typst and a few hundred crates and takes far
longer than the app's; nothing else waits for it.
# recommended: starts the renderer's build in the background, brings up MongoDB
# and the app as soon as their images are ready, then starts the renderer when
# its build finishes
./scripts/docker-up.sh
# the same by hand, in two terminals
docker compose up -d --build mongodb app
docker compose up -d --build pdf
# plain `up --build` works too, but compose builds every image before starting
# any container, so it waits for the Rust compile
docker compose up --build
# after changing application code, rebuild and restart just the app
docker compose up -d --build appPDF_SERVICE_KEY must be set in .env (16+ characters) — the renderer exits at
boot without it. The app does not depend on the renderer: until pdf is
listening, PDF export answers 503 and everything else works.
The app container is always the production server. next build runs once
inside the image and pages are served from that bundle, so nothing compiles on
request; compose pins NODE_ENV=production for it whatever .env says. There is
no source sync or watch mode — for hot reload, run yarn dev on the host. The
image is multi-stage — yarn install --immutable, yarn build, then
yarn workspaces focus --all --production to drop devDependencies — and the
runtime holds only
ffmpeg, qpdf, production node_modules, server/, shared/, public/ and
the built .next. It runs as node, with the code read-only to it; only .next
is writable.
The renderer image ends in debian:trixie-slim with nothing but the binary
(fonts and templates are compiled in). It runs as a non-root user on a read-only
filesystem.
Networking: MongoDB and the renderer sit on an internal network with no
published ports and no route out. The app is on that network and on a normal one
that carries its published port — the only thing that reaches the host.
yarn test runs both suites on Node's built-in test runner — no jest, no vitest
and no build step, because Node 24 strips the types itself.
The API suite runs against an in-memory MongoDB and covers auth, per-user isolation, filtering, sorting, pagination, bulk operations, the trash round trip, recurrence, projects, comments, dependencies, timers, export/import, 2FA, the font proxy, stats, rate limiting, uploads and quotas, compression, the HTML sanitiser, PDF versioning, and each application type. The frontend suite covers the pure modules: helpers, dates, quick-add parsing, filter serialisation, validation, rich text, the theme catalogue and the application-type registry.
yarn test # downloads a mongod binary on first run
MONGO_TEST_URL=mongodb://localhost:27017/todo-test yarn test # or use your own
cargo test --manifest-path services/pdf/Cargo.toml # the renderer's own testsTests that need a tool this machine lacks (qpdf, or the Rust binary) skip
rather than fail, so a clean checkout is green with nothing extra installed.
All routes are under /api. /api/user/register and /api/user/login are
public; everything else requires the session cookie (or an
Authorization: Bearer <token> header).
| Method | Path | Description |
|---|---|---|
| GET | /api |
health check |
| POST | /api/user/register |
create an account |
| POST | /api/user/login |
sign in, sets the token cookie |
| POST | /api/user/logout |
clears the cookie |
| GET | /api/user/me |
current user |
| PUT | /api/user/update |
update name / status / avatar |
| PUT | /api/user/preferences |
theme, theme id, font, default view, page size, density, text size |
| PUT | /api/user/password |
change password |
| GET | /api/todo |
list — supports page, limit, q, status, priority, tags, due, archived, pinned, sort, order |
| POST | /api/todo |
create |
| GET | /api/todo/:id |
read one |
| PUT | /api/todo/:id |
update |
| DELETE | /api/todo/:id |
delete, moving a copy to trash |
| POST | /api/todo/:id/duplicate |
duplicate |
| PATCH | /api/todo/bulk |
bulk status/priority/tag/pin/archive/delete |
| PUT | /api/todo/reorder |
persist manual order |
| GET | /api/trash |
list trashed todos |
| PUT | /api/trash/:id |
restore |
| DELETE | /api/trash/:id |
delete permanently |
| DELETE | /api/trash |
empty trash |
| GET | /api/stats |
analytics aggregation |
| GET | /api/tags |
tags with usage counts |
| GET/POST | /api/project |
list / create projects (also /:id, /reorder) |
| GET/POST | /api/todo/:id/comments |
read and add comments (edit and delete via /api/todo/comments/:commentId) |
| GET | /api/todo/:id/activity |
the change trail for one todo |
| POST | /api/todo/:id/timer/start |
start / stop a timer (/stop) |
| GET/POST | /api/view |
saved views (also /:id) |
| GET/POST | /api/template |
templates, plus /from-todo and /:id/use |
| PUT | /api/tags/rename |
rename, /merge, or DELETE /api/tags/:tag |
| GET | /api/account/export |
export the account; /export/todos for JSON or CSV |
| POST | /api/account/import |
import todos, with a dry-run mode |
| GET | /api/account/sessions |
signed-in devices; DELETE one or /all |
| POST | /api/account/2fa/setup |
two-factor: /enable, /disable |
| GET | /api/account/audit |
account security events |
| GET | /api/fonts/search |
Google Fonts typeahead |
| GET | /api/fonts/css |
a family's stylesheet, proxied and rewritten |
| GET | /api/fonts/file/:id |
one font file, by an id the css route issued |
| GET/POST | /api/files |
list files / upload one (multipart, streamed) |
| GET/HEAD | /api/files/:id/raw |
the bytes: Range-aware, ETag, ?download=1 |
| DELETE | /api/files/:id |
permanent; drops the bytes and credits the quota |
| GET | /api/files/:id/activity |
one file's chain of custody |
| GET | /api/files/events |
live upload and compression progress (SSE) |
| GET/PUT | /api/files/quota |
usage and caps; /quota/tier changes the plan |
| GET/POST | /api/todo/:id/pdfs |
versions, newest first / render a new one (202) |
| GET/DELETE | /api/todo/:id/pdfs/:pdfId |
the bytes / delete that version for good |
(/api/project/:id/pdfs is the same four routes) |
- CHANGELOG.md — what changed, and when.
- CONTEXT.md — the architecture in depth: every decision that is not obvious from the code, and why it is the way it is.
- PROGRESS.md — the feature ledger, batch by batch.
- example-seperate-service-internal-network-only.txt — running the PDF renderer as its own service.
You are free to use this code for your own projects, modify it, or publish it anywhere. Please give me credit if you use it. (@Thedevelop3r), thanks.


