Build a Full-Stack Job Tracker

Build a deployed job application tracker with Next.js, Prisma, and PostgreSQL.

Introduction

30 Second Summary

Job searching means juggling dozens of applications across companies, statuses, and deadlines. Spreadsheets get messy fast, and you never remember where you applied last week.

In this project, you will build a full-stack job application tracker that stores your data in a real PostgreSQL database and deploys to a live URL you can share with employers.

What You'll Build

A Next.js 16 App Router application with a Neon PostgreSQL database, full CRUD operations using Server Actions, and a live Vercel deployment.

By the end of this project, you'll have:

  • A Next.js app with Server Components that query a PostgreSQL database directly, with no API routes needed.
  • A Prisma schema modeling job applications with full CRUD operations (create, read, update, delete).
  • A live deployment on Vercel with a shareable URL that proves you can ship real full-stack applications.
  • Secret Mission: Add filtering by application status and a search bar to find applications by company name.

Are there any prerequisites?

You should have basic familiarity with React and JavaScript. No prior Next.js or database experience is required.

Set Up Your Development Environment

Before you can build a full-stack application, you need the right tools on your machine.

In this step, you'll install Node.js (the JavaScript runtime that powers your project) and scaffold a brand new Next.js 16 app with everything pre-configured.

In this step, get ready to:
  • Install and verify Node.js v20+.
  • Scaffold a Next.js 16 project with TypeScript and Tailwind CSS.
  • Run your development server and confirm it loads in the browser.
Install Node.js

Node.js is the runtime that lets you execute JavaScript outside a browser. Your Next.js project and all its tooling depend on it.

  • Check which version of Node.js you have by running this command in your terminal:
node --version

✔️ I see version 20 or higher

You're all set. Move on to the next substep.

ⓧ I see an older version

This project requires Node.js v20 or higher. Your current version is too old and will cause silent failures later.

  • Visit nodejs.org/en/download and download the LTS installer for your operating system.
  • Run the installer and follow the default prompts.
  • Close and reopen your terminal, then run the version check again.

macOS ships with an older Node.js

If you previously installed Node.js through Homebrew or your system package manager, the old version may still take priority. After installing the new version, run node --version again to confirm it shows v20 or higher. If it still shows the old version, you may need to update your PATH or use a version manager like nvm.

ⓧ Command not found

Node.js is not installed yet. Let's fix that.

  • Visit nodejs.org/en/download and download the LTS installer for your operating system.
  • Run the installer and follow the default prompts.
  • Close and reopen your terminal so the new PATH takes effect.
  • Verify the installation by running:
node --version

You should see a version number starting with v20 or higher.

  • Also confirm npm is available by running:
npm --version

You should see a version number printed. npm is the package manager that ships with Node.js.

Scaffold your Next.js project

Next.js provides a scaffolding tool that generates a complete project structure in one command. It sets up TypeScript, Tailwind CSS, and the App Router for you automatically.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest job-tracker --yes

What does this command do?

npx create-next-app@latest downloads and runs the latest Next.js project scaffolding tool.

job-tracker is the name of the folder it creates. The --yes flag accepts all defaults: TypeScript, Tailwind CSS, ESLint, App Router, Turbopack, and the @/* import alias.

  • Wait for the install to finish. You should see a job-tracker folder appear on your Desktop.
  • Confirm the folder was created by running:
ls

You should see job-tracker listed in the output.

Seeing an error about npx or create-next-app?

Make sure your Node.js version is v20 or higher. Older versions may not support the latest create-next-app.

If you see a permission error, try running the command again. Sometimes the first run prompts to install create-next-app and needs a second attempt.

help me troubleshoot my create-next-app error

Open the project and start the dev server

Now that the project is scaffolded, you'll open it in your code editor, customize the default layout, and start the development server to see it running live.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor (or VS Code if you prefer) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the job-tracker folder, then click Open.

The default scaffold includes font imports and generic metadata that you don't need. Let's clean it up so the project is ready for your job tracker.

  • In the Explorer sidebar, expand the src/app folder and click on layout.tsx to open it.
  • Select all the content in the file (Cmd+A on macOS or Ctrl+A on Windows) and replace it with the following:
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Job Application Tracker",
  description: "Track your job applications in one place",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="antialiased">{children}</body>
    </html>
  );
}

What does this file do?

layout.tsx is the root layout that wraps every page in your Next.js app. It sets the HTML structure, applies global CSS, and defines metadata (the page title and description shown in browser tabs and search results).

You removed the default Geist font imports to keep things simple. The antialiased class smooths text rendering across browsers.

  • Save the file (Cmd+S on macOS or Ctrl+S on Windows).

Now let's start the development server to see your app in the browser.

  • Open the integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Start the development server by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000.

You should see the default Next.js starter page with the Next.js logo and some links. This confirms your project is running correctly.

Page not loading at localhost:3000?

Make sure your terminal is in the job-tracker folder. If you see an error about missing modules, run npm install first and then try npm run dev again.

If another process is using port 3000, the terminal will show the next available port (like 3001). Check the terminal output for the actual URL.

help me fix my dev server

✔️ Awesome, I've got everything!

Great. Double check you've saved layout.tsx and your dev server is still running.

ⓧ I'd like to double check the full code

Here is the complete src/app/layout.tsx file. Make sure yours matches exactly.

import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Job Application Tracker",
  description: "Track your job applications in one place",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="antialiased">{children}</body>
    </html>
  );
}

Your development environment is ready. Next up, you'll connect a real PostgreSQL database so your job tracker can store data that persists across sessions.

Set Up Neon PostgreSQL Database

Your Next.js app is running, but right now it has no way to store data. A job tracker needs a database to persist applications across sessions and devices.

In this step, you'll create a free PostgreSQL database on Neon and install Prisma ORM to connect your app to it. Neon is a serverless Postgres provider that scales to zero when idle, so you pay nothing on the free tier.

In this step, get ready to:
  • Create a free Neon PostgreSQL database.
  • Install Prisma ORM and its PostgreSQL adapter.
  • Initialize Prisma and connect it to your Neon database.
Create a free Neon account and project

Neon provides serverless PostgreSQL databases with a generous free tier. You'll create an account and a project, then grab the connection string your app needs to talk to the database.

  • Open https://neon.tech in your browser and sign up for a free account.
  • After signing up, the onboarding flow asks you to create a project. Enter a project name (for example, job-tracker) and click Create Project.
  • On your Project Dashboard, click the Connect button.
  • In the Connect to your database modal, you'll see a connection string that starts with postgresql://. Copy this entire string and save it somewhere safe.

What is a connection string?

A connection string is a single URL that contains everything your app needs to connect to the database: the username, password, host address, and database name. It looks like postgresql://user:password@host/dbname?sslmode=require.

Keep this string private. It contains your database password.

Why Neon instead of a local database?

A local PostgreSQL install works for development, but you'd need a separate hosted database for deployment anyway. Neon gives you a cloud database from the start, so your app works the same locally and in production. The free tier includes 0.5 GB of storage and scales to zero after 5 minutes of inactivity, meaning no ongoing costs.

Can't find the connection string?

Make sure you're on the Project Dashboard for your newly created project. The Connect button should be visible near the top of the page. If you accidentally closed the onboarding flow, navigate to the Projects page and select your project to reach its dashboard.

Help me find my Neon connection string

Install Prisma and its PostgreSQL adapter

Prisma is an ORM (Object-Relational Mapper) that lets you interact with your database using TypeScript instead of raw SQL. Prisma 7 requires a driver adapter for database connections, so you'll install the core packages alongside the PostgreSQL adapter.

  • Stop your dev server if it's still running by pressing Ctrl+C in the terminal.
  • Install the Prisma CLI as a dev dependency by running this command:
npm install prisma --save-dev
  • Install the Prisma Client, PostgreSQL adapter, driver, and dotenv by running:
npm install @prisma/client @prisma/adapter-pg pg dotenv

What are these packages?

  • prisma is the CLI tool for managing your schema and running migrations.
  • @prisma/client is the auto-generated query builder you'll use in your app code.
  • @prisma/adapter-pg is the PostgreSQL driver adapter required by Prisma 7.
  • pg is the underlying PostgreSQL driver that the adapter wraps.
  • dotenv loads environment variables from your .env file.
  • Confirm the packages installed successfully. You should see a message in the terminal indicating packages were added with no errors.

Seeing permission errors or install failures?

Make sure your terminal is inside the job-tracker folder. Run pwd to check your current directory. If you're in the wrong folder, run cd ~/Desktop/job-tracker to get back.

Help me fix my npm install error

Initialize Prisma and configure the connection

Now you'll scaffold Prisma's configuration files and point them at your Neon database.

  • Initialize Prisma in your project by running:
npx prisma init

What does this command do?

This creates a prisma/ directory with a schema.prisma file where you'll define your database models. It also creates a .env file at the project root for your database connection string, and a prisma.config.ts file for Prisma CLI configuration.

  • Open prisma/schema.prisma in your editor. Replace the entire contents with the following:
// Prisma schema initialized by npx prisma init
// datasource and generator blocks present, no models yet
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

What does this schema do?

  • The generator block tells Prisma to generate a TypeScript client you can import in your app.
  • The datasource block tells Prisma to use PostgreSQL and read the connection URL from the DATABASE_URL environment variable.
  • Open the .env file in your project root. Replace the placeholder DATABASE_URL value with the Neon connection string you copied earlier:
DATABASE_URL="postgresql://your-user:your-password@your-neon-host/neondb?sslmode=require"

What should my connection string look like?

Your actual connection string will have real values instead of the placeholders shown above. It will look something like postgresql://alex:abc123@ep-cool-forest-12345.us-east-2.aws.neon.tech/neondb?sslmode=require. Paste the full string you copied from the Neon console between the double quotes.

  • Save both prisma/schema.prisma and .env.
  • Verify the connection works by running:
npx prisma validate
  • You should see a success message indicating your schema is valid. This confirms Prisma can read your schema and find the DATABASE_URL variable.

Seeing a validation error?

Double-check that your .env file is in the project root (the same level as package.json), not inside the prisma/ folder. Also make sure the connection string is wrapped in double quotes and contains no line breaks.

Help me fix my Prisma validation error

Keep your .env file private

The .env file contains your database credentials. Never commit it to Git. Next.js projects created with create-next-app include .env in .gitignore by default, so you're already covered.

✔️ Awesome, I've got everything!

Great. Make sure you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here's what your prisma/schema.prisma should look like:

// Prisma schema initialized by npx prisma init
// datasource and generator blocks present, no models yet
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

And your .env should contain your Neon connection string:

DATABASE_URL="postgresql://your-user:your-password@your-neon-host/neondb?sslmode=require"

Your database is live and Prisma is wired up to it. Next up, you'll define the schema for your job applications and create the actual database tables.

Define the Database Schema and Run Migrations

You have a database connected, but it has no structure yet. Without a defined schema, there's nowhere to store job applications.

In this step, you'll define the shape of your data using Prisma's schema language, apply it to your Neon database as a migration, and set up the client that your app will use to query data.

In this step, get ready to:
  • Define the JobApplication model with status tracking.
  • Run a migration to create the database table in Neon.
  • Set up a Prisma Client singleton for querying the database.
Define the JobApplication model

A Prisma schema describes your database tables as models. Each model maps to a table, and each field maps to a column.

  • In your editor, open prisma/schema.prisma.
  • Replace the entire contents of the file with the following:
generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// Define the possible statuses for a job application
enum ApplicationStatus {
  APPLIED
  INTERVIEWING
  OFFER
  REJECTED
}

// Define the JobApplication table structure
model JobApplication {
  id          String            @id @default(cuid())
  company     String
  role        String
  status      ApplicationStatus @default(APPLIED)
  dateApplied DateTime          @default(now())
  notes       String?
  url         String?
  createdAt   DateTime          @default(now())
  updatedAt   DateTime          @updatedAt

  @@map("job_applications")
}

What does this schema do?

The generator block tells Prisma where to output the generated client code. The output path places it in src/generated/prisma so your app can import it cleanly.

The enum ApplicationStatus defines the four possible states a job application can be in. The model JobApplication maps to a job_applications table in your database (via @@map).

Fields marked with ? are optional. @default(cuid()) generates a unique ID automatically. @updatedAt updates the timestamp every time the record changes.

  • Save prisma/schema.prisma.

Seeing red squiggly lines in your editor?

If your editor shows syntax errors in the .prisma file, it may not have Prisma language support installed. This won't affect the migration. You can install the Prisma extension for your editor to get syntax highlighting.

help me fix syntax errors in my Prisma schema file

Run the migration to create database tables

A migration takes your schema definition and turns it into actual SQL that creates tables in your database. This is how Prisma keeps your code and database in sync.

  • Apply the schema to your Neon database by running this command:
npx prisma migrate dev --name init

What does this command do?

This command reads your schema.prisma file, generates the SQL to create the job_applications table, and applies it to your Neon database. The --name init flag names this migration "init" so you can identify it later.

The migration files are saved in a prisma/migrations folder. These act as a version history for your database structure.

  • You should see output indicating the migration was created and applied, ending with a message that your database is now in sync with your schema.

In Prisma 7, the client code is no longer generated automatically after a migration. You need to generate it explicitly.

  • Generate the Prisma Client by running:
npx prisma generate
  • You should see output confirming the Prisma Client was generated to src/generated/prisma.

Migration failed with a connection error?

Double check that your DATABASE_URL in .env is correct. It should start with postgresql:// and end with ?sslmode=require. Make sure there are no extra spaces or line breaks in the connection string.

If you see a "shadow database" error, Neon's free tier supports shadow databases automatically. Try running the command again.

help me fix my Prisma migration connection error

Create the Prisma Client singleton

In development, Next.js reloads your code frequently. Without a singleton, every reload would create a new database connection, eventually exhausting the connection pool.

  • Create a new file at src/lib/prisma.ts. Your editor will create the lib folder automatically.
  • Paste the following code into src/lib/prisma.ts:
import { PrismaClient } from "../generated/prisma";
import { PrismaPg } from "@prisma/adapter-pg";

// Store the Prisma Client instance globally to reuse across hot reloads
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

// Create a new Prisma Client with the PostgreSQL adapter
function createPrismaClient() {
  const connectionString = process.env.DATABASE_URL;
  if (!connectionString) {
    throw new Error("DATABASE_URL is not set");
  }
  const adapter = new PrismaPg({ connectionString });
  return new PrismaClient({ adapter });
}

// Reuse existing client in development, create new one in production
export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

Why use a singleton pattern?

Every time Next.js hot-reloads your server code in development, it re-executes your module files. Without this pattern, each reload creates a fresh PrismaClient instance with its own connection pool, quickly exhausting your database connections.

The singleton stores the client on globalThis (which survives hot reloads) so the same client is reused. In production, a single instance is created normally since there are no hot reloads.

Why does Prisma 7 require an adapter?

Prisma 7 removed the built-in Rust query engine. Instead, it uses driver adapters to communicate with your database directly through JavaScript drivers. The PrismaPg adapter connects Prisma to PostgreSQL using the pg package you installed earlier.

  • Save src/lib/prisma.ts.
  • You won't see a visible change yet. The next step uses this client to query your database and display data in the browser.

Editor showing import errors?

If you see a red underline on the import { PrismaClient } from "../generated/prisma" line, make sure you ran npx prisma generate successfully. That command creates the src/generated/prisma folder that this import points to.

help me fix Prisma Client import errors in my project

✔️ Awesome, I've got everything!

Great. Double check you've saved both files.

ⓧ I'd like to double check the full code

Here is the full prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum ApplicationStatus {
  APPLIED
  INTERVIEWING
  OFFER
  REJECTED
}

model JobApplication {
  id          String            @id @default(cuid())
  company     String
  role        String
  status      ApplicationStatus @default(APPLIED)
  dateApplied DateTime          @default(now())
  notes       String?
  url         String?
  createdAt   DateTime          @default(now())
  updatedAt   DateTime          @updatedAt

  @@map("job_applications")
}

Here is the full src/lib/prisma.ts:

import { PrismaClient } from "../generated/prisma";
import { PrismaPg } from "@prisma/adapter-pg";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

function createPrismaClient() {
  const connectionString = process.env.DATABASE_URL;
  if (!connectionString) {
    throw new Error("DATABASE_URL is not set");
  }
  const adapter = new PrismaPg({ connectionString });
  return new PrismaClient({ adapter });
}

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

Your database is structured and ready to hold job applications. Next up, you'll build the page that queries this data and displays it in the browser.

Build the Job Application List

In the previous step, you set up your database schema and created the Prisma Client. Now it is time to display data from that database in your app.

The beauty of Server Components in Next.js is that they run on the server, so you can query your database directly without building a separate API. Your page component fetches data, renders it, and sends finished HTML to the browser.

In this step, get ready to:
  • Create a Server Component page that queries all job applications from your database.
  • Build a styled application card component with color-coded status badges.
  • Add an empty state message for when no applications exist yet.
Create the main page as a Server Component

In Next.js App Router, every component in the app/ directory is a Server Component by default. This means your page can be an async function that queries the database directly, with no useEffect or useState needed.

  • Replace the contents of src/app/page.tsx with the following code:
import { prisma } from "@/lib/prisma";
import { ApplicationCard } from "@/components/application-card";
import { AddApplicationForm } from "@/components/add-application-form";

export default async function Home() {
  // Query all job applications, newest first
  const applications = await prisma.jobApplication.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-900 mb-8">
          Job Application Tracker
        </h1>

        <AddApplicationForm />

        <div className="mt-8">
          {applications.length === 0 ? (
            <p className="text-gray-500 text-center py-12">
              No applications yet. Add your first one above!
            </p>
          ) : (
            <div className="space-y-4">
              {applications.map((app) => (
                <ApplicationCard key={app.id} application={app} />
              ))}
            </div>
          )}
        </div>
      </div>
    </main>
  );
}

What does this code do?

This page is an async function, which means it runs on the server. It calls prisma.jobApplication.findMany() to fetch all applications from your Neon database, sorted by createdAt in descending order (newest first).

The page imports AddApplicationForm and ApplicationCard components that we will create in this step and the next. It also includes an empty state message when there are no applications in the database.

Why Server Components over API routes?

Server Components run entirely on the server, so your database credentials never reach the browser. You get direct database access without writing separate API endpoint files.

This eliminates an entire layer of code. Instead of building a REST API and then fetching from it, you query Prisma directly inside the component that renders the data.

  • Save src/app/page.tsx.

Your terminal will show a compilation error because AddApplicationForm and ApplicationCard do not exist yet. That is expected. You will create ApplicationCard next, and AddApplicationForm in the following step.

Build the application card component

Each job application needs a card that shows the company name, role, a color-coded status badge, and the date applied. This card will also include controls for updating and deleting entries later.

  • Create a new folder called components inside src/.
  • Create a new file called application-card.tsx inside src/components/.
  • Add the following code to src/components/application-card.tsx:
"use client";

import { updateApplicationStatus, deleteApplication } from "@/app/actions";
import { ApplicationStatus } from "../generated/prisma";

// Define the shape of a job application for type safety
interface JobApplication {
  id: string;
  company: string;
  role: string;
  status: ApplicationStatus;
  dateApplied: Date;
  notes: string | null;
  url: string | null;
}

// Map each status to a Tailwind color scheme
const statusColors: Record<ApplicationStatus, string> = {
  APPLIED: "bg-blue-100 text-blue-800",
  INTERVIEWING: "bg-yellow-100 text-yellow-800",
  OFFER: "bg-green-100 text-green-800",
  REJECTED: "bg-red-100 text-red-800",
};

export function ApplicationCard({ application }: { application: JobApplication }) {
  return (
    <div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex items-center justify-between gap-4">
      <div className="flex-1 min-w-0">
        <div className="flex items-center gap-2">
          <h3 className="font-semibold text-gray-900 truncate">{application.company}</h3>
          <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[application.status]}`}>
            {application.status}
          </span>
        </div>
        <p className="text-sm text-gray-600">{application.role}</p>
        <p className="text-xs text-gray-400 mt-1">
          Applied {new Date(application.dateApplied).toLocaleDateString()}
        </p>
        {application.notes && (
          <p className="text-xs text-gray-500 mt-1 truncate">{application.notes}</p>
        )}
      </div>

      <div className="flex items-center gap-2">
        <select
          value={application.status}
          onChange={(e) => updateApplicationStatus(application.id, e.target.value as ApplicationStatus)}
          className="text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="APPLIED">Applied</option>
          <option value="INTERVIEWING">Interviewing</option>
          <option value="OFFER">Offer</option>
          <option value="REJECTED">Rejected</option>
        </select>

        <button
          onClick={() => deleteApplication(application.id)}
          className="text-red-500 hover:text-red-700 text-sm font-medium"
        >
          Delete
        </button>
      </div>
    </div>
  );
}

What does this code do?

The "use client" directive makes this a Client Component because it uses interactive event handlers (onChange and onClick).

The statusColors map assigns each application status a pair of Tailwind CSS classes for background and text color. This gives each status badge (Applied, Interviewing, Offer, Rejected) its own distinct color.

The component also includes a dropdown to change the application status and a delete button. These call updateApplicationStatus and deleteApplication Server Actions that we will create in a later step.

  • Save src/components/application-card.tsx.

Still seeing a compilation error?

Your app will not compile yet because it references @/app/actions and @/components/add-application-form which do not exist. This is normal. We will create both of these files in the next step.

If you see a different error (like a typo or import path issue), double-check that your file is saved at src/components/application-card.tsx and the import path for ApplicationStatus is "../generated/prisma".

help me fix a compilation error in my Next.js application card component

Understand the empty state pattern

Look back at the conditional in page.tsx. When applications.length === 0, the page renders a friendly message instead of an empty void.

Why show an empty state?

An empty state message tells the user what to do next. Without it, a blank page would look broken. The message "No applications yet. Add your first one above!" gives the user clear direction.

This is a common UX pattern in production applications. Always guide users when there is no data to show.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files, then move on to the next step where we will create the form and server actions to make everything compile.

ⓧ I'd like to double check the full code

Here is the full code for both files. Make sure yours matches exactly.

import { prisma } from "@/lib/prisma";
import { ApplicationCard } from "@/components/application-card";
import { AddApplicationForm } from "@/components/add-application-form";

export default async function Home() {
  const applications = await prisma.jobApplication.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-gray-900 mb-8">
          Job Application Tracker
        </h1>

        <AddApplicationForm />

        <div className="mt-8">
          {applications.length === 0 ? (
            <p className="text-gray-500 text-center py-12">
              No applications yet. Add your first one above!
            </p>
          ) : (
            <div className="space-y-4">
              {applications.map((app) => (
                <ApplicationCard key={app.id} application={app} />
              ))}
            </div>
          )}
        </div>
      </div>
    </main>
  );
}
"use client";

import { updateApplicationStatus, deleteApplication } from "@/app/actions";
import { ApplicationStatus } from "../generated/prisma";

interface JobApplication {
  id: string;
  company: string;
  role: string;
  status: ApplicationStatus;
  dateApplied: Date;
  notes: string | null;
  url: string | null;
}

const statusColors: Record<ApplicationStatus, string> = {
  APPLIED: "bg-blue-100 text-blue-800",
  INTERVIEWING: "bg-yellow-100 text-yellow-800",
  OFFER: "bg-green-100 text-green-800",
  REJECTED: "bg-red-100 text-red-800",
};

export function ApplicationCard({ application }: { application: JobApplication }) {
  return (
    <div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex items-center justify-between gap-4">
      <div className="flex-1 min-w-0">
        <div className="flex items-center gap-2">
          <h3 className="font-semibold text-gray-900 truncate">{application.company}</h3>
          <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[application.status]}`}>
            {application.status}
          </span>
        </div>
        <p className="text-sm text-gray-600">{application.role}</p>
        <p className="text-xs text-gray-400 mt-1">
          Applied {new Date(application.dateApplied).toLocaleDateString()}
        </p>
        {application.notes && (
          <p className="text-xs text-gray-500 mt-1 truncate">{application.notes}</p>
        )}
      </div>

      <div className="flex items-center gap-2">
        <select
          value={application.status}
          onChange={(e) => updateApplicationStatus(application.id, e.target.value as ApplicationStatus)}
          className="text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="APPLIED">Applied</option>
          <option value="INTERVIEWING">Interviewing</option>
          <option value="OFFER">Offer</option>
          <option value="REJECTED">Rejected</option>
        </select>

        <button
          onClick={() => deleteApplication(application.id)}
          className="text-red-500 hover:text-red-700 text-sm font-medium"
        >
          Delete
        </button>
      </div>
    </div>
  );
}

Your page and card components are ready. In the next step, you will create the form and Server Actions that let you add, update, and delete applications. Once those files exist, the compilation error resolves and your app will render at localhost:3000.

Build the Add Application Form

Your page can display job applications, but there's no way to create them yet. The AddApplicationForm import in page.tsx points to a component that doesn't exist.

In this step, you'll create Server Actions that handle database mutations and build a form component that submits new applications directly to your Neon database.

In this step, get ready to:
  • Create Server Actions for inserting, updating, and deleting job applications.
  • Build a form component that calls the create action on submit.
  • Verify that new applications appear instantly in the list.
Create the Server Actions file

Server Actions are async functions that run on the server. Placing "use server" at the top of a file marks every exported function in it as a Server Action that clients can call directly.

  • Create a new file at src/app/actions.ts and add the following code:
"use server";

import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { ApplicationStatus } from "../generated/prisma";

// Create a new job application from form data
export async function createApplication(formData: FormData) {
  // Extract each field from the submitted form
  const company = formData.get("company") as string;
  const role = formData.get("role") as string;
  const status = (formData.get("status") as ApplicationStatus) || "APPLIED";
  const url = (formData.get("url") as string) || null;
  const notes = (formData.get("notes") as string) || null;

  // Basic validation before inserting
  if (!company || !role) {
    return { error: "Company and role are required" };
  }

  // Insert the new application into the database
  await prisma.jobApplication.create({
    data: {
      company,
      role,
      status,
      url,
      notes,
    },
  });

  // Tell Next.js to re-render the homepage with fresh data
  revalidatePath("/");
}

What does this code do?

The "use server" directive at the top tells Next.js that every exported function in this file is a Server Action. Client components can call these functions, but the code executes entirely on the server.

The createApplication function receives a standard FormData object, extracts the fields, validates that company and role exist, then inserts a new record via Prisma. After the insert, revalidatePath("/") tells Next.js to re-render the homepage so the new entry appears immediately.

The application card component you built earlier already imports updateApplicationStatus and deleteApplication from this file. You need to add those functions now so the app compiles.

  • Add the following two functions below createApplication in src/app/actions.ts:
// Update the status of an existing application
export async function updateApplicationStatus(id: string, status: ApplicationStatus) {
  await prisma.jobApplication.update({
    where: { id },
    data: { status },
  });

  revalidatePath("/");
}

// Delete an application permanently
export async function deleteApplication(id: string) {
  await prisma.jobApplication.delete({
    where: { id },
  });

  revalidatePath("/");
}

What do these functions do?

Both follow the same pattern: call a Prisma method to mutate the database, then call revalidatePath("/") so the page re-renders with fresh data.

The updateApplicationStatus function changes an application's status (like moving from Applied to Interviewing). The deleteApplication function removes a record entirely.

  • Save src/app/actions.ts.
  • Check your terminal. The dev server should no longer show import errors for @/app/actions.

Seeing a module not found error?

Make sure the file is saved at exactly src/app/actions.ts (not src/actions.ts or src/app/action.ts). The import path in application-card.tsx expects this exact location.

Also confirm that "use server" is the very first line. Nothing can come before it, not even a comment.

help me fix a module not found error for @/app/actions

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

"use server";

import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { ApplicationStatus } from "../generated/prisma";

export async function createApplication(formData: FormData) {
  const company = formData.get("company") as string;
  const role = formData.get("role") as string;
  const status = (formData.get("status") as ApplicationStatus) || "APPLIED";
  const url = (formData.get("url") as string) || null;
  const notes = (formData.get("notes") as string) || null;

  if (!company || !role) {
    return { error: "Company and role are required" };
  }

  await prisma.jobApplication.create({
    data: {
      company,
      role,
      status,
      url,
      notes,
    },
  });

  revalidatePath("/");
}

export async function updateApplicationStatus(id: string, status: ApplicationStatus) {
  await prisma.jobApplication.update({
    where: { id },
    data: { status },
  });

  revalidatePath("/");
}

export async function deleteApplication(id: string) {
  await prisma.jobApplication.delete({
    where: { id },
  });

  revalidatePath("/");
}
Build the form component

The form needs to be a Client Component because it uses useRef to reset the inputs after submission. It passes form data directly to the createApplication Server Action through the form's action prop.

  • Create a new file at src/components/add-application-form.tsx and add the following code:
"use client";

import { useRef } from "react";
import { createApplication } from "@/app/actions";

export function AddApplicationForm() {
  // Ref to the form element so we can reset it after submit
  const formRef = useRef<HTMLFormElement>(null);

  // Submit handler that calls the Server Action then clears the form
  async function handleSubmit(formData: FormData) {
    await createApplication(formData);
    formRef.current?.reset();
  }

  return (
    <form ref={formRef} action={handleSubmit} className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
      <h2 className="text-lg font-semibold text-gray-900 mb-4">Add New Application</h2>
      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <input
          name="company"
          placeholder="Company"
          required
          className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <input
          name="role"
          placeholder="Role"
          required
          className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <select
          name="status"
          className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="APPLIED">Applied</option>
          <option value="INTERVIEWING">Interviewing</option>
          <option value="OFFER">Offer</option>
          <option value="REJECTED">Rejected</option>
        </select>
        <input
          name="url"
          placeholder="Job URL (optional)"
          type="url"
          className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <textarea
          name="notes"
          placeholder="Notes (optional)"
          className="sm:col-span-2 border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
          rows={2}
        />
      </div>
      <button
        type="submit"
        className="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
      >
        Add Application
      </button>
    </form>
  );
}

What does this component do?

  • The "use client" directive makes this a Client Component, which is required for using React hooks like useRef.
  • The form's action prop receives handleSubmit. When the user clicks the button, the browser collects all named inputs into a FormData object and passes it to the function.
  • After the Server Action completes, formRef.current?.reset() clears every input so the form is ready for the next entry.
  • Each input's name attribute matches what formData.get() reads in the Server Action.
  • Save src/components/add-application-form.tsx.
  • Return to your browser at http://localhost:3000. You should see the "Add New Application" form card above the empty state message.

Form not showing up?

Confirm the file is at src/components/add-application-form.tsx (inside the components folder, not app).

Check that the export name is AddApplicationForm (capital A, capital F). The import in page.tsx expects this exact name.

help me debug why my AddApplicationForm component is not rendering

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

"use client";

import { useRef } from "react";
import { createApplication } from "@/app/actions";

export function AddApplicationForm() {
  const formRef = useRef<HTMLFormElement>(null);

  async function handleSubmit(formData: FormData) {
    await createApplication(formData);
    formRef.current?.reset();
  }

  return (
    <form ref={formRef} action={handleSubmit} className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
      <h2 className="text-lg font-semibold text-gray-900 mb-4">Add New Application</h2>
      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <input
          name="company"
          placeholder="Company"
          required
          className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <input
          name="role"
          placeholder="Role"
          required
          className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <select
          name="status"
          className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="APPLIED">Applied</option>
          <option value="INTERVIEWING">Interviewing</option>
          <option value="OFFER">Offer</option>
          <option value="REJECTED">Rejected</option>
        </select>
        <input
          name="url"
          placeholder="Job URL (optional)"
          type="url"
          className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <textarea
          name="notes"
          placeholder="Notes (optional)"
          className="sm:col-span-2 border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
          rows={2}
        />
      </div>
      <button
        type="submit"
        className="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
      >
        Add Application
      </button>
    </form>
  );
}
Test the full create flow

Everything is wired up. Time to confirm that submitting the form inserts a record into Neon and refreshes the page automatically.

  • In the Company field, type NextWork.
  • In the Role field, type Software Engineer.
  • Leave Status as Applied (the default).
  • Click Add Application.

The form clears, and a new application card appears below with the company name, role, and a blue "APPLIED" status badge.

Why does the list update without a page refresh?

When the Server Action calls revalidatePath("/"), it tells Next.js that the cached data for the homepage is stale. Next.js re-runs the Server Component query and streams the updated HTML to the browser. No manual refresh needed.

Form submits but no card appears?

Open your browser's DevTools console and check for errors. A common cause is a mismatch between the DATABASE_URL in .env and your actual Neon connection string.

Also confirm that your Neon project is not suspended. Visit the Neon console and check that compute is active.

help me debug why my form submission is not creating a new application

Your app can now create job applications and display them instantly. Next up, you'll wire up the status dropdown and delete button so you have full CRUD.

Add Update Status and Delete

You can now add job applications through your form. The next piece of the puzzle is letting you update an application's status as it progresses through the hiring pipeline.

In this step, you will wire up the interactive elements in your application card. Each card will get a status dropdown and a delete button that call your existing Server Actions directly.

In this step, get ready to:
  • Review the Server Actions for updating status and deleting applications.
  • Wire up the status dropdown to trigger the update action on change.
  • Wire up the delete button and verify the full CRUD cycle works locally.
Review the Server Actions for update and delete

Your actions.ts file already exports three Server Actions from the previous step. Two of them handle the update and delete operations you are about to connect to the UI.

  • Switch back to src/app/actions.ts in your editor.
  • Find the updateApplicationStatus and deleteApplication functions below createApplication:
export async function updateApplicationStatus(id: string, status: ApplicationStatus) {
  await prisma.jobApplication.update({
    where: { id },
    data: { status },
  });

  revalidatePath("/");
}

export async function deleteApplication(id: string) {
  await prisma.jobApplication.delete({
    where: { id },
  });

  revalidatePath("/");
}

What do these actions do?

updateApplicationStatus takes an application's id and a new status value, then calls Prisma's update method to change just that field in the database.

deleteApplication takes an id and removes the entire row from the database. Both actions call revalidatePath("/") afterwards to tell Next.js to re-render the page with fresh data.

Wire up the status dropdown

Each application card needs a dropdown that lets you change an application's status without leaving the page. When the learner selects a new status, the card calls updateApplicationStatus immediately.

  • Open src/components/application-card.tsx in your editor.
  • Inside the ApplicationCard function, find the <div className="flex items-center gap-2"> section near the bottom of the return statement.
  • Confirm the status dropdown select element matches this code:
<select
  value={application.status}
  onChange={(e) => updateApplicationStatus(application.id, e.target.value as ApplicationStatus)}
  className="text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
  <option value="APPLIED">Applied</option>
  <option value="INTERVIEWING">Interviewing</option>
  <option value="OFFER">Offer</option>
  <option value="REJECTED">Rejected</option>
</select>

What does this code do?

value={application.status} makes the dropdown reflect the current status stored in the database. This is a controlled component pattern in React.

onChange fires every time the learner picks a new option. It calls your Server Action with the application's id and the new status value, casting it to the ApplicationStatus enum type so TypeScript stays happy.

Wire up the delete button and verify CRUD

The last interactive piece is a delete button that removes an application permanently. After that, you will test the entire create-read-update-delete cycle.

  • Directly below the </select> closing tag, confirm the delete button matches this code:
<button
  onClick={() => deleteApplication(application.id)}
  className="text-red-500 hover:text-red-700 text-sm font-medium"
>
  Delete
</button>

What does this code do?

onClick calls deleteApplication with just the application's id. The Server Action deletes the row from the database and revalidates the page, which removes the card from the list instantly.

  • Save application-card.tsx.
  • Return to your browser at http://localhost:3000.
  • Add a test application using the form (for example, company Acme Corp and role Frontend Engineer).
  • Use the status dropdown on the new card to change the status to Interviewing. The badge color should change from blue to yellow.
  • Click the Delete button. The card should disappear from the list.

You have now confirmed the full CRUD cycle: create, read, update, and delete all work end-to-end.

Status not updating or delete not working?

Make sure application-card.tsx starts with "use client" at the very top. Without this directive, event handlers like onChange and onClick will not run in the browser.

Check that the import at the top of the file reads import { updateApplicationStatus, deleteApplication } from "@/app/actions". A missing import causes a runtime error.

If the status changes in the dropdown but snaps back, your Neon database connection may have timed out. Refresh the page to reconnect.

help me debug why my status update or delete isn't working

✔️ Awesome, I've got everything!

Great. Double check you have saved application-card.tsx and confirmed all four CRUD operations work in the browser.

ⓧ I'd like to double check the full code

Here is the complete src/components/application-card.tsx file:

"use client";

import { updateApplicationStatus, deleteApplication } from "@/app/actions";
import { ApplicationStatus } from "../generated/prisma";

interface JobApplication {
  id: string;
  company: string;
  role: string;
  status: ApplicationStatus;
  dateApplied: Date;
  notes: string | null;
  url: string | null;
}

const statusColors: Record<ApplicationStatus, string> = {
  APPLIED: "bg-blue-100 text-blue-800",
  INTERVIEWING: "bg-yellow-100 text-yellow-800",
  OFFER: "bg-green-100 text-green-800",
  REJECTED: "bg-red-100 text-red-800",
};

export function ApplicationCard({ application }: { application: JobApplication }) {
  return (
    <div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex items-center justify-between gap-4">
      <div className="flex-1 min-w-0">
        <div className="flex items-center gap-2">
          <h3 className="font-semibold text-gray-900 truncate">{application.company}</h3>
          <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[application.status]}`}>
            {application.status}
          </span>
        </div>
        <p className="text-sm text-gray-600">{application.role}</p>
        <p className="text-xs text-gray-400 mt-1">
          Applied {new Date(application.dateApplied).toLocaleDateString()}
        </p>
        {application.notes && (
          <p className="text-xs text-gray-500 mt-1 truncate">{application.notes}</p>
        )}
      </div>

      <div className="flex items-center gap-2">
        <select
          value={application.status}
          onChange={(e) => updateApplicationStatus(application.id, e.target.value as ApplicationStatus)}
          className="text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
        >
          <option value="APPLIED">Applied</option>
          <option value="INTERVIEWING">Interviewing</option>
          <option value="OFFER">Offer</option>
          <option value="REJECTED">Rejected</option>
        </select>

        <button
          onClick={() => deleteApplication(application.id)}
          className="text-red-500 hover:text-red-700 text-sm font-medium"
        >
          Delete
        </button>
      </div>
    </div>
  );
}

Your job tracker now supports the full lifecycle of an application. Next up, you will deploy it to Vercel so you have a live URL to share.

Deploy to Vercel

Your job tracker works perfectly on your local machine, but no one else can see it yet. Deployment makes your app accessible to anyone with a URL.

In this step, you will push your code to GitHub, then import the repository into Vercel for automatic deployment. Vercel detects your Next.js project and builds it for you.

In this step, get ready to:
  • Push your project to a GitHub repository.
  • Import the repository in Vercel and configure your database connection.
  • Verify the live deployment works with real data.
Push the project to a GitHub repository

Your project needs to live in a Git repository on GitHub so Vercel can access the code and build it. The .gitignore file created by create-next-app already excludes .env files, so your database credentials stay safe.

  • Open your browser and go to github.com.
  • Sign in to your GitHub account (or create one if you do not have one yet).
  • Click the + icon in the top-right corner, then select New repository.
  • Enter job-tracker as the repository name.
  • Leave all other settings as default (do not initialize with a README, .gitignore, or license).
  • Click Create repository.

GitHub shows a page with instructions for pushing an existing repository. You will use those commands next.

  • Return to your terminal in the job-tracker project folder.
  • Stage and commit all your changes by running these commands:
git add .
git commit -m "Add job tracker application"
  • Connect your local repository to GitHub and push by running these commands:
git remote add origin [[REPO_URL="https://github.com/YOUR_USERNAME/job-tracker.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin links your local project to the GitHub repository you just created.
  • git branch -M main renames your branch to main (the default branch Vercel looks for).
  • git push -u origin main uploads your code to GitHub.
  • Refresh your GitHub repository page in the browser.
  • You should see all your project files listed (src/, prisma/, package.json).

Push rejected or authentication failed?

If you see an authentication error, GitHub requires a Personal Access Token or SSH key for command-line access. Go to GitHub Settings, then Developer settings, then Personal access tokens to generate one.

If the push is rejected because the remote already has commits, make sure you did not check the "Initialize with README" box when creating the repository.

help me push my code to GitHub

Import the repository in Vercel and deploy

Vercel connects directly to your GitHub repository. Every time you push new code, Vercel automatically rebuilds and redeploys your app.

  • Open vercel.com in your browser.
  • Click Sign Up and choose Continue with GitHub to create your free Vercel account (or sign in if you already have one).
  • Once on your dashboard, click Add New... and select Project.
  • In the Import Git Repository section, find your job-tracker repository and click Import.

Don't see your repository?

If your repository is not listed, Vercel may not have permission to access it. Click Configure GitHub App and grant Vercel access to the job-tracker repository (or all repositories).

Vercel auto-detects your project as Next.js and shows the configuration page. Before deploying, you need to add your database connection string as an environment variable.

  • On the configuration page, expand the Environment Variables section.
  • In the Key field, enter DATABASE_URL.
  • In the Value field, paste your Neon connection string (the same value from your local .env file).
  • Click Deploy.

Why add DATABASE_URL here?

Your .env file is gitignored, so your database credentials never reach GitHub. Vercel's environment variables securely inject the value at build time and runtime without exposing it in your source code.

Vercel starts building your project. The build takes about 1-2 minutes. You can watch the build logs in real time on the deployment page.

Build failing?

If the build fails with a Prisma-related error, make sure your DATABASE_URL environment variable is set correctly in Vercel's project settings. The connection string must start with postgresql:// and include ?sslmode=require at the end.

help me fix my Vercel deployment build error

Verify the live deployment

Once the build succeeds, Vercel shows a success screen with a link to your live app. This is the URL you can share with anyone.

  • Click the production URL that Vercel displays (it looks like https://job-tracker-xxx.vercel.app).
  • Your Job Application Tracker should load with the same interface you saw locally.
  • Add a test application using the form: enter a company name, role, and status, then click Add Application.
  • Refresh the page to confirm the application persists (the data is stored in your Neon database, not in the browser).
  • Try changing the status using the dropdown and deleting the entry to confirm all CRUD operations work.

Why does data persist across refreshes?

Your Server Components query the Neon database on every request. When you add or update an application, the Server Action writes to the database and calls revalidatePath to refresh the page with fresh data.

Your full-stack job tracker is live on the internet. Anyone with the URL can see it, and every code change you push to GitHub triggers an automatic redeployment. Next up, you will level up the app with search and filtering in the Secret Mission.

Secret mission

Add Filtering and Search

Your tracker works, but once you have dozens of applications, finding the one you want becomes painful. Can you add status filtering and company search so the list stays manageable?

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. Both Neon and Vercel offer generous free tiers, so there are no ongoing charges for this project.

Resources you used:

  • Neon PostgreSQL database (free tier: 0.5 GB storage, 100 compute-hours/month).
  • Vercel deployment (free Hobby tier).
  • GitHub repository for your project source code.
  • Local project files in the job-tracker folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your app live as a portfolio piece.

  • Your Neon database stays on the permanent free plan (no credit card required). Compute scales to zero after 5 minutes of inactivity automatically.
  • Your Vercel deployment stays live indefinitely on the free Hobby tier.
  • Your GitHub repository remains accessible in your account.
  • Your local job-tracker folder stays on your Desktop.

Pause - I'll come back to this later

Free up local resources while keeping your cloud services intact. Neon already pauses automatically after 5 minutes of inactivity, so no cloud action is needed.

  • Stop the local development server if it is still running by pressing Ctrl+C in your terminal.
  • Close your editor to free up memory.
  • Your Neon database and Vercel deployment remain live on their free tiers. No further action required.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean up completely.

  • Delete your Neon project:
  • Open the Neon Console at console.neon.tech.
  • Select the project you created for this tutorial.
  • Click Settings in the sidebar.
  • Click Delete and confirm. This permanently removes the database, branches, and all stored data.
  • Delete your Vercel project:
  • Open your Vercel dashboard at vercel.com/dashboard.
  • Select the project you deployed.
  • Click Settings in the sidebar.
  • Scroll to the bottom of the General page and click Delete in the Delete Project section.
  • Enter the project name to confirm, then click Continue.
  • Delete your GitHub repository:
  • Go to your repository on GitHub.
  • Click Settings (repository settings, not account settings).
  • Scroll to the bottom and click Delete this repository. Confirm by typing the repository name.
  • Delete your local project files:

macOS

  • Remove the project folder by running:
cd ~/Desktop
rm -rf job-tracker

Windows

  • Remove the project folder by running:
cd ~\Desktop
rmdir /s /q job-tracker

Nice Work!

Nice Work!

You just built and deployed a full-stack job application tracker from scratch. You went from an empty folder to a live, database-backed application with a shareable URL.

Here's what you accomplished:

  • Built a Next.js 16 App Router application with TypeScript and Tailwind CSS, using Server Components to query the database directly and Server Actions for full CRUD operations.
  • Designed a database schema with Prisma ORM and connected it to a serverless Neon PostgreSQL database with type-safe queries.
  • Deployed a live, shareable application to Vercel with automatic CI/CD from your GitHub repository.
  • Secret Mission: Added status filtering and company search using URL search params, making your filtered views shareable.

Ready to quiz yourself?