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:
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 14 (App Router) | Server Components + Server Actions eliminate most API boilerplate |
| Database | Postgres | ACID transactions, date functions, JSON columns — you'll need all of these |
| ORM | Prisma | Type-safe queries, automatic migrations, excellent DX |
| Auth | NextAuth.js v5 | Handles OAuth, session management, and CSRF without you writing JWT logic |
| Styling | Tailwind CSS | Fast iteration, consistent design |
| Hosting | Vercel + Supabase | Generous 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:
bash1npm install prisma @prisma/client2npx prisma init --datasource-provider postgresql
Define the schema:
prisma1// prisma/schema.prisma2generator client {3 provider = "prisma-client-js"4}56datasource db {7 provider = "postgresql"8 url = env("DATABASE_URL")9}1011model User {12 id String @id @default(cuid())13 email String @unique14 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}2324model Category {25 id String @id @default(cuid())26 name String27 color String @default("#6366f1")28 icon String @default("tag")29 userId String30 user User @relation(fields: [userId], references: [id], onDelete: Cascade)31 expenses Expense[]3233 @@unique([name, userId]) // Same user can't have duplicate category names34}3536model Expense {37 id String @id @default(cuid())38 amount Decimal @db.Decimal(10, 2) // Never use Float for money39 description String40 date DateTime @db.Date41 categoryId String42 category Category @relation(fields: [categoryId], references: [id])43 userId String44 user User @relation(fields: [userId], references: [id], onDelete: Cascade)45 createdAt DateTime @default(now())46 updatedAt DateTime @updatedAt4748 @@index([userId, date]) // Queries are almost always scoped to a user + date range49}5051// NextAuth required models52model Account {53 id String @id @default(cuid())54 userId String55 type String56 provider String57 providerAccountId String58 refresh_token String? @db.Text59 access_token String? @db.Text60 expires_at Int?61 token_type String?62 scope String?63 id_token String? @db.Text64 session_state String?65 user User @relation(fields: [userId], references: [id], onDelete: Cascade)6667 @@unique([provider, providerAccountId])68}6970model Session {71 id String @id @default(cuid())72 sessionToken String @unique73 userId String74 expires DateTime75 user User @relation(fields: [userId], references: [id], onDelete: Cascade)76}7778model VerificationToken {79 identifier String80 token String @unique81 expires DateTime8283 @@unique([identifier, token])84}
Never use Float for money. Floating-point arithmetic will silently corrupt monetary values. Use
Decimalin Prisma (NUMERICin Postgres) and convert to strings when sending to the frontend.
Run the migration:
bash1npx prisma migrate dev --name init2npx 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:
typescript1// auth.ts2import NextAuth from 'next-auth'3import Google from 'next-auth/providers/google'4import { PrismaAdapter } from '@auth/prisma-adapter'5import { prisma } from '@/lib/prisma'67export 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 object18 // so server components can use it without extra DB round-trips19 session.user.id = user.id20 return session21 },22 },23 pages: {24 signIn: '/login',25 },26})
Wire up the route handler:
typescript1// app/api/auth/[...nextauth]/route.ts2export { GET, POST } from '@/auth'
Protect routes with middleware:
typescript1// middleware.ts2import { auth } from './auth'34export default auth((req) => {5 const isLoggedIn = !!req.auth6 const isAuthPage = req.nextUrl.pathname.startsWith('/login')78 if (!isLoggedIn && !isAuthPage) {9 return Response.redirect(new URL('/login', req.nextUrl))10 }11})1213export 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.
typescript1// app/actions/expenses.ts2'use server'34import { revalidatePath } from 'next/cache'5import { auth } from '@/auth'6import { prisma } from '@/lib/prisma'7import { z } from 'zod'89const 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})1516export async function createExpense(formData: FormData) {17 const session = await auth()18 if (!session?.user?.id) throw new Error('Unauthorized')1920 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 })2627 if (!parsed.success) {28 return { error: parsed.error.flatten().fieldErrors }29 }3031 const { amount, description, date, categoryId } = parsed.data3233 // Verify the category belongs to this user before using it34 const category = await prisma.category.findFirst({35 where: { id: categoryId, userId: session.user.id },36 })3738 if (!category) return { error: { categoryId: ['Category not found'] } }3940 await prisma.expense.create({41 data: {42 amount,43 description,44 date: new Date(date),45 categoryId,46 userId: session.user.id,47 },48 })4950 revalidatePath('/dashboard')51 return { success: true }52}5354export async function getMonthlyBreakdown(year: number, month: number) {55 const session = await auth()56 if (!session?.user?.id) throw new Error('Unauthorized')5758 const start = new Date(year, month - 1, 1)59 const end = new Date(year, month, 0, 23, 59, 59)6061 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 })6970 // Group by category for the summary chart71 const byCategory = expenses.reduce<Record<string, { name: string; color: string; total: number }>>(72 (acc, expense) => {73 const key = expense.categoryId74 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 acc83 },84 {}85 )8687 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.
tsx1// app/dashboard/page.tsx2import { auth } from '@/auth'3import { redirect } from 'next/navigation'4import { getMonthlyBreakdown } from '@/app/actions/expenses'5import { DashboardClient } from './DashboardClient'67export default async function DashboardPage({8 searchParams,9}: {10 searchParams: { month?: string; year?: string }11}) {12 const session = await auth()13 if (!session) redirect('/login')1415 const now = new Date()16 const year = Number(searchParams.year ?? now.getFullYear())17 const month = Number(searchParams.month ?? now.getMonth() + 1)1819 const data = await getMonthlyBreakdown(year, month)2021 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}
tsx1// app/dashboard/DashboardClient.tsx2'use client'34import { useTransition } from 'react'5import { createExpense } from '@/app/actions/expenses'67type Props = {8 initialData: Awaited<ReturnType<typeof import('@/app/actions/expenses').getMonthlyBreakdown>>9 year: number10 month: number11}1213export function DashboardClient({ initialData }: Props) {14 const [isPending, startTransition] = useTransition()1516 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 automatically23 })24 }2526 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>3839 <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:
typescript1import { Ratelimit } from '@upstash/ratelimit'2import { Redis } from '@upstash/redis'34const ratelimit = new Ratelimit({5 redis: Redis.fromEnv(),6 limiter: Ratelimit.slidingWindow(20, '1 m'), // 20 requests per minute per user7})89// 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:
typescript1// app/api/export/route.ts2export async function GET(req: Request) {3 const session = await auth()4 if (!session?.user?.id) return new Response('Unauthorized', { status: 401 })56 const expenses = await prisma.expense.findMany({7 where: { userId: session.user.id },8 include: { category: true },9 orderBy: { date: 'desc' },10 })1112 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')2324 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.

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.