6 min read

Baseline app. The goal here isn't the todo list — it's practicing the full eleven-step process (see How to Plan a Full-Stack Project) on the simplest possible domain, so the process itself becomes automatic before app 2 adds real complexity on top of it.


1. Problem framing

People track tasks in scattered places — sticky notes, notes apps, the backs of receipts — and lose track of what's actually left to do. The user wants one place, reachable from any device, to write down a task and mark it done. No collaboration, no reminders, no project management — just a reliable personal list.

2. PRD

Goal: Let a signed-in user create, complete, and manage a personal task list from any device.

Non-goals (explicitly out of scope for v1):

  • Sharing or collaborating on lists with other users
  • Due dates, reminders, or notifications
  • Subtasks, tags, or projects/folders
  • Native mobile app (responsive web only)
  • Drag-to-reorder (manual ordering)

User stories:

  • As a user, I want to sign up and log in, so my tasks are private and persist across devices.
  • As a user, I want to add a task with a title, so I can capture something I need to do.
  • As a user, I want to mark a task complete/incomplete, so I can track progress.
  • As a user, I want to delete a task, so my list doesn't fill up with junk.
  • As a user, I want to filter by All / Active / Completed, so I can focus on what's left.

Success metrics:

  • Create → see task on screen in under 500ms (p95)
  • Zero data loss on refresh or device switch
  • First contentful paint under 1.5s on 4G

MVP cut line: auth + create/read/update(complete)/delete + filter. Everything in non-goals is deferred to a hypothetical v2.

3. Wireframes & UX flow

Screen inventory:

ScreenPurposeKey elements
LandingUnauthenticated entry pointValue prop, "Log in" / "Sign up"
AuthSign up / log inEmail + password form, error state
Task list (main)The whole appAdd-task input, list, filter tabs, empty state

Everything after login happens on one screen — that's a deliberate MVP decision, not laziness: a single-screen app has no routing edge cases, no "where does the user end up after X" questions to design around.

4. HLD

No external services, no cache, no queue. Stack rationale: this app's entire read/write pattern is "fetch my rows, mutate my rows" against a dataset that's at most a few thousand rows per user — Postgres handles that without help. Reaching for Redis or a queue here would be solving a problem this app doesn't have (see the methodology chapter's stack philosophy).

5. Data model / ERD

Index: TASK(user_id, completed) — every query on this table filters by owner and usually by completion state, so that's the composite index that matters.

6. API design + flow mapping

MethodPathBodyResponseAuth
GET/api/tasks?filter=active|completed|all—Task[]required
POST/api/tasks{ title }Task (201)required
PATCH/api/tasks/:id{ completed? , title? }Taskrequired, must own task
DELETE/api/tasks/:id—204required, must own task

Flow: create a task (optimistic UI)

If the POST fails, the client rolls back the optimistic row and shows an inline error — this failure path is exactly the kind of thing that's easy to forget if you go straight to code without drawing the flow first.

7. LLD

Module breakdown:

code
app/api/tasks/route.ts       — GET (list), POST (create)
app/api/tasks/[id]/route.ts  — PATCH (update), DELETE
lib/db.ts                    — Prisma client singleton
components/TaskList.tsx      — fetches + renders, owns filter state
components/TaskItem.tsx      — checkbox, delete button, optimistic toggle
components/TaskForm.tsx      — the add-task input

Edge cases worth naming explicitly:

  • Empty title — reject server-side (never trust client validation alone); trim whitespace before checking.
  • Double-submit — a fast double Enter-press before the optimistic row settles; disable the input while a create is in flight rather than de-duping server-side, since a single-user single-device create has no real concurrent-write race to worry about.
  • Ownership check on every mutation — PATCH/DELETE must filter by WHERE id = ? AND user_id = ?, not just WHERE id = ?. This is the single most common authz bug in CRUD apps: it works fine until someone edits the URL.
  • Empty list state — distinguish "no tasks yet" from "no tasks match this filter" in the UI; they're different messages.

8. NFRs

  • Performance: task list (≤1,000 rows) loads in under 300ms server-side; indexed query, no N+1.
  • Security / authz: every mutation checks user_id ownership server-side; passwords hashed via Auth.js's credential provider (bcrypt); session cookie is httpOnly + Secure + SameSite=Lax.
  • Observability: log 4xx/5xx from the tasks API with user id + route; no need for tracing/metrics infra at this scale — a log line and Vercel's built-in function logs are enough.

9. ADRs

code
ADR-1: Postgres over client-only storage (localStorage/IndexedDB)
Context: user wants tasks available across devices.
Decision: persist server-side in Postgres from day one.
Alternatives: localStorage-only MVP, sync later.
Consequence: requires auth + a backend from the start, but avoids a
             painful "add a server" migration later.

ADR-2: Route Handlers (REST) over Server Actions for the tasks API
Context: Server Actions would mean less boilerplate for this single client.
Decision: expose a stable REST contract under /api/tasks anyway.
Alternatives: Server Actions called directly from components.
Consequence: slightly more code now; the same API is reusable by a future
             mobile client or third-party integration without a rewrite.

ADR-3: No manual ordering in v1
Context: drag-to-reorder needs either a float "position" column with
         fractional reinsertion or a full reindex on every move.
Decision: defer — sort by created_at only for MVP.
Alternatives: implement fractional-index ordering now.
Consequence: users can't manually reorder tasks until v2; acceptable
             because it wasn't in any user story.

10. Deploy plan

  • Environments: local (.env.local + local Postgres), Preview (one per PR, Vercel + a branch database), Production (main branch).
  • CI: typecheck + lint on every push; prisma migrate deploy runs as part of the Vercel build step against the target database.
  • Definition of done: a user can sign up, add/complete/delete tasks, refresh and see them persist, on the deployed URL.
  • Rollback: Vercel's instant rollback to the previous deployment; database migrations for this app are additive-only in v1, so rollback never needs a down-migration.

11. Retro — "at 10x"

If this suddenly had 10x the users, the first thing to break is the unindexed assumption that "a user's task list is small" — a power user with tens of thousands of completed tasks turns GET /api/tasks into a slow sequential-ish scan without pagination. Second: nothing currently rate-limits task creation, so a misbehaving script could hammer inserts. At 10x, I'd add cursor-based pagination to the list endpoint and a basic per-user rate limit on POST — both are the kind of thing that's cheap to add proactively but easy to forget until an incident forces the question.