Build a Job Tracker with PERN Stack
Build a full-stack job application tracker with PostgreSQL, Express, React, and Node.js.
Introduction
30 Second Summary
Job searching means juggling dozens of applications across companies, stages, and dates. Spreadsheets get messy fast, and you lose track of where you stand.
In this project, you will build a full-stack Job Application Tracker using PostgreSQL, Express, React, and Node.js (the PERN stack). You will create, read, update, and delete applications through a clean UI backed by a real database.
What You'll Build
You open the app, add a new job application, change its status to "Interview," and watch the card update instantly with no page refresh needed.
By the end of this project, you'll have:
- A React frontend that fetches and displays your job applications as color-coded cards pulled straight from a PostgreSQL database.
- A REST API built with Express that handles full CRUD operations you can test independently with curl or your browser.
- A refetch-after-mutation pattern that keeps your UI perfectly in sync with your database after every create, update, or delete.
- Secret Mission: Add filtering by status and sorting by date to tame a growing list of applications.
Are there any prerequisites?
You need Node.js and PostgreSQL installed locally, a GitHub account, and Cursor as your code editor. Step 1 walks you through confirming everything is ready.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to commit to the goal helps you stay oriented as you move through the full-stack pieces ahead.
Set Up Both Projects and the Database
A full-stack app has three moving parts: a frontend the user sees, a backend API that handles logic, and a database that stores data. If any one of these is missing, nothing works end-to-end.
In this step you will scaffold a React frontend, set up an Express backend, and create a PostgreSQL database with sample data. By the end, all three pieces will be running and talking to each other.
In this step, get ready to:
- Scaffold a React frontend and Express backend in separate directories.
- Create a PostgreSQL database with an applications table and sample data.
- Connect Express to PostgreSQL and verify the connection with a test route.
Create the project structure and scaffold React
You need a top-level project folder with two subdirectories: client/ for the React app and server/ for the Express API.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on both macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir job-tracker
cd job-tracker
- Scaffold the React frontend using Vite by running this command:
npm create vite@latest client -- --template react --no-interactive
What does this command do?
This tells Vite to create a new React project inside a folder called client. The --template react flag selects the JavaScript React starter. The --no-interactive flag prevents the CLI from asking follow-up questions.
- Move into the client folder and install its dependencies by running:
cd client
npm install
- Start the Vite dev server to confirm the scaffold worked:
npm run dev
- Open http://localhost:5173 in your browser. You should see the Vite + React starter page with a spinning logo and a counter button.
- Return to your terminal and press Ctrl+C to stop the dev server.
Don't see the starter page?
Make sure you ran npm install inside the client folder before npm run dev. If the port is already in use, Vite will print an alternative URL in the terminal output.
Vite requires Node.js 20.19+ or 22.12+. Run node --version to check yours.
help me troubleshoot why the Vite dev server isn't starting
- Navigate back to the project root by running:
cd ..
Now you will set up the server side.
- Create the server directory and move into it by running:
mkdir server
cd server
- Create server/package.json by clicking File > New File in Cursor. Name it package.json and save it inside the server folder.
- Paste the following into package.json:
{
"name": "job-tracker-server",
"version": "1.0.0",
"description": "Job Application Tracker API",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node index.js"
},
"dependencies": {
"cors": "2.8.6",
"dotenv": "16.5.0",
"express": "5.2.1",
"pg": "8.22.0"
}
}
What does this file do?
This file tells Node.js what your project is called, what packages it needs, and how to run it. The four dependencies are express (web server), pg (PostgreSQL driver), cors (cross-origin requests), and dotenv (environment variables).
- Install the dependencies by running this in the terminal (make sure you are inside the server folder):
npm install
- Confirm the install worked by running:
ls
You should see node_modules, package.json, and package-lock.json listed.
Next you will create the Express entry file.
- In Cursor, create a new file called index.js inside the server folder.
- Paste the following code into server/index.js:
const express = require("express");
const cors = require("cors");
require("dotenv").config();
const app = express();
const PORT = process.env.PORT || 5000;
// Enable cross-origin requests and JSON body parsing
app.use(cors());
app.use(express.json());
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
This creates a minimal Express server. It loads environment variables from a .env file (which you will create soon), enables CORS so the React frontend can talk to it, and starts listening on port 5000.
- Save index.js and start the server by running:
node index.js
You should see Server running on port 5000 printed in the terminal.
- Press Ctrl+C to stop the server for now.
Seeing an error about a missing module?
If you see Cannot find module 'express', you likely ran node index.js from outside the server folder. Run pwd to check your current directory and cd server if needed.
help me fix a missing module error when starting my Express server
Create the PostgreSQL database and table
Your app needs somewhere to store job applications. You will create a database called job_tracker with an applications table, then seed it with sample rows so you have data to work with immediately.
- Open a PostgreSQL session by running this in your terminal:
psql -U postgres
What is psql?
psql is the built-in command-line client for PostgreSQL. It connects you directly to your database so you can run SQL. The -U postgres flag logs you in as the default superuser.
Connection refused or password prompt?
If PostgreSQL is not running, start it first. On macOS with Homebrew, run brew services start postgresql. On Windows, check that the PostgreSQL service is running in Services.
If prompted for a password, enter the password you set when you installed PostgreSQL.
help me connect to PostgreSQL with psql
- Create the database by running this SQL command inside psql:
CREATE DATABASE job_tracker;
- Connect to the new database:
\c job_tracker
You should see a message like You are now connected to database "job_tracker".
- Create the applications table by running:
CREATE TABLE applications (
id SERIAL PRIMARY KEY,
company VARCHAR(255),
position VARCHAR(255),
status VARCHAR(50) DEFAULT 'Applied',
date_applied DATE DEFAULT CURRENT_DATE,
notes TEXT
);
What does this table store?
Each row represents one job application. The id auto-increments so every new entry gets a unique number. The status defaults to 'Applied' and date_applied defaults to today's date, so you only need to provide a company name and position to create a record.
- Seed the table with 3 sample applications by running:
INSERT INTO applications (company, position, status, notes)
VALUES
('Google', 'Frontend Engineer', 'Applied', 'Referral from a friend'),
('Stripe', 'Full Stack Developer', 'Interview', 'Phone screen scheduled'),
('Vercel', 'Software Engineer', 'Applied', 'Applied through website');
- Verify the data exists by running:
SELECT * FROM applications;
You should see 3 rows with the companies Google, Stripe, and Vercel.
- Exit psql by typing:
\q
Connect Express to PostgreSQL
The Express server needs a way to talk to your new database. You will create a connection pool configuration and then prove it works with a test route.
- In Cursor, create a new file called .env inside the server folder.
- Paste the following into server/.env:
PG_USER=postgres
PG_PASSWORD=your-password-here
PG_HOST=localhost
PG_PORT=5432
PG_DATABASE=job_tracker
PORT=5000
- Replace your-password-here with the PostgreSQL password you used when you connected via psql.
Why use a .env file?
Storing credentials in a .env file keeps them out of your code. The dotenv package reads these values and loads them into process.env at runtime.
- Create a new file called db.js inside the server folder.
- Paste the following into server/db.js:
const { Pool } = require("pg");
require("dotenv").config();
// Create a connection pool using environment variables
const pool = new Pool({
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
host: process.env.PG_HOST,
port: process.env.PG_PORT,
database: process.env.PG_DATABASE,
});
module.exports = pool;
What is a connection pool?
A pool keeps a set of database connections open and ready to use. Instead of opening a new connection for every request (slow), your server reuses connections from the pool. The pg library manages this automatically.
Now you need to add a test route to index.js that queries the database.
- In server/index.js, find the app.listen line.
- Add the following code directly above app.listen:
app.get("/api/test", async (req, res) => {
const pool = require("./db");
const result = await pool.query("SELECT NOW()");
res.json(result.rows[0]);
});
What does this route do?
When someone visits /api/test, the server runs a simple SELECT NOW() query against PostgreSQL. If the database connection works, you get back the current timestamp as JSON.
- Save index.js.
Before you run the server, what do you think you will see if you visit http://localhost:5000/api/test in your browser?
- Start the server by running:
node index.js
- Open http://localhost:5000/api/test in your browser.
You should see a JSON object containing the current timestamp, something like {"now":"2026-07-28T..."}. This confirms Express is successfully querying PostgreSQL.
Seeing a connection error?
If the browser shows a connection error or the terminal prints a database error, double-check the values in your .env file. The PG_PASSWORD must match the password your PostgreSQL installation uses.
Make sure PostgreSQL is still running. On macOS run brew services list to check.
help me fix my Express to PostgreSQL connection
- Keep the server running for now. You will use it in the next step.
✔️ Awesome, I've got everything!
Great. Make sure server/index.js, server/db.js, and server/.env are all saved.
ⓧ I'd like to double check the full code
Here is server/index.js in its final state for this step:
const express = require("express");
const cors = require("cors");
require("dotenv").config();
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
app.get("/api/test", async (req, res) => {
const pool = require("./db");
const result = await pool.query("SELECT NOW()");
res.json(result.rows[0]);
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Here is server/db.js:
const { Pool } = require("pg");
require("dotenv").config();
const pool = new Pool({
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
host: process.env.PG_HOST,
port: process.env.PG_PORT,
database: process.env.PG_DATABASE,
});
module.exports = pool;
Here is server/.env:
PG_USER=postgres
PG_PASSWORD=your-password-here
PG_HOST=localhost
PG_PORT=5432
PG_DATABASE=job_tracker
PORT=5000
Your full-stack foundation is in place. Next up, you will build the REST API that the React frontend will consume to create, read, update, and delete job applications.
Build the REST API
Your database is seeded, your server is running, and your connection pool is ready. Now it is time to build the API that your React frontend will talk to.
A REST API organizes your backend into predictable endpoints. Each endpoint handles one operation on your data. In this step, you will build five endpoints that cover every action your tracker needs: listing, viewing, creating, updating, and deleting applications.
In this step, get ready to:
- Create a routes file with endpoints for reading, creating, updating, and deleting applications.
- Mount the router in your Express server so the endpoints become reachable.
- Test every endpoint to confirm data flows correctly between the API and PostgreSQL.
Create the routes file and list all applications
Express Router lets you group related endpoints into their own file. This keeps your main index.js clean as your API grows.
- Create a new folder called routes inside your server directory, then create applications.js inside it by running:
mkdir server/routes
touch server/routes/applications.js
- Confirm the file exists by running:
ls server/routes
You should see applications.js in the output.
- Open server/routes/applications.js in Cursor and add the first route that returns all applications:
const express = require("express");
const router = express.Router();
const pool = require("../db");
// GET all applications, newest first
router.get("/", async (req, res) => {
try {
const result = await pool.query(
"SELECT * FROM applications ORDER BY date_applied DESC"
);
res.json(result.rows);
} catch (err) {
console.error(err.message);
res.status(500).json({ error: "Server error" });
}
});
module.exports = router;
What does this code do?
This sets up a modular router that handles requests relative to whatever path you mount it on later.
- express.Router() creates an isolated set of routes you can export as a module.
- pool.query() sends a SQL query to your PostgreSQL database via the connection pool from db.js.
- The try/catch block returns a 500 status if the database query fails, so the server never crashes silently.
Before you can test this endpoint, the server needs to know it exists. You will mount the router in index.js now so you can see results immediately.
- In server/index.js, find the line:
require("dotenv").config();
- Add this line directly below it to import the router:
const applicationsRouter = require("./routes/applications");
- Now find the line:
app.use(express.json());
- Add this line directly below it to mount the router at the /api/applications path:
app.use("/api/applications", applicationsRouter);
What does mounting do?
app.use("/api/applications", applicationsRouter) tells Express to forward any request that starts with /api/applications to your router. The router's GET / handler receives requests at GET /api/applications because the base path is prepended automatically.
The cors() and express.json() middleware you added in Step 1 already apply globally, so the new router benefits from them too.
- Save both files.
- Switch back to the terminal where your server is running. Stop it with Ctrl+C and restart it by running:
node index.js
Before you check the results, what do you think will happen when you visit http://localhost:5000/api/applications in your browser?
- Open http://localhost:5000/api/applications in your browser.
You should see a JSON array containing your 3 seeded applications, ordered by date with the newest first.
Seeing an empty array or an error?
- If you see [], check that you inserted sample rows in Step 1. Run SELECT * FROM applications; in psql to confirm data exists.
- If you see a "Cannot GET" message, verify you saved index.js after adding the app.use line and restarted the server.
- If you see a connection error, make sure PostgreSQL is running and your .env credentials are correct.
help me debug my GET /api/applications endpoint
- Now add the route to get a single application by ID. In server/routes/applications.js, find the module.exports = router; line and add this code above it:
// GET a single application by ID
router.get("/:id", async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
"SELECT * FROM applications WHERE id = $1",
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: "Application not found" });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err.message);
res.status(500).json({ error: "Server error" });
}
});
What is a parameterized query?
The $1 in the SQL string is a placeholder. The actual value (id) is passed in the array as the second argument to pool.query(). This prevents SQL injection because PostgreSQL treats the value as data, never as executable SQL.
- Save server/routes/applications.js.
- Restart your server (Ctrl+C, then node index.js).
- Open http://localhost:5000/api/applications/1 in your browser.
You should see a single JSON object for the application with id: 1.
Add create, update, and delete endpoints
The read endpoints confirm your router works. Now you will add the endpoints that let users create new applications, update existing ones, and remove them.
- In server/routes/applications.js, find the module.exports = router; line and add the POST route above it:
// POST a new application
router.post("/", async (req, res) => {
try {
const { company, position, notes } = req.body;
const result = await pool.query(
"INSERT INTO applications (company, position, notes) VALUES ($1, $2, $3) RETURNING *",
[company, position, notes]
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error(err.message);
res.status(500).json({ error: "Server error" });
}
});
What does RETURNING * do?
RETURNING * is a PostgreSQL feature that sends back the newly created row immediately after inserting it. This saves a second query and lets the API return the full application object (including the auto-generated id and date_applied) to the client.
- Save the file and restart your server.
- Test the POST endpoint by running this curl command in a new terminal tab:
curl -X POST http://localhost:5000/api/applications \
-H "Content-Type: application/json" \
-d '{"company": "NextWork", "position": "Frontend Engineer", "notes": "Applied via website"}'
You should see a JSON response with your new application, including an auto-generated id and today's date in date_applied.
Seeing "Cannot POST" or no response?
- Make sure you restarted the server after saving the file.
- Check that the -H "Content-Type: application/json" header is included. Without it, Express cannot parse the JSON body.
help me debug my POST /api/applications curl request
- Now add the PUT route for updating applications. Find module.exports = router; again and add this above it:
// PUT (update) an application by ID
router.put("/:id", async (req, res) => {
try {
const { id } = req.params;
const { company, position, status, notes } = req.body;
const result = await pool.query(
"UPDATE applications SET company = $1, position = $2, status = $3, notes = $4 WHERE id = $5 RETURNING *",
[company, position, status, notes, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: "Application not found" });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err.message);
res.status(500).json({ error: "Server error" });
}
});
Why does PUT accept all fields?
The PUT endpoint accepts company, position, status, and notes. This lets the frontend update any combination of fields in a single request. The status field is included here (but not in POST) because new applications always start as "Applied" by default.
- Finally, add the DELETE route above module.exports = router;:
// DELETE an application by ID
router.delete("/:id", async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
"DELETE FROM applications WHERE id = $1 RETURNING *",
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: "Application not found" });
}
res.json({ message: "Application deleted" });
} catch (err) {
console.error(err.message);
res.status(500).json({ error: "Server error" });
}
});
- Save server/routes/applications.js and restart your server.
Before you test, what do you expect to see if you try to delete an application that does not exist (for example, ID 999)?
- Test the DELETE endpoint by running:
curl -X DELETE http://localhost:5000/api/applications/999
You should see {"error":"Application not found"} with a 404 status. The route correctly rejects IDs that do not exist in the database.
- Now test with a real ID by visiting http://localhost:5000/api/applications in your browser to confirm all your applications (including the one you added via POST) are returned as JSON.
You should see 4 applications: your 3 original seeds plus the "NextWork" entry you created with curl.
Missing the new application in the list?
- If you only see 3 results, your POST request may have failed silently. Run SELECT * FROM applications; in psql to check if the row was actually inserted.
- Make sure you restarted the server after adding the POST route. Routes added after the server started are not available until restart.
help me verify my CRUD endpoints are all working
✔️ Awesome, I've got everything!
Great. Double check you have saved both server/routes/applications.js and server/index.js.
ⓧ I'd like to double check the full code
Here is the complete server/index.js:
const express = require("express");
const cors = require("cors");
require("dotenv").config();
const applicationsRouter = require("./routes/applications");
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
app.use("/api/applications", applicationsRouter);
app.get("/api/test", async (req, res) => {
const pool = require("./db");
const result = await pool.query("SELECT NOW()");
res.json(result.rows[0]);
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
For server/routes/applications.js, confirm you have the following sections in order (scroll up and check each one is present):
- The require imports for express, router, and pool at the top.
- GET / route (list all applications, ordered by date DESC).
- GET /:id route (get single application).
- POST / route (create new application).
- PUT /:id route (update application).
- DELETE /:id route (delete application).
- module.exports = router; at the very end.
Your API is complete and tested. In the next step, you will connect your React frontend to these endpoints and see real database data rendered in the browser.
Display Applications in React
Your Express API is tested and returning real data from PostgreSQL. Now it's time for the full-stack payoff: seeing that database data rendered live in your browser.
In this step, you'll build React components that fetch from your API and display each application as a color-coded card.
In this step, get ready to:
- Create ApplicationCard and ApplicationList components.
- Connect App.jsx to your Express API with useState and useEffect.
- Style the cards with status-based color coding.
Create the component files
React apps are built from small, reusable components. You'll create two: one that renders a single application card, and one that renders the full list.
- In your terminal, make sure you're inside the client folder, then create a components directory by running:
mkdir src/components
- Create src/components/ApplicationCard.jsx with the following code:
function ApplicationCard({ application }) {
// Derive a CSS class from the status (e.g. "applied", "interview")
const statusClass = application.status.toLowerCase();
return (
<div className={`card ${statusClass}`}>
<h3>{application.company}</h3>
<p className="position">{application.position}</p>
<p className="date">Applied: {new Date(application.date_applied).toLocaleDateString()}</p>
{application.notes && <p className="notes">{application.notes}</p>}
<p className="status">{application.status}</p>
</div>
);
}
export default ApplicationCard;
What does this component do?
- It destructures the application prop to access company, position, status, and date.
- The statusClass variable converts the status to lowercase so it matches CSS class names like .applied or .interview.
- The conditional {application.notes && ...} only renders the notes paragraph if notes exist.
- Save ApplicationCard.jsx.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing.
ⓧ I'd like to double check the full code
function ApplicationCard({ application }) {
const statusClass = application.status.toLowerCase();
return (
<div className={`card ${statusClass}`}>
<h3>{application.company}</h3>
<p className="position">{application.position}</p>
<p className="date">Applied: {new Date(application.date_applied).toLocaleDateString()}</p>
{application.notes && <p className="notes">{application.notes}</p>}
<p className="status">{application.status}</p>
</div>
);
}
export default ApplicationCard;
- Create src/components/ApplicationList.jsx with the following code:
import ApplicationCard from "./ApplicationCard";
function ApplicationList({ applications, onMutate }) {
// Show a message if the list is empty
if (applications.length === 0) {
return <p className="empty">No applications yet. Add one above!</p>;
}
return (
<div className="application-list">
{applications.map((app) => (
<ApplicationCard key={app.id} application={app} onMutate={onMutate} />
))}
</div>
);
}
export default ApplicationList;
What does this component do?
- It receives an applications array and an onMutate callback as props.
- If the array is empty, it renders a friendly message instead of an empty page.
- Otherwise it maps each application object into an ApplicationCard, using app.id as the React key for efficient re-renders.
- Save ApplicationList.jsx.
- Confirm both files exist by running:
ls src/components
You should see ApplicationCard.jsx and ApplicationList.jsx listed.
Don't see both files?
- Make sure you ran mkdir src/components from inside the client folder, not from the project root.
- Check that the filenames end in .jsx, not .js.
Why aren't my component files showing up?
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing.
ⓧ I'd like to double check the full code
import ApplicationCard from "./ApplicationCard";
function ApplicationList({ applications, onMutate }) {
if (applications.length === 0) {
return <p className="empty">No applications yet. Add one above!</p>;
}
return (
<div className="application-list">
{applications.map((app) => (
<ApplicationCard key={app.id} application={app} onMutate={onMutate} />
))}
</div>
);
}
export default ApplicationList;
Fetch data and style the cards
Your components are ready but nothing is using them yet. Now you'll rewrite App.jsx to fetch data from your Express API and pass it down to ApplicationList.
- Open client/src/App.jsx and delete all the existing Vite boilerplate.
- Replace the entire file content with the imports, state, and fetch logic:
import { useState, useEffect } from "react";
import ApplicationList from "./components/ApplicationList";
import "./App.css";
function App() {
// State for the applications array, loading flag, and error message
const [applications, setApplications] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch all applications from the Express API
const fetchApplications = async () => {
try {
setError(null);
const response = await fetch("http://localhost:5000/api/applications");
if (!response.ok) throw new Error("Failed to fetch applications");
const data = await response.json();
setApplications(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Run fetchApplications once when the component mounts
useEffect(() => {
fetchApplications();
}, []);
What does this code do?
- Three pieces of state track the applications array, a loading boolean, and any error message.
- The fetchApplications function calls your Express API, parses the JSON response, and stores it in state.
- The useEffect with an empty dependency array [] ensures the fetch runs exactly once when the page loads.
- Directly below the useEffect (still inside the App function), add the render logic:
// Show loading or error states before rendering the list
if (loading) return <p className="loading">Loading applications...</p>;
if (error) return <p className="error">Error: {error}</p>;
return (
<div className="app">
<h1>Job Application Tracker</h1>
<ApplicationList applications={applications} onMutate={fetchApplications} />
</div>
);
}
export default App;
What does the render logic do?
- The two early returns handle loading and error states so the user always sees feedback.
- Once data is loaded, it renders the heading and passes the applications array to ApplicationList.
- The onMutate prop passes fetchApplications down so child components can trigger a data refresh later.
- Save App.jsx.
- Make sure your Express server is still running in its terminal (node index.js in the server folder).
- Switch to your Vite dev server terminal and confirm it's still running at http://localhost:5173.
- Open http://localhost:5173 in your browser.
You should see your 3 seeded applications rendered as text on the page. They won't look styled yet, but the company names, positions, and dates should all be visible.
Seeing a blank page or error?
- Check your browser's developer console for errors. A common issue is a typo in the import path (./components/ApplicationList must match the folder and file name exactly).
- If you see a CORS error, make sure your Express server has app.use(cors()) before the route definitions.
- If it shows "Loading applications..." forever, confirm your Express server is running on port 5000.
Why aren't my applications showing in the browser?
✔️ Awesome, I've got everything!
Great. Double check you've saved App.jsx before continuing to the CSS.
ⓧ I'd like to double check the full code
Your App.jsx should contain both chunks above in order: the imports/state/fetch logic at the top, then the render logic and export at the bottom. Scroll up and confirm both code blocks are present, with the useEffect closing bracket immediately before the if (loading) line.
Your data is flowing through the full stack. Now let's make it look good with CSS.
- Open client/src/App.css and delete all the existing Vite styles.
- Replace the file content with these global layout styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f5f5f5;
color: #333;
}
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
text-align: center;
margin-bottom: 2rem;
color: #1a1a2e;
}
.application-list {
display: grid;
gap: 1rem;
}
What do these styles do?
- The global reset removes browser default margins and padding.
- The .app class centers the content at a max width of 800px.
- The .application-list uses CSS grid with a 1rem gap to space out cards vertically.
- Below the .application-list block, add the card and status styles:
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
border-left: 4px solid #ccc;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.card.applied { border-left-color: #3498db; }
.card.interview { border-left-color: #f39c12; }
.card.offer { border-left-color: #27ae60; }
.card.rejected { border-left-color: #e74c3c; }
.card h3 { margin-bottom: 0.25rem; }
.card .position { color: #555; margin-bottom: 0.25rem; }
.card .date { font-size: 0.85rem; color: #888; margin-bottom: 0.5rem; }
.card .notes { font-size: 0.85rem; color: #666; font-style: italic; margin-bottom: 0.5rem; }
.loading, .error, .empty {
text-align: center;
padding: 2rem;
color: #888;
}
.error { color: #e74c3c; }
What do these card styles do?
- Each card gets a white background, rounded corners, and a colored left border.
- The status classes (.card.applied, .card.interview, etc.) change the border color so you can identify status at a glance.
- The utility classes .loading and .error style the conditional messages from App.jsx.
- Save App.css.
Before you check the browser, what do you expect to see differently now that the CSS is in place?
- Refresh http://localhost:5173 in your browser.
You should see your 3 seeded applications displayed as white cards with colored left borders. Each card shows the company name, position, application date, and a status label.
Cards showing but no colors?
- Check that your seeded data uses exact status values like Applied (capital A). The component calls .toLowerCase() to match the CSS classes.
- Make sure both CSS code blocks are in App.css (the layout styles first, then the card styles below).
- Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to clear cached styles.
Why aren't my card styles showing up?
✔️ Awesome, I've got everything!
Great. You've connected the full pipeline from PostgreSQL through Express to React.
ⓧ I'd like to double check the full code
Your App.css should contain both CSS blocks from above in order: the global layout styles at the top, then the card and status styles below. Scroll up and confirm both are present in one continuous file.
You just completed the full-stack loop: data stored in PostgreSQL, served by Express, and rendered by React. Next up, you'll add the ability to create new applications and delete ones you no longer need.
Add New Applications and Delete
Your tracker displays data from PostgreSQL through React. Reading data is only half the story.
Now you need to let users create and remove applications. This is where you will hit a common React challenge: the UI falls out of sync with the database after a mutation.
In this step, get ready to:
- Create the AddApplication form component.
- Add a delete button to each ApplicationCard.
- Observe the stale-data problem firsthand and fix it with a callback.
Create the AddApplication form component
Your API already accepts POST requests to create new applications. You need a React form that collects company, position, and notes, then sends them to the backend.
- Create a new file at client/src/components/AddApplication.jsx and add the following code:
import { useState } from "react";
function AddApplication() {
const [company, setCompany] = useState("");
const [position, setPosition] = useState("");
const [notes, setNotes] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
if (!company || !position) return;
try {
const response = await fetch("http://localhost:5000/api/applications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ company, position, notes }),
});
if (!response.ok) throw new Error("Failed to add application");
// Clear the form after a successful submission
setCompany("");
setPosition("");
setNotes("");
} catch (err) {
console.error(err.message);
}
};
return (
<form className="add-form" onSubmit={handleSubmit}>
<input type="text" placeholder="Company" value={company} onChange={(e) => setCompany(e.target.value)} required />
<input type="text" placeholder="Position" value={position} onChange={(e) => setPosition(e.target.value)} required />
<input type="text" placeholder="Notes (optional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
<button type="submit">Add Application</button>
</form>
);
}
export default AddApplication;
What does this code do?
Three pieces of state (company, position, notes) track what the user types into the form inputs.
When the form is submitted, handleSubmit sends a POST request with the form data as JSON. If the request succeeds, it resets all three fields to empty strings so the form is ready for another entry.
The form needs styling to look tidy. The .add-form class makes it a horizontal row with spacing.
- Open client/src/App.css and add these styles directly below the h1 rule:
.add-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.add-form input {
flex: 1;
min-width: 150px;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9rem;
}
.add-form button {
padding: 0.75rem 1.5rem;
background: #1a1a2e;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.add-form button:hover { background: #16213e; }
What does this CSS do?
The form uses flexbox to lay out the inputs side by side. Each input grows to fill available space (flex: 1) and wraps to a new row on narrow screens.
Now wire the component into your app so it renders above the application list.
- In client/src/App.jsx, find this import line:
import ApplicationList from "./components/ApplicationList";
- Add this import directly below it:
import AddApplication from "./components/AddApplication";
- In the same file, find this line inside the return statement:
<h1>Job Application Tracker</h1>
- Add the following line directly below it:
<AddApplication />
- Save both App.jsx and App.css.
- Switch to your browser at http://localhost:5173. You should see a row of inputs (Company, Position, Notes) and an Add Application button above your existing cards.
Don't see the form?
Check that your import path matches ./components/AddApplication exactly. The filename is case-sensitive.
Make sure the Vite dev server is still running in your terminal. If it stopped, restart it with npm run dev from inside the client folder.
Help me debug my AddApplication import
Before you test the form, think about what will happen. You submit data, the API saves it to PostgreSQL, and then... what happens to the list on screen?
- Type Spotify in the Company field.
- Type Frontend Engineer in the Position field.
- Click Add Application.
The form clears. But the new application does NOT appear in your list below.
- Confirm the data actually reached the database by opening this URL in a new browser tab:
http://localhost:5000/api/applications
You should see your "Spotify" application in the JSON array. The data is in the database. The problem is that the React list on screen has no idea something changed.
Why doesn't the list update?
Your useEffect in App.jsx fetches applications once when the component mounts. After that, nothing tells React to fetch again.
The database has the new row, but the browser is showing stale state. If you manually refresh the page, the new application appears because useEffect runs again on mount. This stale-data gap is the problem you will solve shortly.
Add a delete button to each card
The same stale-data gap will show up with deletion. You will add a delete button and confirm the same out-of-sync behaviour before fixing both together.
- Replace the entire contents of client/src/components/ApplicationCard.jsx with this updated version that adds a delete handler:
function ApplicationCard({ application }) {
const statusClass = application.status.toLowerCase();
// Send a DELETE request to remove this application from the database
const handleDelete = async () => {
try {
const response = await fetch(
`http://localhost:5000/api/applications/${application.id}`,
{ method: "DELETE" }
);
if (!response.ok) throw new Error("Failed to delete");
} catch (err) {
console.error(err.message);
}
};
return (
<div className={`card ${statusClass}`}>
<h3>{application.company}</h3>
<p className="position">{application.position}</p>
<p className="date">Applied: {new Date(application.date_applied).toLocaleDateString()}</p>
{application.notes && <p className="notes">{application.notes}</p>}
<p className="status">{application.status}</p>
<div className="card-actions">
<button onClick={handleDelete} className="delete-btn">Delete</button>
</div>
</div>
);
}
export default ApplicationCard;
What does handleDelete do?
It sends a DELETE request to the API using the application's id in the URL. The server removes the row from PostgreSQL and returns a confirmation.
The delete button needs its own styling to stand out as a destructive action.
- In client/src/App.css, add these styles below the .card .notes rule:
.card-actions {
display: flex;
gap: 0.5rem;
}
.card-actions button {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.card-actions .delete-btn {
color: #e74c3c;
border-color: #e74c3c;
}
.card-actions .delete-btn:hover {
background: #e74c3c;
color: white;
}
- Save ApplicationCard.jsx and App.css.
- Refresh your browser. Each card should now show a red Delete button at the bottom.
Try clicking Delete on one of your seeded applications. The card stays on screen. Refresh the page manually and it disappears. Same stale-data problem.
Delete button not showing?
Confirm you saved ApplicationCard.jsx. Vite hot-reloads on save, but only if the file is written to disk.
Check that the .card-actions styles are inside App.css and not accidentally pasted inside another rule block.
Help me debug my delete button
Fix the stale UI with a refetch callback
Both creating and deleting suffer from the same root cause: nothing tells App.jsx to re-fetch the list after a change. The fix is to pass fetchApplications as a callback prop that each child calls after a successful mutation.
- In client/src/App.jsx, find this line you added earlier:
<AddApplication />
- Replace it with this version that passes the refetch callback:
<AddApplication onMutate={fetchApplications} />
Now update the AddApplication component to accept and call this prop.
- In client/src/components/AddApplication.jsx, find this line:
function AddApplication() {
- Replace it with:
function AddApplication({ onMutate }) {
- In the same file, find the line that clears the notes state inside the try block:
setNotes("");
- Add this line directly below it to trigger the refetch:
onMutate();
Now do the same for the delete handler in ApplicationCard.
- In client/src/components/ApplicationCard.jsx, find this line:
function ApplicationCard({ application }) {
- Replace it with:
function ApplicationCard({ application, onMutate }) {
- Find the line inside handleDelete that checks the response:
if (!response.ok) throw new Error("Failed to delete");
- Add this line directly below it:
onMutate();
- Finally, add this import at the very top of ApplicationCard.jsx (you will need it in the next step):
import { useState } from "react";
- Save all three files: App.jsx, AddApplication.jsx, and ApplicationCard.jsx.
What is the onMutate pattern?
The parent component (App) owns the data and the function that fetches it. Children that change data call onMutate() after a successful request, which triggers the parent to re-fetch and re-render with fresh data.
This is called the "refetch after mutation" pattern. It keeps the UI in sync with the database without duplicating fetch logic across components.
Do you think adding a new application will now appear in the list immediately, or will you still need to refresh?
- In your browser, type Netflix in the Company field and Data Engineer in the Position field.
- Click Add Application.
The new card appears in your list instantly. No page refresh needed.
- Now click the Delete button on any card. It disappears immediately from the list.
List still not updating after add or delete?
Make sure you added onMutate() inside the try block AFTER the success check in both components. If it is in the catch block, it only runs on errors.
Confirm that App.jsx passes onMutate={fetchApplications} to both <AddApplication> and <ApplicationList>.
Help me debug the onMutate refetch pattern
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full AddApplication.jsx:
import { useState } from "react";
function AddApplication({ onMutate }) {
const [company, setCompany] = useState("");
const [position, setPosition] = useState("");
const [notes, setNotes] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
if (!company || !position) return;
try {
const response = await fetch("http://localhost:5000/api/applications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ company, position, notes }),
});
if (!response.ok) throw new Error("Failed to add application");
setCompany("");
setPosition("");
setNotes("");
onMutate();
} catch (err) {
console.error(err.message);
}
};
return (
<form className="add-form" onSubmit={handleSubmit}>
<input type="text" placeholder="Company" value={company} onChange={(e) => setCompany(e.target.value)} required />
<input type="text" placeholder="Position" value={position} onChange={(e) => setPosition(e.target.value)} required />
<input type="text" placeholder="Notes (optional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
<button type="submit">Add Application</button>
</form>
);
}
export default AddApplication;
Here is the full ApplicationCard.jsx:
import { useState } from "react";
function ApplicationCard({ application, onMutate }) {
const statusClass = application.status.toLowerCase();
const handleDelete = async () => {
try {
const response = await fetch(
`http://localhost:5000/api/applications/${application.id}`,
{ method: "DELETE" }
);
if (!response.ok) throw new Error("Failed to delete");
onMutate();
} catch (err) {
console.error(err.message);
}
};
return (
<div className={`card ${statusClass}`}>
<h3>{application.company}</h3>
<p className="position">{application.position}</p>
<p className="date">Applied: {new Date(application.date_applied).toLocaleDateString()}</p>
{application.notes && <p className="notes">{application.notes}</p>}
<p className="status">{application.status}</p>
<div className="card-actions">
<button onClick={handleDelete} className="delete-btn">Delete</button>
</div>
</div>
);
}
export default ApplicationCard;
Here is the full App.jsx:
import { useState, useEffect } from "react";
import ApplicationList from "./components/ApplicationList";
import AddApplication from "./components/AddApplication";
import "./App.css";
function App() {
const [applications, setApplications] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchApplications = async () => {
try {
setError(null);
const response = await fetch("http://localhost:5000/api/applications");
if (!response.ok) throw new Error("Failed to fetch applications");
const data = await response.json();
setApplications(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchApplications();
}, []);
if (loading) return <p className="loading">Loading applications...</p>;
if (error) return <p className="error">Error: {error}</p>;
return (
<div className="app">
<h1>Job Application Tracker</h1>
<AddApplication onMutate={fetchApplications} />
<ApplicationList applications={applications} onMutate={fetchApplications} />
</div>
);
}
export default App;
You have working create and delete with instant UI feedback. In the next step, you will extend this pattern to support editing applications and changing their status, plus add visual polish with colour-coded cards.
Fix Stale UI and Complete Edit and Status Updates
In Step 4, you hit the stale-data problem: mutations reached the database just fine, but the UI stayed frozen until you manually refreshed the page.
This is a core React challenge that every frontend developer faces. The fix is a refetch pattern: after every successful mutation, your app re-fetches the full list from the server and React re-renders with the fresh data. You already have fetchApplications wired up as onMutate. In this step, you will confirm it works, then build the real features on top of it: inline editing and one-click status changes.
In this step, get ready to:
- Verify the refetch pattern fixes the stale-data problem from Step 4.
- Add inline editing and status-change buttons to ApplicationCard.
- Add visual polish with styled status buttons and edit inputs.
Confirm the refetch pattern fixes stale data
Your App.jsx already defines fetchApplications and passes it as the onMutate prop to both AddApplication and ApplicationList. Each child calls onMutate() after a successful POST or DELETE, which triggers a fresh fetch and a re-render.
What is the refetch-after-mutation pattern?
Instead of manually updating local state to match the server, you simply re-fetch the entire list after any change. This keeps your UI as the single source of display and your database as the single source of truth.
It is slightly less efficient than optimistic updates, but far simpler to reason about. Most production CRUD apps start here.
Before you test, do you think adding a new application will update the list immediately this time, or will you still need to refresh?
- Make sure both your Express server and Vite dev server are running.
- Open http://localhost:5173 in your browser.
- Fill in the form with any company and position, then click Add Application.
The new card appears in the list instantly. No page refresh needed. Click the Delete button on any card and it vanishes immediately too.
Why does it work now?
Every mutation calls onMutate() on success. That triggers fetchApplications in App.jsx, which updates the applications state. React re-renders any component that depends on that state.
Add status-change and inline edit to ApplicationCard
Right now each card only shows data and a delete button. You need status-change buttons (Applied, Interview, Offer, Rejected) and an inline edit mode so the learner can update any field without leaving the page.
- In Cursor, open client/src/components/ApplicationCard.jsx.
- Select all the existing content and delete it.
- Replace it with the following code, starting with the imports, constant, state, and delete handler:
import { useState } from "react";
// Status options for the quick-change buttons
const STATUS_OPTIONS = ["Applied", "Interview", "Offer", "Rejected"];
function ApplicationCard({ application, onMutate }) {
// Track whether the card is in edit mode
const [isEditing, setIsEditing] = useState(false);
// Local state for the edit form, pre-populated with current values
const [company, setCompany] = useState(application.company);
const [position, setPosition] = useState(application.position);
const [notes, setNotes] = useState(application.notes || "");
// Derive a CSS class from the status for color-coded borders
const statusClass = application.status.toLowerCase();
const handleDelete = async () => {
try {
const response = await fetch(
`http://localhost:5000/api/applications/${application.id}`,
{ method: "DELETE" }
);
if (!response.ok) throw new Error("Failed to delete");
onMutate();
} catch (err) {
console.error(err.message);
}
};
What does this code do?
STATUS_OPTIONS defines the four statuses as a constant array so you can map over them to render buttons.
The isEditing state controls whether the card renders as a display or as an editable form. The company, position, and notes state variables hold the form values, pre-populated from props so the learner sees current data when editing.
- Directly below the handleDelete function, add the status-change handler:
const handleStatusChange = async (newStatus) => {
try {
const response = await fetch(
`http://localhost:5000/api/applications/${application.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
company: application.company,
position: application.position,
status: newStatus,
notes: application.notes,
}),
}
);
if (!response.ok) throw new Error("Failed to update status");
onMutate();
} catch (err) {
console.error(err.message);
}
};
What does handleStatusChange do?
It sends a PUT request with the new status while keeping the other fields unchanged. After success, onMutate() triggers a refetch so the card immediately reflects the new status and its color-coded border updates.
- Below handleStatusChange, add the save handler for inline editing:
const handleSave = async () => {
try {
const response = await fetch(
`http://localhost:5000/api/applications/${application.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
company,
position,
status: application.status,
notes,
}),
}
);
if (!response.ok) throw new Error("Failed to update");
setIsEditing(false);
onMutate();
} catch (err) {
console.error(err.message);
}
};
What does handleSave do?
It reads the edited values from local state (company, position, notes) and sends them in a PUT request. It preserves the current application.status since the learner is editing content, not changing status. After a successful save, it exits edit mode and refetches the list.
- Below handleSave, add the conditional edit-mode return:
// If in edit mode, render input fields instead of display
if (isEditing) {
return (
<div className={`card ${statusClass}`}>
<input
type="text"
value={company}
onChange={(e) => setCompany(e.target.value)}
/>
<input
type="text"
value={position}
onChange={(e) => setPosition(e.target.value)}
/>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
<div className="card-actions">
<button onClick={handleSave}>Save</button>
<button onClick={() => setIsEditing(false)}>Cancel</button>
</div>
</div>
);
}
What does the edit-mode block do?
When isEditing is true, the card renders three text inputs pre-populated with current values. The learner can modify them and click Save to persist changes, or Cancel to discard edits and return to the display view.
- Finally, add the main display return and the closing export below the edit-mode block:
return (
<div className={`card ${statusClass}`}>
<h3>{application.company}</h3>
<p className="position">{application.position}</p>
<p className="date">
Applied: {new Date(application.date_applied).toLocaleDateString()}
</p>
{application.notes && <p className="notes">{application.notes}</p>}
<div className="status-buttons">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
className={application.status === status ? "active" : ""}
onClick={() => handleStatusChange(status)}
>
{status}
</button>
))}
</div>
<div className="card-actions">
<button onClick={() => setIsEditing(true)}>Edit</button>
<button onClick={handleDelete} className="delete-btn">
Delete
</button>
</div>
</div>
);
}
export default ApplicationCard;
What changed in the display return?
The plain status text is gone. In its place, a row of STATUS_OPTIONS buttons lets the learner change status with one click. The currently active status gets the active CSS class so it stands out visually. An Edit button now sits alongside Delete in the card actions.
- Save ApplicationCard.jsx.
- Return to your browser at http://localhost:5173.
- Click the Interview button on any card.
The card's left border changes to yellow and the Interview button becomes highlighted. The change persisted to the database and the UI updated instantly.
- Click the Edit button on any card.
- Change the company name to something new, then click Save.
The card snaps back to display mode with the updated company name. Every mutation now triggers an immediate UI refresh.
Status buttons not showing?
Make sure you included the STATUS_OPTIONS constant OUTSIDE the function (above function ApplicationCard). If it is inside the function, it will work but check that it is spelled exactly as shown.
If clicking a status button does nothing, open the browser console and look for errors. The most common cause is a typo in the fetch URL or missing Content-Type header.
help me debug my status change buttons
Add CSS for status buttons and edit inputs
The status buttons work but look unstyled. You need CSS for the button row, the active state highlight, and the input fields that appear during edit mode.
- Open client/src/App.css.
- Find the .card-actions rule block. Add the following CSS above it:
.status-buttons {
display: flex;
gap: 0.25rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.status-buttons button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.status-buttons button.active {
background: #1a1a2e;
color: white;
border-color: #1a1a2e;
}
What do these styles do?
The .status-buttons container uses flexbox to lay out the four buttons in a row. Each button is small and pill-shaped. The .active class inverts the colors so the current status stands out against the others.
- Below the .card-actions .delete-btn:hover rule, add styles for edit-mode inputs:
.card input {
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
What does this style do?
When a card enters edit mode, the inputs need their own sizing. This gives each input full width, some padding for comfortable typing, and spacing between each field.
- Save App.css.
- Return to your browser.
The status buttons now have a clean pill shape with the active one highlighted in dark. Click Edit on a card and the inputs are neatly stacked with comfortable spacing.
Styles not applying?
Check that .status-buttons appears BEFORE .card-actions in App.css. CSS order matters when specificity is equal.
If the active button looks the same as the others, confirm the class name is button.active (not .active button). The dot-class goes directly on the button element.
help me fix my status button styles
Time for the full end-to-end test.
- Add a new application using the form.
- Change its status to Interview and watch the border turn yellow and the button highlight.
- Click Edit, change the company name, and click Save.
- Click Delete and watch the card disappear.
Every action updates the UI immediately without a page refresh. The stale-data problem is solved.
✔️ Awesome, I've got everything!
Great. Make sure both ApplicationCard.jsx and App.css are saved.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these sections is present in your ApplicationCard.jsx, in order:
- Imports, STATUS_OPTIONS constant, function declaration with four useState calls and statusClass.
- handleDelete function.
- handleStatusChange function.
- handleSave function.
- isEditing conditional return with three inputs and Save/Cancel buttons.
- Main return with company, position, date, notes, status-buttons div, and card-actions div with Edit and Delete.
- Export statement.
For App.css, confirm you have .status-buttons, .status-buttons button, .status-buttons button.active, and .card input rules present.
Your Job Application Tracker is fully functional. You can create, read, update status, edit details, and delete applications with the UI always staying in sync. Next up, a secret mission to add filtering and sorting for when that list starts growing.
Secret mission
Filter by Status and Sort by Date
Your tracker works, but a growing list of applications gets hard to scan. Can you add filter tabs and a sort toggle so you can slice your list by status and reorder by date, all without hitting the backend again?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- A local PostgreSQL database called job_tracker (free, runs on your machine).
- The client/ directory (React frontend built with Vite).
- The server/ directory (Express API with pg connection).
Keep everything running
No action needed. Choose this if you plan to continue building on this project, such as deploying it to the cloud in a follow-on project.
- Your job_tracker database persists automatically. PostgreSQL stores data on disk even when the server is idle.
- Your client/ and server/ directories stay on your Desktop, ready to spin up again with npm run dev.
Pause - I'll come back to this later
Stop the running dev servers to free up terminal sessions and memory. Your data persists in PostgreSQL and your code stays on disk.
- Switch to any terminal running the Vite dev server or the Express server and press Ctrl+C to stop each one.
- That is all. Your job_tracker database keeps its data. When you return, start the servers again with node index.js in the server/ folder and npm run dev in the client/ folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Stop any running dev servers by pressing Ctrl+C in their terminal sessions.
- Drop the PostgreSQL database by opening a new terminal and running:
psql -U postgres -c "DROP DATABASE job_tracker;"
What if the drop command fails?
You must not be connected to the job_tracker database when you drop it. The command above connects to the default postgres database instead. If other connections are still open, close them first or quit any psql sessions that use job_tracker.
- Delete the project directories from your Desktop by running:
macOS
cd ~/Desktop
rm -rf client server
Windows
cd ~\Desktop
rmdir /s /q client server
Nice Work!
Nice Work!
You just built a full-stack Job Application Tracker using the PERN stack. From database schema to REST API to a reactive frontend, every layer talks to the next and keeps data perfectly in sync.
Here's what you learned:
- Built a REST API with Express that handles full CRUD operations against PostgreSQL using parameterized queries.
- Designed a database schema and seeded it with sample data so every step produced visible results.
- Connected a React frontend to your API and solved the stale-data problem by implementing a refetch-after-mutation pattern with callback props.
- Secret Mission: Implemented client-side filtering by status and sorting by date using derived state with useMemo.
Ready to quiz yourself?