30 to 90 Days: Build a Full-Stack Expense Tracker with Auth
Engineering

30 to 90 Days: Build a Full-Stack Expense Tracker with Auth

November 20, 2024
10 min read

30 to 90 Days: Build a Full-Stack Expense Tracker with Auth

A full-stack app with authentication, a real database, and a deployed URL separates junior developers from engineers who can ship.

An expense tracker is the canonical medium-sized project for a reason: it's complex enough to be real but scoped enough to finish. You'll deal with user authentication, relational data that belongs to a specific user, form handling, date arithmetic, aggregation queries, and a production deployment that non-technical people can actually use. This guide takes you from zero to production in 30–90 days depending on how many hours per week you have.


Tech Stack Decisions

Before writing a line of code, understand why each tool is in the stack:

LayerChoiceWhy
FrameworkNext.js 14 (App Router)Server Components + Server Actions eliminate most API boilerplate
DatabasePostgresACID transactions, date functions, JSON columns — you'll need all of these
ORMPrismaType-safe queries, automatic migrations, excellent DX
AuthNextAuth.js v5Handles OAuth, session management, and CSRF without you writing JWT logic
StylingTailwind CSSFast iteration, consistent design
HostingVercel + SupabaseGenerous free tiers, great DX

Resist the urge to swap any of these out until you've shipped the first version. Stack decisions are worth zero points — shipping is worth everything.


Phase 1 (Days 1–10): Data Modelling

Get the schema right before you write any UI. A bad schema is expensive to change later.

Install dependencies and initialise Prisma:

bash
1npm install prisma @prisma/client
2npx prisma init --datasource-provider postgresql

Define the schema:

prisma
1// prisma/schema.prisma
2generator client {
3 provider = "prisma-client-js"
4}
5
6datasource db {
7 provider = "postgresql"
8 url = env("DATABASE_URL")
9}
10
11model User {
12 id String @id @default(cuid())
13 email String @unique
14 name String?
15 emailVerified DateTime?
16 image String?
17 accounts Account[]
18 sessions Session[]
19 expenses Expense[]
20 categories Category[]
21 createdAt DateTime @default(now())
22}
23
24model Category {
25 id String @id @default(cuid())
26 name String
27 color String @default("#6366f1")
28 icon String @default("tag")
29 userId String
30 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
31 expenses Expense[]
32
33 @@unique([name, userId]) // Same user can't have duplicate category names
34}
35
36model Expense {
37 id String @id @default(cuid())
38 amount Decimal @db.Decimal(10, 2) // Never use Float for money
39 description String
40 date DateTime @db.Date
41 categoryId String
42 category Category @relation(fields: [categoryId], references: [id])
43 userId String
44 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
45 createdAt DateTime @default(now())
46 updatedAt DateTime @updatedAt
47
48 @@index([userId, date]) // Queries are almost always scoped to a user + date range
49}
50
51// NextAuth required models
52model Account {
53 id String @id @default(cuid())
54 userId String
55 type String
56 provider String
57 providerAccountId String
58 refresh_token String? @db.Text
59 access_token String? @db.Text
60 expires_at Int?
61 token_type String?
62 scope String?
63 id_token String? @db.Text
64 session_state String?
65 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
66
67 @@unique([provider, providerAccountId])
68}
69
70model Session {
71 id String @id @default(cuid())
72 sessionToken String @unique
73 userId String
74 expires DateTime
75 user User @relation(fields: [userId], references: [id], onDelete: Cascade)
76}
77
78model VerificationToken {
79 identifier String
80 token String @unique
81 expires DateTime
82
83 @@unique([identifier, token])
84}

Never use Float for money. Floating-point arithmetic will silently corrupt monetary values. Use Decimal in Prisma (NUMERIC in Postgres) and convert to strings when sending to the frontend.

Run the migration:

bash
1npx prisma migrate dev --name init
2npx prisma generate

Phase 2 (Days 11–20): Authentication

NextAuth v5 with Google OAuth is the fastest path to working auth. Create auth.ts at the project root:

typescript
1// auth.ts
2import NextAuth from 'next-auth'
3import Google from 'next-auth/providers/google'
4import { PrismaAdapter } from '@auth/prisma-adapter'
5import { prisma } from '@/lib/prisma'
6
7export const { handlers, auth, signIn, signOut } = NextAuth({
8 adapter: PrismaAdapter(prisma),
9 providers: [
10 Google({
11 clientId: process.env.GOOGLE_CLIENT_ID!,
12 clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
13 }),
14 ],
15 callbacks: {
16 session({ session, user }) {
17 // Attach the database user ID to the session object
18 // so server components can use it without extra DB round-trips
19 session.user.id = user.id
20 return session
21 },
22 },
23 pages: {
24 signIn: '/login',
25 },
26})

Wire up the route handler:

typescript
1// app/api/auth/[...nextauth]/route.ts
2export { GET, POST } from '@/auth'

Protect routes with middleware:

typescript
1// middleware.ts
2import { auth } from './auth'
3
4export default auth((req) => {
5 const isLoggedIn = !!req.auth
6 const isAuthPage = req.nextUrl.pathname.startsWith('/login')
7
8 if (!isLoggedIn && !isAuthPage) {
9 return Response.redirect(new URL('/login', req.nextUrl))
10 }
11})
12
13export const config = {
14 matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
15}

With this middleware in place, every page except /login requires an authenticated session. No per-page checks needed.


Phase 3 (Days 21–40): The Core API with Server Actions

This is where Next.js Server Actions shine. Instead of building a REST API and then calling it from the client, you write server-side functions and call them directly from form actions.

typescript
1// app/actions/expenses.ts
2'use server'
3
4import { revalidatePath } from 'next/cache'
5import { auth } from '@/auth'
6import { prisma } from '@/lib/prisma'
7import { z } from 'zod'
8
9const CreateExpenseSchema = z.object({
10 amount: z.coerce.number().positive('Amount must be positive').max(999999.99),
11 description: z.string().min(1).max(200),
12 date: z.string().regex(/^d{4}-d{2}-d{2}$/, 'Invalid date format'),
13 categoryId: z.string().cuid('Invalid category'),
14})
15
16export async function createExpense(formData: FormData) {
17 const session = await auth()
18 if (!session?.user?.id) throw new Error('Unauthorized')
19
20 const parsed = CreateExpenseSchema.safeParse({
21 amount: formData.get('amount'),
22 description: formData.get('description'),
23 date: formData.get('date'),
24 categoryId: formData.get('categoryId'),
25 })
26
27 if (!parsed.success) {
28 return { error: parsed.error.flatten().fieldErrors }
29 }
30
31 const { amount, description, date, categoryId } = parsed.data
32
33 // Verify the category belongs to this user before using it
34 const category = await prisma.category.findFirst({
35 where: { id: categoryId, userId: session.user.id },
36 })
37
38 if (!category) return { error: { categoryId: ['Category not found'] } }
39
40 await prisma.expense.create({
41 data: {
42 amount,
43 description,
44 date: new Date(date),
45 categoryId,
46 userId: session.user.id,
47 },
48 })
49
50 revalidatePath('/dashboard')
51 return { success: true }
52}
53
54export async function getMonthlyBreakdown(year: number, month: number) {
55 const session = await auth()
56 if (!session?.user?.id) throw new Error('Unauthorized')
57
58 const start = new Date(year, month - 1, 1)
59 const end = new Date(year, month, 0, 23, 59, 59)
60
61 const expenses = await prisma.expense.findMany({
62 where: {
63 userId: session.user.id,
64 date: { gte: start, lte: end },
65 },
66 include: { category: true },
67 orderBy: { date: 'desc' },
68 })
69
70 // Group by category for the summary chart
71 const byCategory = expenses.reduce<Record<string, { name: string; color: string; total: number }>>(
72 (acc, expense) => {
73 const key = expense.categoryId
74 if (!acc[key]) {
75 acc[key] = {
76 name: expense.category.name,
77 color: expense.category.color,
78 total: 0,
79 }
80 }
81 acc[key].total += Number(expense.amount)
82 return acc
83 },
84 {}
85 )
86
87 return {
88 expenses,
89 summary: Object.values(byCategory).sort((a, b) => b.total - a.total),
90 total: expenses.reduce((sum, e) => sum + Number(e.amount), 0),
91 }
92}

Phase 4 (Days 41–65): The Dashboard UI

A Server Component fetches the data; a Client Component owns the interactive state.

tsx
1// app/dashboard/page.tsx
2import { auth } from '@/auth'
3import { redirect } from 'next/navigation'
4import { getMonthlyBreakdown } from '@/app/actions/expenses'
5import { DashboardClient } from './DashboardClient'
6
7export default async function DashboardPage({
8 searchParams,
9}: {
10 searchParams: { month?: string; year?: string }
11}) {
12 const session = await auth()
13 if (!session) redirect('/login')
14
15 const now = new Date()
16 const year = Number(searchParams.year ?? now.getFullYear())
17 const month = Number(searchParams.month ?? now.getMonth() + 1)
18
19 const data = await getMonthlyBreakdown(year, month)
20
21 return (
22 <div className="max-w-5xl mx-auto px-4 py-8">
23 <div className="flex items-center justify-between mb-8">
24 <h1 className="text-2xl font-bold">
25 {new Date(year, month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}
26 </h1>
27 <p className="text-3xl font-mono font-bold text-indigo-400">
28 ${data.total.toFixed(2)}
29 </p>
30 </div>
31 <DashboardClient initialData={data} year={year} month={month} />
32 </div>
33 )
34}
tsx
1// app/dashboard/DashboardClient.tsx
2'use client'
3
4import { useTransition } from 'react'
5import { createExpense } from '@/app/actions/expenses'
6
7type Props = {
8 initialData: Awaited<ReturnType<typeof import('@/app/actions/expenses').getMonthlyBreakdown>>
9 year: number
10 month: number
11}
12
13export function DashboardClient({ initialData }: Props) {
14 const [isPending, startTransition] = useTransition()
15
16 function handleAddExpense(formData: FormData) {
17 startTransition(async () => {
18 const result = await createExpense(formData)
19 if (result?.error) {
20 console.error(result.error)
21 }
22 // revalidatePath in the server action re-fetches this page automatically
23 })
24 }
25
26 return (
27 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
28 <div className="lg:col-span-1 space-y-3">
29 <h2 className="font-semibold text-gray-400 text-sm uppercase tracking-wide">By Category</h2>
30 {initialData.summary.map(cat => (
31 <div key={cat.name} className="flex items-center gap-3">
32 <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: cat.color }} />
33 <span className="flex-1 text-sm">{cat.name}</span>
34 <span className="font-mono text-sm text-gray-300">${cat.total.toFixed(2)}</span>
35 </div>
36 ))}
37 </div>
38
39 <div className="lg:col-span-2 space-y-2">
40 <h2 className="font-semibold text-gray-400 text-sm uppercase tracking-wide">Transactions</h2>
41 {initialData.expenses.map(expense => (
42 <div key={expense.id} className="flex items-center gap-4 p-3 rounded-lg bg-gray-800">
43 <div className="flex-1">
44 <p className="font-medium">{expense.description}</p>
45 <p className="text-xs text-gray-400">
46 {expense.category.name} · {new Date(expense.date).toLocaleDateString()}
47 </p>
48 </div>
49 <span className="font-mono font-semibold">${Number(expense.amount).toFixed(2)}</span>
50 </div>
51 ))}
52 </div>
53 </div>
54 )
55}

Phase 5 (Days 66–90): Production Hardening

Before sharing a link with anyone:

Rate limiting — install @upstash/ratelimit and protect your server actions:

typescript
1import { Ratelimit } from '@upstash/ratelimit'
2import { Redis } from '@upstash/redis'
3
4const ratelimit = new Ratelimit({
5 redis: Redis.fromEnv(),
6 limiter: Ratelimit.slidingWindow(20, '1 m'), // 20 requests per minute per user
7})
8
9// In your server action:
10const { success } = await ratelimit.limit(session.user.id)
11if (!success) throw new Error('Too many requests')

Database connection pooling — add ?pgbouncer=true&connection_limit=1 to your Supabase connection string for serverless environments.

Error monitoring — add Sentry with npx @sentry/wizard@latest -i nextjs. It takes five minutes and will save hours of debugging.

CSV export — users always ask for this:

typescript
1// app/api/export/route.ts
2export async function GET(req: Request) {
3 const session = await auth()
4 if (!session?.user?.id) return new Response('Unauthorized', { status: 401 })
5
6 const expenses = await prisma.expense.findMany({
7 where: { userId: session.user.id },
8 include: { category: true },
9 orderBy: { date: 'desc' },
10 })
11
12 const csv = [
13 'Date,Description,Category,Amount',
14 ...expenses.map(e =>
15 [
16 new Date(e.date).toISOString().slice(0, 10),
17 `"${e.description.replace(/"/g, '""')}"`,
18 e.category.name,
19 Number(e.amount).toFixed(2),
20 ].join(',')
21 ),
22 ].join('\n')
23
24 return new Response(csv, {
25 headers: {
26 'Content-Type': 'text/csv',
27 'Content-Disposition': 'attachment; filename="expenses.csv"',
28 },
29 })
30}

What This Project Teaches You

After 30–90 days of focused work you'll have hands-on experience with:

  • Auth flows — OAuth, session management, route protection at the middleware layer
  • Relational schema design — foreign keys, cascade deletes, composite unique constraints, and indexing for real query patterns
  • Type-safe database access — Prisma migrations, generated types, and safe money handling
  • Server Actions — the modern alternative to REST for Next.js applications
  • Validation at the boundary — Zod schemas on every server-side entry point
  • Production operations — rate limiting, connection pooling, error monitoring, data export

Every one of these concepts appears in every production codebase. This project is the reps.

Tags
Next.jsPrismaPostgresNextAuthTypeScriptServer ActionsFull-Stack
Kevin Mbugua Kiarie

Kevin Mbugua Kiarie

Senior Fullstack Developer

Five years building full-stack systems in TypeScript, Elixir, and Python. Writing about engineering, architecture, and the craft of shipping software that lasts.

30 to 90 Days: Build a Full-Stack Expense Tracker with Auth · Kevin Kiarie