A personal expense tracking web app built with Next.js 16 (App Router), Prisma ORM, Neon (PostgreSQL), and shadcn/ui.
Deployed on Vercel — https://expense-tracker-red-nine-80.vercel.app/
- Node.js 18+
- A Neon account (free tier works)
npm install
# Add your Neon connection string to .env
echo 'DATABASE_URL="postgresql://..."' > .env
npx prisma migrate deploy # apply migrations to your DB
npm run devOpen http://localhost:3000.
- Add expenses with amount, category, description (optional), and date
- Filter expenses by category (Food, Travel, Rent, Shopping, Bills)
- Expenses always sorted newest-first
- Total reflects only currently visible (filtered) expenses
- Idempotency — duplicate submissions never create duplicate records
- Loading and error states throughout
- Submission lock — the form is disabled while a request is in flight
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router) |
| Database | Neon — serverless PostgreSQL |
| ORM | Prisma 6 |
| UI Library | shadcn/ui (Nova preset, Radix primitives) |
| Styling | Tailwind CSS v4 |
| Icons | Lucide React |
| Deployment | Vercel |
Returns all expenses sorted by date descending.
| Query param | Type | Description |
|---|---|---|
category |
string | Filter by exact category name |
Response: 200 OK — array of expense objects.
Creates a new expense.
Headers:
| Header | Required | Description |
|---|---|---|
Content-Type |
Yes | application/json |
Idempotency-Key |
Recommended | UUID per attempt — prevents duplicate saves on retry |
Body:
| Field | Type | Required | Rules |
|---|---|---|---|
amount |
number | Yes | Must be a positive number |
category |
string | Yes | Must be a non-empty string |
date |
string | Yes | Valid ISO date (e.g. YYYY-MM-DD) |
description |
string | No | Free-text note |
Response codes:
| Status | Meaning |
|---|---|
201 |
Expense created |
200 |
Idempotent hit — existing expense returned |
400 |
Validation error (see error field in body) |
500 |
Unexpected server error |
Data is stored in Neon (serverless PostgreSQL) managed through Prisma ORM. The schema is in prisma/schema.prisma; migrations live in prisma/migrations/.
Why Neon over SQLite? The app is deployed on Vercel, which runs on a read-only serverless filesystem. SQLite cannot write to disk in that environment. Neon is free, serverless-compatible, and requires zero infrastructure to operate.
The frontend generates a crypto.randomUUID() per submission attempt and sends it in the Idempotency-Key request header.
The backend:
- Looks up the key in the
idempotencyKeycolumn (unique index onExpense) - If found → returns the existing record immediately (HTTP 200)
- If not found → creates and stores the record along with the key (HTTP 201)
This prevents duplicate rows from:
- Fast double-clicks on the submit button
- Network retries after a timeout
- Page refreshes mid-request
The API is implemented as standard Next.js Route Handlers (app/api/expenses/route.ts) rather than Server Actions. This keeps the API decoupled and testable independently of the UI, and makes the idempotency-key header pattern straightforward to implement.
The header approach puts deduplication control in the client, which is the industry standard (Stripe, Shopify, etc.). Hash-based dedup on field values would incorrectly block legitimately identical expenses (e.g. same coffee, same price, same day).
shadcn components are copied into the project rather than installed as a black-box library. This gives full control over styling and behaviour without runtime overhead, and plays well with Tailwind v4's CSS-variable–driven theming.
The frontend is a single "use client" page managing its own state and calling the REST API via fetch. This is the simplest correct architecture for this scope — no React Query, no global store, no server components for the list (which would complicate the filter/sort interaction).
loadExpenses is wrapped in useCallback so it can safely be listed in the useEffect dependency array. An AbortController is used to cancel in-flight requests when the filter changes rapidly, preventing a slow earlier request from overwriting the result of a faster later one.
| Trade-off | Reason |
|---|---|
| No authentication | Out of scope for a personal single-user tracker |
| No expense editing or deletion | Would require additional routes and UI; not listed in requirements |
| No pagination | Expense list assumed to stay manageable in scope |
| No optimistic UI | The list reloads from the server after each add; simpler and always consistent |
| Fixed category list (hardcoded) | A category management system adds significant complexity with no clear requirement |
| No test suite | Given the timebox, coverage was provided through manual verification and TypeScript's type system |
- Authentication / multi-user support — no requirement for it; would need a session layer (NextAuth, Clerk, etc.)
- Editing / deleting expenses — not in the requirements
- Charts / analytics — nice-to-have but out of scope
- Offline support / PWA — not required
- End-to-end tests — would require Playwright/Cypress setup; validated manually instead
- Dark mode toggle — shadcn's theme system supports it natively but a UI toggle wasn't part of the scope
- Single-user application; no auth required
- Categories are fixed: Food, Travel, Rent, Shopping, Bills
descriptionis optional; all other fields are required- Neon free tier is sufficient (scales automatically, sleeps after inactivity — first request after sleep may be slow)