Full-Stack Expense Tracker with Prisma
Build a full-stack expense tracker with Next.js, Prisma ORM, and PostgreSQL.
Introduction
30 Second Summary
Tracking where your money goes is one of those things everyone knows they should do. Most people give up because their tools are either too simple (a spreadsheet) or too complex (a subscription app they never open).
In this project, you will build a full-stack expense tracker that stores data in a real cloud database. You will design a database schema, fetch data with Server Components, and handle form submissions with Server Actions.
What You'll Build
A full-stack expense tracker app powered by Next.js, Prisma ORM, and a cloud PostgreSQL database on Neon.
By the end of this project, you'll have:
- A Next.js 16 project with TypeScript and Tailwind CSS connected to a cloud PostgreSQL database.
- A Prisma schema with a one-to-many relationship between Categories and Expenses, plus type-safe database queries.
- A working app using Server Components to read data and Server Actions to create and delete expenses.
- Secret Mission: Add category filtering with URL search params and a monthly expense summary.
Are there any prerequisites?
You should be comfortable with React fundamentals (components, props, state, and hooks) and have basic SQL knowledge. You will also need Node.js installed on your machine.
Set Up Your Project and Database
Before you can track expenses, you need the right foundation in place. This step scaffolds a Next.js project, installs Prisma for database access, and connects everything to a cloud PostgreSQL database.
By the end of this step, you'll have a running app connected to a real database. Everything else in the project builds on this foundation.
In this step, get ready to:
- Scaffold a Next.js project with TypeScript and Tailwind CSS.
- Install Prisma and connect it to a cloud PostgreSQL database on Neon.
- Verify the database connection and confirm the app runs locally.
Scaffold a Next.js project and install Prisma
You need a Next.js project as the base for your app and Prisma as the bridge between your code and the database. Let's create both.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your expense tracker project by running this command:
npx create-next-app@latest expense-tracker --yes --use-npm
What does this command do?
This scaffolds a full Next.js project in a folder called expense-tracker.
- The --yes flag accepts all defaults: TypeScript, ESLint, Tailwind CSS, App Router, and Turbopack.
- The --use-npm flag ensures the project uses npm as its package manager.
- Move into your new project folder by running:
cd expense-tracker
- Install Prisma and the database adapter packages by running these commands:
npm install -D prisma@latest
npm install @prisma/client@latest @prisma/adapter-pg@latest pg
npm install -D @types/pg
What are these packages?
- prisma is the CLI tool that manages your schema and migrations.
- @prisma/client is the generated database client you'll use in your app code.
- @prisma/adapter-pg and pg connect Prisma to PostgreSQL using a driver adapter.
- @types/pg provides TypeScript type definitions for the pg package.
- Confirm the packages installed successfully by checking the terminal output ends with a line showing packages added.
Seeing permission errors or EACCES?
- On macOS, try prefixing the command with sudo.
- On Windows, open your terminal as Administrator.
- Make sure you are inside the expense-tracker folder before running the install commands.
Fix my npm permission errors
- Initialize Prisma in your project by running:
npx prisma init --datasource-provider postgresql
What did this create?
This command creates three things:
- A prisma/ folder with a schema.prisma file where you define your data models.
- A .env file at the project root for environment variables like your database URL.
- A prisma.config.ts file that tells Prisma where to find your schema and database connection.
The generated schema needs one small adjustment. The default output path puts generated code at ../generated/prisma, but this project keeps it inside src/ for cleaner imports.
- Open prisma/schema.prisma in your code editor.
- Find this line in the generator block:
output = "../generated/prisma"
- Replace it with this path so the generated client lives inside your src/ directory:
output = "../src/generated/prisma"
Next, add the database URL directly to the datasource block. This ensures Prisma can find the connection string during migrations.
- Find the datasource db block. It currently looks like this:
datasource db {
provider = "postgresql"
}
- Add the url line so the block looks like this:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
- Save prisma/schema.prisma.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Your prisma/schema.prisma should look exactly like this:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
Create a Neon database and connect it
Your app needs a real database to store expenses. Neon provides a free cloud PostgreSQL database that requires no credit card and no local installation.
- Open your browser and go to neon.tech.
- Click Sign Up and create an account using GitHub or email.
- Once signed in, click New Project.
- Enter expense-tracker as the project name.
- Pick a region close to you from the dropdown.
- Click Create Project.
After creation, Neon shows you a connection string. This string is how your app talks to the database.
- On the Neon dashboard, find the Connection string section.
- Click the copy icon to copy the full connection string.
- In your code editor, open the .env file at the root of your expense-tracker folder.
- Replace the placeholder value so the file looks like this:
DATABASE_URL="your-neon-connection-string-here"
- Paste your actual Neon connection string between the quotes, replacing your-neon-connection-string-here.
- Save the .env file.
What does the connection string look like?
It starts with postgresql:// followed by your username, password, host, and database name. Neon generates this for you automatically.
Can't find the connection string?
- In the Neon console, click your project name in the sidebar, then look for the Connection Details panel.
- Make sure the Role dropdown shows your default role and Database shows neondb.
Help me find my Neon connection string
Verify the connection
With the connection string in place, let's confirm Prisma can reach your database and the app runs.
- Test the database connection by running this command in your terminal:
npx prisma db pull
What should I see?
Since the database is empty (no tables yet), Prisma reports that the introspected database was empty. This is expected. The important thing is that the command completed without a connection error.
Seeing a connection error?
- Double-check your .env file has the full connection string starting with postgresql://.
- Make sure you saved the .env file after pasting the connection string.
- Confirm your Neon project is active (not suspended) by checking the Neon dashboard.
Help me debug my Prisma connection error
- Start the Next.js development server by running:
npm run dev
- Open your browser and go to http://localhost:3000.
- You should see the default Next.js welcome page with the Vercel logo and documentation links.
Page not loading?
- Check your terminal for errors. If port 3000 is in use, Next.js may start on a different port (it will print the URL).
- Make sure you ran npm run dev from inside the expense-tracker folder.
Help me get my Next.js dev server running
Your project is scaffolded, Prisma is installed, and your database connection is confirmed. Next up, you'll design your database schema and create the tables that store expenses.
Design Your Database Schema and Run Migrations
In the previous step, you scaffolded a Next.js project and connected it to a cloud PostgreSQL database on Neon. The app runs, but the database is empty.
Before you can store expenses, you need to define what an expense looks like in the database. In this step, you will design a schema with two related tables, push that schema to your cloud database, and fill it with starter data.
In this step, get ready to:
- Define Category and Expense models in your Prisma schema.
- Run a migration to create the tables in your Neon database.
- Seed the database with default categories.
Define the Prisma schema with Category and Expense models
A Prisma schema is where you declare your database structure using models. Each model maps to a table. You will create two models with a one-to-many relationship: one Category can have many Expenses.
- Open prisma/schema.prisma in your editor.
- Below the existing datasource db block, add the Category and Expense models by pasting this code:
model Category {
id Int @id @default(autoincrement())
name String @unique
expenses Expense[]
}
model Expense {
id Int @id @default(autoincrement())
description String
amount Decimal @db.Decimal(10, 2)
createdAt DateTime @default(now())
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
}
What does this schema do?
The Category model stores expense categories like Food or Transport. The @unique constraint on name prevents duplicate category names.
The Expense model stores individual expenses. The categoryId field is a foreign key that links each expense to exactly one category. This is a one-to-many relationship: one category can have many expenses, but each expense belongs to one category.
The Decimal @db.Decimal(10, 2) type stores monetary values precisely (up to 10 digits with 2 decimal places), avoiding the floating-point rounding issues you would get with a plain Float type.
- Save prisma/schema.prisma.
Seeing a red underline in your schema?
If your editor shows an error under Expense[] or @relation, make sure both models are saved in the same file. Prisma validates relations across models, so both must exist before the error clears.
Help me fix my Prisma schema validation error
✔️ Awesome, I've got everything!
Great. Double check you have saved prisma/schema.prisma before moving on.
ⓧ I'd like to double check the full code
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Category {
id Int @id @default(autoincrement())
name String @unique
expenses Expense[]
}
model Expense {
id Int @id @default(autoincrement())
description String
amount Decimal @db.Decimal(10, 2)
createdAt DateTime @default(now())
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
}
Run the migration and generate Prisma Client
A migration takes the models you defined in the schema and creates the actual tables in your database. Prisma also generates a TypeScript client with types that match your schema, giving you type-safe database queries.
- Run the migration by entering this command in your terminal:
npx prisma migrate dev --name init
What does this command do?
This command does three things. It compares your schema to the current database state, generates a SQL migration file in prisma/migrations/, and applies that SQL to your Neon database to create the Category and Expense tables.
It also regenerates the Prisma Client at src/generated/prisma with TypeScript types that match your models. This means you get autocomplete and type checking every time you query the database.
- You should see output confirming that the migration was applied successfully and the Prisma Client was generated.
- Open Prisma Studio to visually confirm your tables exist by running:
npx prisma studio
- Prisma Studio opens in your browser at http://localhost:5555. You should see both Category and Expense listed in the left sidebar. Both tables are empty for now.
- Close Prisma Studio by pressing Ctrl+C in the terminal when you are done.
Migration failed with a connection error?
Make sure your DATABASE_URL in .env is correct. Copy it again from the Neon dashboard if needed. The Neon database auto-suspends after 5 minutes of inactivity and may take a moment to wake up on the next query.
Help me troubleshoot my Prisma migration connection error
Seed the database with categories
Your Expense table has a categoryId column that references the Category table. Before you can create expenses, you need categories to exist. A seed script fills the database with this starter data.
- Install the dependencies needed to run the seed script by running:
npm install -D tsx dotenv
Why tsx and dotenv?
The tsx package lets you run TypeScript files directly without a separate compile step. The dotenv package loads your .env file so the seed script can read DATABASE_URL.
Prisma 7 does not load environment variables automatically, so you must load them yourself in standalone scripts.
- Create a new file called prisma/seed.ts and paste the following code:
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });
async function main() {
// Define the five default categories
const categories = ["Food", "Transport", "Entertainment", "Bills", "Other"];
// Upsert each category (create if missing, skip if exists)
for (const name of categories) {
await prisma.category.upsert({
where: { name },
update: {},
create: { name },
});
}
console.log("Seeded categories:", categories);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
What does this seed script do?
The script connects to your Neon database using the same PrismaPg adapter you will use in the app. It loops through five category names and uses upsert to insert each one.
If a category already exists (matched by its unique name), it skips it. This makes the seed script safe to run multiple times without creating duplicates.
- Save prisma/seed.ts.
- Now configure Prisma to know about your seed script. Open prisma.config.ts in your project root and replace its contents with the following:
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: env("DATABASE_URL"),
},
});
What does the seed field do?
The migrations.seed field tells Prisma which command to run when you execute npx prisma db seed. In this case it runs tsx prisma/seed.ts to execute the TypeScript seed file directly.
- Save prisma.config.ts.
- Run the seed script by entering this command:
npx prisma db seed
- You should see the output Seeded categories: [ 'Food', 'Transport', 'Entertainment', 'Bills', 'Other' ] in your terminal.
- Verify the seed worked by running Prisma Studio again:
npx prisma studio
- Click on Category in Prisma Studio. You should see 5 rows: Food, Transport, Entertainment, Bills, and Other.
- Click on Expense. The table should exist but be empty (no rows).
- Close Prisma Studio with Ctrl+C when you are done.
Seed command failed?
Make sure you installed tsx and dotenv (check with npm list tsx dotenv). Also confirm that prisma.config.ts includes the seed: "tsx prisma/seed.ts" line inside the migrations object.
If you see a module resolution error, ensure the import path in seed.ts matches your generated client location: ../src/generated/prisma.
Help me troubleshoot my seed command failure
Your database schema is designed, migrated, and seeded. Next up, you will build the read layer that fetches expenses from this database and displays them on the page using Server Components.
Build the Read Layer with Server Components
Your database is live with five categories ready to go. Now it's time to connect your app to that data and display it on screen.
In a traditional React app, you would write a useEffect hook to fetch data after the page loads, manage loading states, and handle errors. Server Components eliminate all of that. You call your database directly inside the component, and the data arrives before the page ever reaches the browser.
In this step, get ready to:
- Create a Prisma Client singleton to safely share the database connection.
- Build a Server Component that fetches and displays expenses from the database.
- Render a friendly empty state when no expenses exist yet.
Set up the Prisma Client singleton
In development, Next.js hot-reloads your code on every save. Each reload creates a new PrismaClient instance, which opens fresh database connections. Without a safeguard, you quickly exhaust the connection pool and see "Too many connections" errors.
The fix is a singleton pattern that stores one PrismaClient on globalThis so it survives hot reloads.
- Create a new folder and file at src/lib/prisma.ts by running this command from your expense-tracker folder:
mkdir -p src/lib && touch src/lib/prisma.ts
- Confirm the file exists by running:
ls src/lib
You should see prisma.ts in the output.
- Open src/lib/prisma.ts in your editor and paste the following code:
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma";
// Store the PrismaClient on globalThis to survive hot reloads
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
// Create the PostgreSQL adapter using the connection string from .env
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
// Reuse the existing client if available, otherwise create a new one
export const prisma =
globalForPrisma.prisma || new PrismaClient({ adapter });
// In development, attach the client to globalThis so it persists across reloads
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
What does this code do?
- The PrismaPg adapter tells Prisma to connect to PostgreSQL using your DATABASE_URL from .env.
- The globalForPrisma variable casts globalThis so TypeScript lets you attach a prisma property to it.
- The conditional at the bottom only caches the client in development. In production, each serverless invocation gets its own client.
- Save src/lib/prisma.ts.
Seeing a TypeScript import error?
If your editor shows a red underline on the ../generated/prisma import, make sure you ran npx prisma migrate dev --name init in the previous step. That command generates the Prisma Client at src/generated/prisma.
Help me fix my Prisma Client import error
Fetch and display expenses on the home page
With the Prisma singleton ready, you can now query the database directly inside a page component. This is the power of Server Components. The component is async, fetches data on the server, and sends finished HTML to the browser.
- Open src/app/layout.tsx and replace its entire contents with this code:
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Expense Tracker",
description: "A full-stack expense tracker built with Next.js, Prisma, and PostgreSQL",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="bg-gray-50 min-h-screen">{children}</body>
</html>
);
}
What does this code do?
This sets the page title and description that appear in the browser tab. The bg-gray-50 class gives the entire app a subtle off-white background, and min-h-screen makes the body fill the full viewport height.
- Save src/app/layout.tsx.
- Now open src/app/page.tsx and replace its entire contents with this code:
import { prisma } from "@/lib/prisma";
import { deleteExpense } from "./actions";
import ExpenseForm from "@/components/ExpenseForm";
export default async function Home() {
// Fetch all categories, sorted alphabetically
const categories = await prisma.category.findMany({
orderBy: { name: "asc" },
});
// Fetch all expenses with their category info, newest first
const expenses = await prisma.expense.findMany({
include: { category: true },
orderBy: { createdAt: "desc" },
});
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Expense Tracker</h1>
<section className="mb-8 rounded-lg border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">Add Expense</h2>
<ExpenseForm categories={categories} />
</section>
<section>
<h2 className="text-xl font-semibold text-gray-800 mb-4">Expenses</h2>
{expenses.length === 0 ? (
<p className="text-gray-500 text-center py-8">
No expenses yet. Add your first expense above!
</p>
) : (
<ul className="space-y-3">
{expenses.map((expense) => (
<li
key={expense.id}
className="flex items-center justify-between rounded-lg border border-gray-200 p-4"
>
<div>
<p className="font-medium text-gray-900">
{expense.description}
</p>
<p className="text-sm text-gray-500">
{expense.category.name} ·{" "}
{new Date(expense.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-4">
<span className="font-semibold text-gray-900">
${expense.amount.toString()}
</span>
<form action={deleteExpense}>
<input type="hidden" name="id" value={expense.id} />
<button
type="submit"
className="text-sm text-red-500 hover:text-red-700"
>
Delete
</button>
</form>
</div>
</li>
))}
</ul>
)}
</section>
</main>
);
}
What does this code do?
- The async keyword makes this a Server Component. It runs on the server, so you can await database queries directly inside it.
- The prisma.category.findMany() call fetches all categories sorted by name. The prisma.expense.findMany() call fetches expenses with their related category data included.
- The include: { category: true } option joins the Category table so each expense object contains its category name.
- The ExpenseForm component and deleteExpense action are imported here but will be created in the next step. The page structure is ready for them.
Why Server Components over API routes?
In older React patterns, you would create an API route (like /api/expenses), then call it from the client with useEffect and fetch. That means extra network round-trips, loading spinners, and more code to maintain.
Server Components query the database directly during rendering. The browser receives finished HTML with data already included. No client-side JavaScript is needed to load the initial data.
- Save src/app/page.tsx.
Seeing a module not found error?
The page.tsx file imports ./actions and @/components/ExpenseForm, which you will create in the next step. The dev server will show an error until those files exist. This is expected.
Help me understand this import error
Understand the empty state
Your Expense table is currently empty (you only seeded categories). The page handles this gracefully with a conditional check.
- Look at this section in your page.tsx:
{expenses.length === 0 ? (
<p className="text-gray-500 text-center py-8">
No expenses yet. Add your first expense above!
</p>
) : (
<ul className="space-y-3">
{/* expense list renders here */}
</ul>
)}
What is this pattern?
This is a ternary conditional. If the expenses array is empty, it shows a helpful message. Once you add expenses in the next step, the list will render automatically because the server re-fetches data on each request.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/lib/prisma.ts, src/app/layout.tsx, and src/app/page.tsx.
ⓧ I'd like to double check the full code
Here is src/lib/prisma.ts:
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
export const prisma =
globalForPrisma.prisma || new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Here is src/app/layout.tsx:
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Expense Tracker",
description: "A full-stack expense tracker built with Next.js, Prisma, and PostgreSQL",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="bg-gray-50 min-h-screen">{children}</body>
</html>
);
}
Here is src/app/page.tsx:
import { prisma } from "@/lib/prisma";
import { deleteExpense } from "./actions";
import ExpenseForm from "@/components/ExpenseForm";
export default async function Home() {
const categories = await prisma.category.findMany({
orderBy: { name: "asc" },
});
const expenses = await prisma.expense.findMany({
include: { category: true },
orderBy: { createdAt: "desc" },
});
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Expense Tracker</h1>
<section className="mb-8 rounded-lg border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">Add Expense</h2>
<ExpenseForm categories={categories} />
</section>
<section>
<h2 className="text-xl font-semibold text-gray-800 mb-4">Expenses</h2>
{expenses.length === 0 ? (
<p className="text-gray-500 text-center py-8">
No expenses yet. Add your first expense above!
</p>
) : (
<ul className="space-y-3">
{expenses.map((expense) => (
<li
key={expense.id}
className="flex items-center justify-between rounded-lg border border-gray-200 p-4"
>
<div>
<p className="font-medium text-gray-900">
{expense.description}
</p>
<p className="text-sm text-gray-500">
{expense.category.name} ·{" "}
{new Date(expense.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-4">
<span className="font-semibold text-gray-900">
${expense.amount.toString()}
</span>
<form action={deleteExpense}>
<input type="hidden" name="id" value={expense.id} />
<button
type="submit"
className="text-sm text-red-500 hover:text-red-700"
>
Delete
</button>
</form>
</div>
</li>
))}
</ul>
)}
</section>
</main>
);
}
Your read layer is in place. The page structure is ready to display expenses, and the empty state handles the case where no data exists yet. In the next step, you will create the Server Actions and form component that let users add and delete expenses.
Build the Write Layer with Server Actions
In the last step, you built a Server Component that reads expenses from the database and displays them on the page. Right now your dev server is showing an error because page.tsx references two files that don't exist yet.
In this step, you'll create those files. You'll write Server Actions to handle form submissions (adding and deleting expenses) and build a Client Component for the expense form.
In this step, get ready to:
- Create Server Actions to add and delete expenses.
- Build a Client Component form for submitting new expenses.
- Test the full create and delete flow end to end.
Create the Server Actions file
Traditional React apps use API routes to handle data mutations. You'd write a POST endpoint, call fetch from the client, parse the response, and manually update state.
Server Actions replace all of that. You mark a file with 'use server' and export async functions that run on the server. These functions can be passed directly to a form's action prop.
- Create a new file at src/app/actions.ts and add the createExpense function by pasting this code:
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
// Server Action to create a new expense from form data
export async function createExpense(formData: FormData) {
const description = formData.get("description") as string;
const amount = parseFloat(formData.get("amount") as string);
const categoryId = parseInt(formData.get("categoryId") as string, 10);
// Validate that all required fields are present
if (!description || isNaN(amount) || isNaN(categoryId)) {
return { error: "All fields are required" };
}
// Insert the new expense into the database
await prisma.expense.create({
data: {
description,
amount,
categoryId,
},
});
// Tell Next.js to re-render the home page with fresh data
revalidatePath("/");
}
What does this code do?
The "use server" directive at the top marks every exported function in this file as a Server Action. These functions run exclusively on the server, never in the browser.
When a form calls createExpense, the function receives FormData automatically. It extracts the fields, validates them, inserts a row into the database with prisma.expense.create(), then calls revalidatePath("/") to tell Next.js to re-render the home page with the updated data.
- Add the deleteExpense function below createExpense in the same file:
// Server Action to delete an expense by ID
export async function deleteExpense(formData: FormData) {
const id = parseInt(formData.get("id") as string, 10);
if (isNaN(id)) {
return { error: "Invalid expense ID" };
}
await prisma.expense.delete({
where: { id },
});
revalidatePath("/");
}
What does this code do?
The deleteExpense function reads an id from the form's hidden input, deletes the matching row with prisma.expense.delete(), and revalidates the path so the list updates.
- Save src/app/actions.ts.
- You won't see a change in the browser yet because ExpenseForm.tsx still needs to be created.
Why Server Actions instead of API routes?
With API routes, you write a handler, call fetch from the client, manage loading states, and handle errors on both sides. Server Actions collapse all of that into a single function call that the framework wires up for you.
The mutation, the cache invalidation, and the page re-render all happen in a single HTTP roundtrip. No client-side fetch code, no manual state management.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/app/actions.ts before moving on.
ⓧ I'd like to double check the full code
Here's the complete src/app/actions.ts file:
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
export async function createExpense(formData: FormData) {
const description = formData.get("description") as string;
const amount = parseFloat(formData.get("amount") as string);
const categoryId = parseInt(formData.get("categoryId") as string, 10);
if (!description || isNaN(amount) || isNaN(categoryId)) {
return { error: "All fields are required" };
}
await prisma.expense.create({
data: {
description,
amount,
categoryId,
},
});
revalidatePath("/");
}
export async function deleteExpense(formData: FormData) {
const id = parseInt(formData.get("id") as string, 10);
if (isNaN(id)) {
return { error: "Invalid expense ID" };
}
await prisma.expense.delete({
where: { id },
});
revalidatePath("/");
}
Build the expense form component
The form needs client-side interactivity to reset its fields after a successful submission. This means it must be a Client Component marked with 'use client'.
Client Components can still call Server Actions directly. The form's action prop accepts the imported Server Action, and React handles the network call behind the scenes.
- Create a new folder src/components if it doesn't exist, then create src/components/ExpenseForm.tsx with this code:
"use client";
import { createExpense } from "@/app/actions";
import { useRef } from "react";
// Type for the category prop passed from the parent Server Component
interface Category {
id: number;
name: string;
}
export default function ExpenseForm({
categories,
}: {
categories: Category[];
}) {
// Ref to the form element so we can reset it after submission
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
await createExpense(formData);
formRef.current?.reset();
}
return (
<form ref={formRef} action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<input
type="text"
id="description"
name="description"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="amount" className="block text-sm font-medium text-gray-700">
Amount
</label>
<input
type="number"
id="amount"
name="amount"
step="0.01"
min="0.01"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="categoryId" className="block text-sm font-medium text-gray-700">
Category
</label>
<select
id="categoryId"
name="categoryId"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Select a category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<button
type="submit"
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Add Expense
</button>
</form>
);
}
What does this code do?
The 'use client' directive makes this a Client Component. It imports createExpense from your Server Actions file and wraps it in a handleSubmit function that also resets the form fields after submission.
The useRef hook stores a reference to the <form> element. After createExpense completes, formRef.current?.reset() clears all the input fields so the user can immediately add another expense.
The categories prop is passed down from the parent Server Component (page.tsx), which fetches the list from the database. The form renders them as a <select> dropdown.
- Save src/components/ExpenseForm.tsx.
- Return to your browser at http://localhost:3000. The page should now load without errors, showing the form with fields for Description, Amount, and Category.
Still seeing an error?
- Make sure both src/app/actions.ts and src/components/ExpenseForm.tsx are saved. The page imports from both files.
- Check that the file paths are correct. ExpenseForm.tsx must be inside src/components/ (not src/app/components/).
- Verify your dev server is still running. If it crashed, restart it with npm run dev.
Help me debug my expense form not loading
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/components/ExpenseForm.tsx and the form is visible in your browser.
ⓧ I'd like to double check the full code
Here's the complete src/components/ExpenseForm.tsx file:
"use client";
import { createExpense } from "@/app/actions";
import { useRef } from "react";
interface Category {
id: number;
name: string;
}
export default function ExpenseForm({
categories,
}: {
categories: Category[];
}) {
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
await createExpense(formData);
formRef.current?.reset();
}
return (
<form ref={formRef} action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<input
type="text"
id="description"
name="description"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="amount" className="block text-sm font-medium text-gray-700">
Amount
</label>
<input
type="number"
id="amount"
name="amount"
step="0.01"
min="0.01"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="categoryId" className="block text-sm font-medium text-gray-700">
Category
</label>
<select
id="categoryId"
name="categoryId"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Select a category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<button
type="submit"
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Add Expense
</button>
</form>
);
}
Test the delete functionality
Your page.tsx already includes a delete form for each expense row. Each row renders a small form with a hidden input carrying the expense id and a submit button that calls deleteExpense.
Here's the relevant section from page.tsx that wires this up:
<form action={deleteExpense}>
<input type="hidden" name="id" value={expense.id} />
<button
type="submit"
className="text-sm text-red-500 hover:text-red-700"
>
Delete
</button>
</form>
How does the delete pattern work?
Each expense row has its own <form> with action={deleteExpense} pointing directly at the Server Action. A hidden <input> carries the expense id so the server knows which row to delete.
When the user clicks Delete, the form submits, the Server Action deletes the row, and revalidatePath("/") triggers a fresh server render. The deleted expense disappears from the list without any client-side JavaScript managing the state.
- In your browser, fill in the form: enter Coffee for the description, 4.50 for the amount, and select Food as the category.
- Click Add Expense.
- The expense should appear in the list below the form, showing the description, amount, category, and today's date.
- Click Delete next to the expense you just added. It should disappear from the list.
- Refresh the page to confirm the data persists. Add another expense and refresh. The expense stays because it's stored in your Neon database, not in local state.
Expense not appearing after submission?
- Open your browser's developer console and check for errors. A common issue is the DATABASE_URL not being loaded. Restart the dev server if you recently changed .env.
- Make sure you selected a category from the dropdown. The form validates that all fields are present.
- Check that actions.ts starts with "use server" (with quotes). Without the directive, the functions won't run on the server.
Help me debug my expense form submission
Your expense tracker now has full CRUD functionality. You can create expenses through the form and delete them with a single click. Next up, you'll take on the Secret Mission to add category filtering and a monthly spending summary.
Secret mission
Category Filtering and Monthly Summary
Your expense tracker works, but what if you have 50 expenses and need to see only your Food spending? Or want to know how much you've spent this month at a glance? In this mission, you'll add a category filter dropdown that uses URL search params and a monthly total summary card powered by Prisma's aggregation queries.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Neon's free tier, so there are no ongoing costs while your database is idle.
Resources you used:
- Neon PostgreSQL database (free tier, 0.5 GB storage, 100 compute-hours per month).
- Local expense-tracker project folder on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep building on this project.
- Your Neon database stays on the free tier with no time limit. It auto-suspends after 5 minutes of inactivity and wakes up automatically on the next query.
- Your local expense-tracker folder remains on your Desktop, ready to open in your editor.
Pause - I'll come back to this later
Stop running processes to free up local resources. Your files and database stay intact for when you return.
- If your dev server is still running, press Ctrl+C in the terminal to stop it.
- Your Neon database auto-suspends on its own after 5 minutes of inactivity. No manual action needed.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Delete your Neon project by navigating to the Neon Console.
- Select your expense-tracker project.
- Click Settings in the sidebar.
- Click Delete and confirm the deletion.
Remove the local project folder by running this command:
cd ~/Desktop
rm -rf expense-tracker
On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force expense-tracker
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see expense-tracker in the output.
Nice Work!
Nice Work!
Nice work! You just built a full-stack expense tracker from scratch using Next.js 16, Prisma ORM, and a cloud PostgreSQL database.
You've learned how to:
- Designed a relational database schema with Prisma, using a one-to-many relationship between Category and Expense models connected to a cloud PostgreSQL database on Neon.
- Fetched data using Server Components that query the database directly on the server. No useEffect, no client-side loading states.
- Handled mutations using Server Actions passed directly to form action props. No API routes, no fetch calls.
- Secret Mission: Added category filtering with URL search params and a monthly expense aggregation summary.
Ready to quiz yourself?