EDUCATIONOverviewStudentsTeachersSchoolsCurriculumProject Library
GUIDED PROJECT·Intermediate·~2 weeks·11 min read

Build an Expense Tracker

Build a complete full-stack product: a personal expense tracker with a real database, typed CRUD, authentication so each user sees only their own data, and charts that turn rows into insight. This is the project where you stop making toys and start making software.

WHAT YOU'LL WALK AWAY WITH
  • A deployed full-stack app where users sign in and track real expenses against a real database
  • A data model you designed yourself — tables, relationships, and the reasoning behind them
  • Type-safe CRUD from the database up through the API to the UI, with validation at the boundary
  • Authentication and per-user data isolation done right — the security concept every real product depends on
  • Charts that answer a question (where did the money go?), not just decorate the page
TIER
Intermediate
TIME
~2 weeks
SKILLS
Databases, CRUD, charts, auth
STACK
React, SQL, authentication
PORTFOLIO
A complete product with real data modeling
REQUIRES
One beginner project

Beginner projects live in the browser. This one has a back end, a database, and real users — which is exactly why it's the project that makes your portfolio serious. An expense tracker is the perfect vehicle for it: the domain is obvious (money in, money out, by category), so all your attention goes to the engineering that every SaaS product shares — modeling data, moving it safely between layers, and making sure one user can never see another's.

This assumes you've built at least one smaller project and are comfortable with React and JavaScript. You do not need prior database or backend experience — that's what you're here to learn.

Info

Estimated time: ~2 weeks, part-time. Not because it's huge, but because it introduces four genuinely new things — a database, a typed API, authentication, and data visualization — and each deserves real attention. Build it in the milestone order below; skipping ahead to the UI before the data model is set is the most common way this project goes sideways.

The stack (and why)

  • Next.js (App Router) — one project holds both your React UI and your server-side API routes, so you're not juggling two codebases.
  • Postgres via Supabase — a real relational database with a generous free tier, plus built-in authentication you'll use in Milestone 4.
  • Drizzle ORM — lets you define your database schema in TypeScript and get fully typed queries back. It's how "a column exists in the database" becomes "the type system knows about it."
  • Recharts — for the spending charts at the end.
Tip

Prefer a different stack? The concepts are portable. Any SQL database, any typed query layer, and any auth provider give you the same four lessons. The specific tools matter far less than understanding what each layer is responsible for — which is what the milestones focus on.

Milestone 0 — Design the data model first

Before installing anything, answer one question on paper: what is an expense? For this app, an expense has an amount, a category, a description, a date, and — crucially — an owner (the user it belongs to). Write that out as a table:

expenses
  id           uuid, primary key
  user_id      uuid   → references the user who owns it
  amount_cents integer   (store money as integer cents, never floats)
  category     text
  description  text
  spent_on     date
  created_at   timestamp

Two decisions here are worth understanding, because they're the kind a senior engineer makes automatically:

  • Money is stored as integer cents, not 4.99 as a decimal. Floating-point math can't represent 0.10 exactly, so summing dollars-as-floats drifts by a cent over time — unacceptable for money. Store 499, divide by 100 only when you display it.
  • Every expense carries a user_id. This single column is the foundation of the entire security model in Milestone 4. Designing it in from the start — rather than bolting it on later — is why we model before we build.
Heads up

This is the highest-leverage milestone in the project and it involves zero code. A good data model makes every feature downstream easy; a bad one makes all of them hard. If you can explain why each column exists, you understand the app better than most people who've "finished" a tutorial version of it.

Milestone 1 — The database and a typed schema

Create a Supabase project (it gives you a Postgres database and a connection string). Then scaffold the app and define your schema in code with Drizzle:

npx create-next-app@latest expense-tracker --typescript --app
cd expense-tracker
npm install drizzle-orm postgres
npm install -D drizzle-kit
// db/schema.ts
import { pgTable, uuid, integer, text, date, timestamp } from "drizzle-orm/pg-core";

export const expenses = pgTable("expenses", {
  id: uuid("id").defaultRandom().primaryKey(),
  userId: uuid("user_id").notNull(),
  amountCents: integer("amount_cents").notNull(),
  category: text("category").notNull(),
  description: text("description"),
  spentOn: date("spent_on").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

Run a migration to create the table in Postgres (drizzle-kit generate then drizzle-kit migrate — read the Drizzle migration docs).

What just happened, and why it matters: that TypeScript object is now the single source of truth for your data. Drizzle uses it to create the real table and to type every query you write. Ask for expenses and TypeScript knows the result has an amountCents number and a category string. Misspell a column and it won't compile. This is the payoff of a typed database layer: whole categories of bugs become impossible before you ever run the code.

Milestone 2 — CRUD: the four operations every app is made of

CRUD — Create, Read, Update, Delete — is the backbone of almost every product. Build it as server-side API routes so the database credentials stay on the server, never in the browser. Start with create and read:

// app/api/expenses/route.ts
import { db } from "@/db/client";
import { expenses } from "@/db/schema";
import { z } from "zod";

const NewExpense = z.object({
  amountCents: z.number().int().positive(),
  category: z.string().min(1),
  description: z.string().optional(),
  spentOn: z.string(), // ISO date
});

export async function GET() {
  const rows = await db.select().from(expenses);
  return Response.json({ expenses: rows });
}

export async function POST(req: Request) {
  const parsed = NewExpense.safeParse(await req.json());
  if (!parsed.success) {
    return Response.json({ error: "Invalid expense" }, { status: 400 });
  }
  // userId is hardcoded for now — Milestone 4 replaces it with the real user.
  const [row] = await db
    .insert(expenses)
    .values({ ...parsed.data, userId: "00000000-0000-0000-0000-000000000000" })
    .returning();
  return Response.json({ expense: row }, { status: 201 });
}

Two things to internalize:

  • Never trust the request body. Zod validates the incoming JSON at the boundary before it touches the database. A positive-integer check on amountCents isn't paranoia — it's the wall between your data and a malformed or malicious request.
  • The API is a contract. The browser sends JSON and gets JSON back; it never sees SQL or the database. That separation is what lets you change the database later without touching the front end — and what keeps your credentials off the client.

Add PUT/DELETE (update and delete a single expense by id) the same way. You now have a complete data layer you can test with curl before any UI exists — do that, it's a good habit.

Milestone 3 — The UI: list and add

Now React. Fetch the expenses and render them, with a form to add one:

"use client";
import { useEffect, useState } from "react";

interface Expense {
  id: string;
  amountCents: number;
  category: string;
  spentOn: string;
}

export default function Expenses() {
  const [expenses, setExpenses] = useState<Expense[]>([]);

  async function load() {
    const res = await fetch("/api/expenses");
    const data = await res.json();
    setExpenses(data.expenses);
  }

  useEffect(() => { load(); }, []);

  async function add(form: FormData) {
    await fetch("/api/expenses", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        amountCents: Math.round(Number(form.get("amount")) * 100),
        category: String(form.get("category")),
        spentOn: String(form.get("spentOn")),
      }),
    });
    load(); // re-fetch so the list reflects the new row
  }

  return (
    <main>
      <ul>
        {expenses.map((e) => (
          <li key={e.id}>
            {e.category}: ${(e.amountCents / 100).toFixed(2)} on {e.spentOn}
          </li>
        ))}
      </ul>
      {/* a <form action={add}> with amount, category, and date inputs */}
    </main>
  );
}

Notice the Math.round(dollars * 100) on the way in and the / 100 on the way out — the cents decision from Milestone 0 showing up exactly where you predicted. After adding an expense, the code re-fetches rather than guessing the new state locally; simple and always correct while you're learning. (Optimistic updates are a worthwhile refinement later, once you understand why the naive version is safe.)

Commit here — you have a working, if single-user, product. This is a natural point to run Cartara against your repo (see the last section).

Milestone 4 — Auth, and the security concept that underpins everything

Right now every expense uses a fake user_id, so there's one shared pile of data. Real apps have users, and — this is the part that matters — one user must never see another's data. Add Supabase Auth (email/password or a magic link; follow the Supabase Auth docs). Once a user can sign in, two things change:

  1. On create, stamp each expense with the signed-in user's id, from the server session — never from the request body (the client could lie).
  2. On read, filter to the current user:
const rows = await db
  .select()
  .from(expenses)
  .where(eq(expenses.userId, session.user.id));
Heads up

The cardinal rule of multi-user apps: authorization happens on the server, from the session — never from data the client sent. If the browser tells you "I'm user 42," you don't believe it; you read who they are from their authenticated session. Getting this wrong is how real apps leak one customer's data to another. Understanding why the user_id must come from the session and not the request body is one of the most important ideas in web security — and you designed the schema in Milestone 0 to make it possible.

For extra rigor, Supabase supports row-level security — rules enforced by the database itself, so even a buggy query can't return the wrong user's rows. Reading how it works is worth an hour even if you enforce ownership in your queries for now.

Milestone 5 — Charts that answer a question

Data becomes insight when you can see it. Add a chart of spending by category. On the server, aggregate:

const byCategory = await db
  .select({
    category: expenses.category,
    total: sql<number>`sum(${expenses.amountCents})`,
  })
  .from(expenses)
  .where(eq(expenses.userId, session.user.id))
  .groupBy(expenses.category);

Then feed it to a Recharts pie or bar chart on the client. The engineering lesson hiding in this milestone: aggregate in the database, not in JavaScript. Postgres can sum and group by across thousands of rows far faster than shipping them all to the browser and reducing there. Knowing where computation should happen — database vs. server vs. client — is a distinction that separates intermediate developers from beginners.

Tip

Before you style the chart, make sure it answers a real question: "where did my money go this month?" A chart that doesn't change a decision is decoration. This is also great portfolio material — a screenshot of a clean spending breakdown communicates "I built a real product" instantly.

Milestone 6 — Ship it

Deploy to Vercel (zero-config for Next.js):

  1. Push the repo to GitHub.
  2. Import it into Vercel and set your database URL and Supabase keys as environment variables in the dashboard — never committed to the repo.
  3. Run your migrations against the production database, then deploy.

Sign in on the live URL, add a few expenses, and watch the chart update. You now have a deployed, multi-user, full-stack product — the real thing.

Stuck? Study a production build

Read these after you've built your own version, so you can see what they added and why — not copy their answers:

  • maybe-finance/maybe — a full open-source personal-finance app. Study how it models financial data and isolates each user.
  • drizzle-team/drizzle-orm — when a query won't type-check, the docs and examples here are the fastest way to understand why.
  • supabase/supabase — its auth and row-level-security guides are the reference for Milestones 4.

Build it with Cartara

A project this size is where the "it works but I couldn't rebuild it" problem gets real. There are a lot of moving parts — a schema, an API, auth, aggregation — and it's genuinely easy to get the whole thing running while only half-understanding how the layers connect. Add an AI assistant writing some of those layers for you, and the gap between what shipped and what you understand can open up without you noticing.

Closing that gap is exactly what Cartara is for. As you build:

  • Point Cartara at your repo. It watches the code that lands — including anything an AI assistant generated — and turns each meaningful change into a checkpoint for understanding, not just a commit.
  • Explain your changes back. When you add per-user filtering or database-side aggregation, Cartara asks you the why and the trade-offs — the questions a code reviewer or interviewer would.
  • See your comprehension, not just your output. You get an honest read on which layers of your own app you actually understand — the schema, the auth boundary, the query — so you can shore up the weak spots before they surface in a review.

That's the loop this whole library is built around: build something real, then prove to yourself you understand it. The expense tracker is the project. The understanding is the point.

Turn a full-stack build into understanding you can prove

Point Cartara at your expense-tracker repo and it captures what you genuinely learned across every layer — the honest way to make an ambitious, AI-assisted project truly yours.

Join the waitlist

Reference implementations

Production code worth studying — afteryou've built your own version.

maybe-finance/maybe
A production, open-source personal-finance app. Study its data model and how it isolates each user's data — after you've designed your own.
drizzle-team/drizzle-orm
The type-safe SQL toolkit this build uses; its docs show how a schema in TypeScript becomes typed queries.
supabase/supabase
Postgres, auth, and row-level security in one platform — the auth patterns in Milestone 3 map onto its docs.
← All projects

Build it with Cartara

Point Cartara at your repo as you build this project. It captures what you actually learned from every change — including the code an AI assistant wrote — so an AI-assisted build becomes understanding you can prove.

Join the waitlist