Stop 10 · Full-Stack FrameworksProjects · 1/1

Full-Stack Next.js App

Part of the Full Stack Developer Roadmap

In short

Time to build one app where the UI, the backend and the database live in one Next.js project. An expense tracker is perfect: small enough to finish, real enough to show in interviews.

A complete application built entirely within Next.js — Server Components for data fetching, Server Actions or API routes for mutations, and a real database — your first taste of a truly unified full-stack codebase.

The blob character cooks inside a small food truck and hands a plate out of the window.

Think of it like this

Your own food truck

A food truck is a whole restaurant on wheels. The stove, the counter and the cash box sit in one vehicle, and you run all of it. Your Next.js app is the same: pages, server code and database access in one project you can drive anywhere.

Watch it happen

Build plan, one layer at a time

Schema
Read
Write
Auth
Deploy

1/5Define an Expense model in Prisma: id, amountPaise, category, spentOn, userId. Run the first migration.

The big ideas

What you need to know

Add and delete expenses

A form with amount, category and date, saved through a server action, plus a delete button on each row.

Monthly view

A server component that totals the month and groups by category. A query param like ?month=2026-10 makes it shareable.

Your data only

Every read and write filters by the session user. Test it by signing in as a second user.

Live on the internet

Deploy with a real database and environment variables. A live link beats a screenshot on your resume.

See it in code

Walk through the code

tsx
1"use server";2import { z } from "zod";3import { revalidatePath } from "next/cache";4import { auth } from "@/auth";5const Expense = z.object({6  rupees: z.coerce.number().positive().max(1_000_000),7  category: z.enum(["food", "travel", "rent", "other"]),8  spentOn: z.coerce.date(),9});10export async function createExpense(formData: FormData) {11  const session = await auth();12  if (!session?.user?.id) throw new Error("Please sign in");13  const { rupees, category, spentOn } = Expense.parse(Object.fromEntries(formData));14  await db.expense.create({ data: { amountPaise: Math.round(rupees * 100), category, spentOn, userId: session.user.id } });15  revalidatePath("/expenses");16}

1/4Form values arrive as strings. z.coerce turns them into a number and a date, and rejects junk.

app/expenses/actions.ts. auth comes from your Auth.js setup and db is your Prisma client. Use it as <form action={createExpense}>.

Save yourself the pain

Common mistakes

Oops

Storing money as a float like 199.99.

Do this instead

Store whole paise as an integer. Floats cause rounding errors when you add up a month of expenses.

Oops

Forgetting the userId filter in one query, so users see each other's expenses.

Do this instead

Write one helper like getUserExpenses(userId) and use it everywhere, so the filter can't be skipped.

Oops

Building every feature before deploying even once.

Do this instead

Deploy on day one with just the list page. Fixing deploy problems early is far easier.

Your mission

First milestone: list and add

~45 min

Quick check

After createExpense saves a row, why call revalidatePath('/expenses')?

Go deeper

  1. 1Fetch real data from your database directly in a Server Component.
  2. 2Handle a form submission with a Server Action that writes to the database.
  3. 3Deploy it to Vercel and confirm the whole thing works end-to-end in production.

Stuck on this topic? Ask an Insider

Get 1:1 guidance from people who've walked this exact path — on the InsideEdge app.

Download
InsideEdge