Build a Full-Stack Job Tracker
Build a job tracker with Express, SQLite, and a React frontend.
Introduction
30 Second Summary
Job hunting means juggling dozens of applications across different companies, each at a different stage. Spreadsheets get messy fast, and it is surprisingly hard to remember which role you applied to last Tuesday.
In this project, you will build a full-stack job application tracker that runs entirely on your local machine. You will create a backend API, connect it to a database, and build a polished React frontend to manage everything through your browser.
What You'll Build
You will open your browser, see a dashboard of every job you have applied to, add new applications through a form, update their status as you move through interviews, and watch everything persist even after a page refresh.
By the end of this project, you'll have:
- A live dashboard where you add job applications, update their status, and see changes appear instantly in the browser.
- A REST API you built from scratch with Express.js, storing data permanently in a SQLite database file on your machine.
- A React frontend with polished components generated by v0.dev and wired to your own backend with real-time data flow.
- Secret Mission: Add a search bar and status filter to quickly find applications as your list grows.
Are there any prerequisites?
You will need Node.js installed in WSL, VS Code, and basic JavaScript or TypeScript knowledge. You will also need a free account on v0.dev to generate UI components.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. You're creating a full-stack job application tracker with an Express API, a SQLite database, and a React frontend that all work together.
Set Up the Backend and Frontend Projects
A full-stack app needs two halves: an API server that stores and serves data, and a frontend that displays it in the browser.
In this step, you will create both projects side by side in a single root folder. The backend uses Express.js with SQLite, and the frontend uses React scaffolded by the shadcn CLI. By the end you will have two servers running independently, ready to be wired together.
In this step, get ready to:
- Create the project root and initialize the Express backend with its dependencies.
- Scaffold the React frontend using the shadcn CLI.
- Verify both servers start independently.
Create the project root and initialize the Express backend
Your backend is a Node.js server powered by Express. It needs three packages: express for routing, better-sqlite3 for the database, and cors for allowing cross-origin requests from the frontend.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project root folder, the server directory inside it, and move into the server directory by running these commands:
mkdir -p job-tracker/server
cd job-tracker/server
- Confirm you are in the right place by running:
pwd
You should see a path ending in job-tracker/server.
- Initialize the server project and install the three dependencies by running these commands:
npm init -y
npm install express@5.2.1 better-sqlite3@13.0.3 cors@2.8.6
You will see npm download and install the packages. When it finishes, you will have a node_modules folder and a package.json inside server/.
What did those commands do?
The npm init -y command creates a default package.json so Node.js recognizes this folder as a project.
The npm install command downloads express (web framework), better-sqlite3 (local database driver), and cors (cross-origin middleware) into node_modules/ and pins their versions in package.json.
- Now update package.json so it matches the project configuration. Open the file in VS Code by running:
code package.json
- Replace the entire contents of package.json with the following:
{
"name": "job-tracker-server",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"better-sqlite3": "13.0.3",
"cors": "2.8.6",
"express": "5.2.1"
}
}
Why replace the whole file?
The npm init -y command generates extra fields you do not need. This trimmed version keeps only what matters: the project name, entry point, a start script, and the three dependencies at their pinned versions.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Create a placeholder server file so you can verify Express starts. Run this command:
touch index.js
code index.js
- Paste the following into index.js:
const express = require('express');
const app = express();
const PORT = 3001;
// Start the server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
This is a minimal Express server. It imports Express, creates an app instance, and tells it to listen on port 3001. When it starts successfully, it prints a confirmation message to the terminal.
- Save index.js.
- Test that the server starts by running:
node index.js
You should see:
Server running on http://localhost:3001
- Stop the server by pressing Ctrl+C in the terminal.
Seeing 'Cannot find module express'?
Make sure you ran npm install inside the server/ directory. Check that a node_modules folder exists there.
If it does not, run npm install again from inside job-tracker/server.
Help me fix a missing module error in my Express server.
Scaffold the React frontend using the shadcn CLI
The frontend needs React, TypeScript, Tailwind CSS, and the shadcn/ui component library. Instead of setting these up one by one, the shadcn CLI scaffolds everything in a single command.
- Move back to the project root by running:
cd ~/Desktop/job-tracker
- Scaffold the frontend project by running:
npx shadcn@latest init -t vite
What prompts will I see?
The CLI will ask you to configure your project. Here is how to respond:
- When asked for a project name, type client and press Enter.
- For any other prompts (base, preset, style), accept the defaults by pressing Enter.
- If you see a React 19 peer dependency prompt asking how to proceed, select Use --force and press Enter.
The CLI will download dependencies and scaffold files. This may take a minute.
When the command finishes, you will have a client/ folder inside job-tracker/ containing a full Vite + React + TypeScript + Tailwind + shadcn/ui project.
- Confirm both directories exist by running:
ls
You should see client and server listed in the output.
Don't see a client folder?
If the shadcn command failed or created the folder with a different name, check the terminal output for errors. The most common issue is a network timeout during package installation.
Run the command again from the job-tracker/ directory. Make sure you typed client when prompted for the project name.
Help me fix my shadcn init command that failed.
Verify both servers start independently
You now have two projects that should each run on their own. Time to confirm.
- Start the backend server by running:
cd ~/Desktop/job-tracker/server
node index.js
You should see Server running on http://localhost:3001 in the terminal.
- Open a second terminal. Press Ctrl+Shift+` in VS Code to open a new terminal tab.
- Start the frontend dev server by running:
cd ~/Desktop/job-tracker/client
npm run dev
Before you look at the output, what URL do you expect Vite to serve the app on?
Vite prints a local URL. Open http://localhost:5173 in your browser. You should see the default Vite + React starter page.
Frontend not loading at localhost:5173?
Check the terminal output for errors. If it shows a different port, use that one instead.
Make sure you are inside the client/ directory when running npm run dev. If node_modules is missing, run npm install first.
Help me get my Vite dev server running.
Why two separate directories?
Keeping the backend and frontend in separate folders with their own package.json files makes the architecture visible. Each project manages its own dependencies and runs its own server process.
When you connect them in a later step, the boundary between client and server becomes a learning moment: CORS, fetch requests, and data serialization all happen at that boundary.
Both halves of your app are alive. Next up, you will build the Express API routes that store and retrieve job applications from a real database.
Build the Express API with SQLite
Your backend and frontend projects are scaffolded and ready to go. Now it's time to give your application a brain.
Right now, your frontend has no way to permanently store data. Anything a user adds would vanish the moment they close the browser. In this step, you'll build a REST API with Express.js that persists job applications in a SQLite database file on your machine.
In this step, get ready to:
- Create the SQLite database schema for storing job applications.
- Build four Express routes that handle full CRUD operations (create, read, update, delete).
- Test every route using curl commands in your terminal.
Create the server file and database schema
Your server needs to do three things when it starts: connect to a database, set up the table structure, and begin listening for requests. You'll put all of this in a single index.js file.
- In VS Code, create a new file called index.js inside your server/ directory.
- Paste the following code into server/index.js:
const express = require('express');
const cors = require('cors');
const Database = require('better-sqlite3');
const path = require('path');
// Create the Express app and set the port
const app = express();
const PORT = 3001;
// Enable CORS and JSON body parsing
app.use(cors());
app.use(express.json());
// Connect to a SQLite database file (creates it if missing)
const db = new Database(path.join(__dirname, 'applications.db'));
// Create the applications table if it doesn't already exist
db.exec(`
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company TEXT NOT NULL,
position TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'bookmarked',
job_url TEXT,
salary_range TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
applied_date TEXT,
created_at TEXT DEFAULT (datetime('now'))
)
`);
What does this code do?
This sets up the foundation of your server:
- require('express') loads the Express framework for handling HTTP requests.
- require('better-sqlite3') loads the database driver that reads and writes to a file called applications.db.
- db.exec(...) runs the SQL statement that creates your table structure with columns for company, position, status, and contact info.
- CREATE TABLE IF NOT EXISTS means this runs safely every time the server starts without duplicating the table.
The table doesn't have routes yet, so the server won't respond to any requests. Add the listener at the bottom so you can confirm it starts.
- Add this code at the very end of server/index.js, below the db.exec(...) block:
// Start the server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
- Save server/index.js.
- Open a terminal in VS Code, navigate into the server/ folder, and start the server by running:
cd server
node index.js
You should see this output:
Server running on http://localhost:3001
Seeing an error about a missing module?
If you see Cannot find module 'express' or similar, make sure you ran npm install inside the server/ directory in Step 1. Run npm install now and try again.
help me fix a missing module error when starting my Express server
Build the Express routes
Your server starts, but it can't do anything useful yet. You need routes that handle the four basic data operations: listing all applications, creating a new one, updating an existing one, and deleting one.
- Stop the running server by pressing Ctrl+C in the terminal.
- In server/index.js, find the app.listen line at the bottom. Add the following GET and POST routes directly above it:
// Return all applications, newest first
app.get('/api/applications', (req, res) => {
const applications = db.prepare('SELECT * FROM applications ORDER BY created_at DESC').all();
res.json(applications);
});
// Create a new application
app.post('/api/applications', (req, res) => {
const { company, position, status, job_url, salary_range, contact_name, contact_email, notes, applied_date } = req.body;
const stmt = db.prepare(`
INSERT INTO applications (company, position, status, job_url, salary_range, contact_name, contact_email, notes, applied_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(company, position, status || 'bookmarked', job_url || null, salary_range || null, contact_name || null, contact_email || null, notes || null, applied_date || null);
const created = db.prepare('SELECT * FROM applications WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(created);
});
What do these routes do?
GET /api/applications queries the database for every row in the applications table and returns them as JSON, sorted by newest first.
POST /api/applications extracts the fields from the request body, inserts a new row with ? placeholders (which prevent SQL injection), then returns the newly created record. The || null pattern makes optional fields store null instead of undefined.
- Save server/index.js and restart the server:
node index.js
- Open a second terminal in VS Code (click the + icon in the terminal panel).
- Test the GET route by running:
curl http://localhost:3001/api/applications
You should see an empty JSON array because the database has no records yet:
[]
- Test the POST route by creating your first application:
curl -X POST http://localhost:3001/api/applications -H "Content-Type: application/json" -d '{"company":"Acme Corp","position":"Software Engineer","status":"applied"}'
You should see the created application returned as JSON with an id field and a created_at timestamp.
Getting an empty response or connection refused?
Make sure your server is still running in the first terminal (you should see the Server running on http://localhost:3001 message). If you stopped it, run node index.js again from inside the server/ directory.
help me debug why my curl request to localhost:3001 is failing
Two routes down, two to go. Now you'll add the ability to update and delete applications.
- Stop the server with Ctrl+C.
- In server/index.js, add the PUT route directly below the POST route (still above app.listen):
// Update an existing application by ID
app.put('/api/applications/:id', (req, res) => {
const { id } = req.params;
const { company, position, status, job_url, salary_range, contact_name, contact_email, notes, applied_date } = req.body;
const stmt = db.prepare(`
UPDATE applications
SET company = ?, position = ?, status = ?, job_url = ?, salary_range = ?, contact_name = ?, contact_email = ?, notes = ?, applied_date = ?
WHERE id = ?
`);
stmt.run(company, position, status, job_url || null, salary_range || null, contact_name || null, contact_email || null, notes || null, applied_date || null, id);
const updated = db.prepare('SELECT * FROM applications WHERE id = ?').get(id);
if (!updated) return res.status(404).json({ error: 'Application not found' });
res.json(updated);
});
What does the PUT route do?
It takes the application's id from the URL path (:id) and the updated fields from the request body. After running the UPDATE query, it fetches the updated row and returns it. If no row matches the ID, it returns a 404 error.
- Add the DELETE route directly below the PUT route:
// Delete an application by ID
app.delete('/api/applications/:id', (req, res) => {
const { id } = req.params;
const result = db.prepare('DELETE FROM applications WHERE id = ?').run(id);
if (result.changes === 0) return res.status(404).json({ error: 'Application not found' });
res.json({ message: 'Deleted successfully' });
});
What does the DELETE route do?
It deletes the row matching the given ID. The result.changes property tells you how many rows were affected. If zero rows were deleted, the ID doesn't exist, so it returns a 404.
- Save server/index.js.
✔️ 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
Your server/index.js should contain these sections in order. Scroll through your file and confirm each one is present:
- Imports (express, cors, better-sqlite3, path).
- App setup (app, PORT, middleware).
- Database connection and CREATE TABLE statement.
- GET /api/applications route.
- POST /api/applications route.
- PUT /api/applications/:id route.
- DELETE /api/applications/:id route.
- app.listen call at the very bottom.
Test the full CRUD cycle
All four routes are in place. Before moving on, you'll confirm the entire create-read-update-delete lifecycle works end to end.
Before you run these tests, what do you expect to happen when you GET all applications right after a fresh server start with the database from earlier?
- Restart the server in your first terminal:
node index.js
- In your second terminal, list all applications by running:
curl http://localhost:3001/api/applications
You should see the Acme Corp application you created earlier (the database persists between restarts).
- Update that application's status to interviewing by running:
curl -X PUT http://localhost:3001/api/applications/1 -H "Content-Type: application/json" -d '{"company":"Acme Corp","position":"Software Engineer","status":"interviewing"}'
You should see the application returned with "status":"interviewing" in the JSON response.
- Delete the application by running:
curl -X DELETE http://localhost:3001/api/applications/1
You should see:
{"message":"Deleted successfully"}
- Confirm the delete worked by listing all applications one more time:
curl http://localhost:3001/api/applications
You should see an empty array [] confirming the record was removed from the database.
PUT or DELETE returning 'Application not found'?
The ID in your URL must match an existing record. If you already deleted the application, create a new one with POST first, then use the id value from that response in your PUT or DELETE URL.
help me figure out why my PUT or DELETE request is returning a 404
Why is SQLite great for this project?
Unlike PostgreSQL or MySQL, SQLite requires no separate server process. Your entire database lives in a single file (applications.db) right inside your project folder. This means zero configuration and instant startup.
The better-sqlite3 library makes this even simpler by running queries synchronously. No callbacks, no promises for database calls.
Your API is fully functional. You can create, read, update, and delete job applications from the command line. Next up, you'll build a visual frontend so you can interact with this data through a real UI instead of curl commands.
Build the Frontend with v0-Generated Components
Your API is fully operational and storing real data in SQLite. But right now the only way to interact with it is through curl commands in the terminal.
In this step, you will use v0.dev to generate polished React components, then wire them up with hardcoded sample data to see the UI in your browser before connecting to the real API.
In this step, get ready to:
- Generate an application card component and an add-application form using v0.dev.
- Install the shadcn/ui primitives your components need.
- Render the components in your app with mock data to confirm the UI works.
Generate your components with v0.dev
v0.dev is an AI tool by Vercel that generates production-ready React components from plain English prompts. It outputs code using shadcn/ui and Tailwind CSS, which is exactly what your project uses.
- Open your browser and go to v0.dev.
- Sign in with your Vercel account (or create a free one if you do not have one).
- In the chat prompt, paste the following prompt to generate your ApplicationCard component:
Create a React component called ApplicationCard using shadcn/ui Card, Badge, Button, and Select components with Tailwind CSS.
Props interface:
- application: object with fields: id (number), company (string), position (string), status (string), job_url (string | null), salary_range (string | null), contact_name (string | null), contact_email (string | null), notes (string | null), applied_date (string | null), created_at (string)
- onStatusChange: (id: number, status: string) => void
- onDelete: (id: number) => void
Display requirements:
- Show company name as the card title, position as subtitle
- Show a colored Badge for the status (bookmarked, applied, interviewing, offered, rejected)
- Show contact name and email if present
- Show salary range and applied date if present
- Include a Select dropdown to change status that calls onStatusChange
- Include a Delete button that calls onDelete
- Clean card layout with good spacing
What is this prompt doing?
You are telling v0 exactly what props your component will receive and what it should display. The more specific your prompt, the closer the output matches what you need. The props you listed here match the data shape your Express API returns.
- Wait for v0 to generate the component code.
- Review the preview. If the card looks good, click Copy Code to copy the full component.
- Now paste this second prompt to generate the AddApplicationDialog component:
Create a React component called AddApplicationDialog using shadcn/ui Dialog, Button, Input, Select, and Label components with Tailwind CSS.
Props interface:
- open: boolean
- onOpenChange: (open: boolean) => void
- onSubmit: (data: object) => void
The form should have these fields:
- company (required text input)
- position (required text input)
- status (select: bookmarked, applied, interviewing, offered, rejected)
- job_url (optional text input)
- salary_range (optional text input)
- contact_name (optional text input)
- contact_email (optional text input)
- notes (optional textarea)
- applied_date (optional date input)
Design requirements:
- Modal dialog that opens when open is true
- Form fields with labels, organized in a clean layout
- Submit button that collects all field values and calls onSubmit
- Cancel button that calls onOpenChange(false)
- Wait for v0 to generate the form component.
- Review the preview, then click Copy Code to copy it.
Your output will look different from others
v0 generates slightly different code each time. Your component might use different icon names, spacing, or layout than someone else following this project. That is perfectly fine. What matters is that the props interface matches.
Install shadcn/ui primitives and save the components
The v0-generated code imports shadcn/ui primitives like Card, Badge, and Dialog. These need to be installed in your project before the components will work.
- Open a terminal in VS Code. Make sure you are inside the client directory.
- Install all the shadcn/ui primitives your components need by running this command:
npx shadcn@latest add card badge button input select dialog label textarea
What does this command do?
The npx shadcn@latest add command copies pre-built component files into your src/components/ui/ folder. Each primitive (card, badge, button, etc.) becomes a local file you own and can customize.
If prompted to overwrite existing files, enter y to confirm.
- Confirm the primitives were installed by running:
ls src/components/ui/
You should see files like card.tsx, badge.tsx, button.tsx, dialog.tsx, and others listed.
Command not found or install errors?
Make sure you are inside the client/ directory, not the project root or server/. Run pwd to confirm your current location.
Help me troubleshoot shadcn/ui installation errors in my Vite + React project.
- Create a file at src/components/ApplicationCard.tsx inside client/ by running:
touch src/components/ApplicationCard.tsx
- Open src/components/ApplicationCard.tsx in VS Code and paste the entire v0-generated ApplicationCard code you copied earlier.
- Create the second component file by running:
touch src/components/AddApplicationDialog.tsx
- Open src/components/AddApplicationDialog.tsx in VS Code and paste the entire v0-generated AddApplicationDialog code you copied earlier.
- Save both files.
What if v0 used different import paths?
Your v0-generated code should import shadcn primitives from paths like @/components/ui/card. This matches the project setup from Step 1. If v0 used a different path format, update the imports to use the @/components/ui/ prefix.
Render the components with mock data
Before connecting to the real API, you will render the components with hardcoded data. This lets you confirm the UI works without worrying about network requests.
- Open src/App.tsx in VS Code. Delete all the existing starter content.
- Paste the following imports and mock data at the top of the file:
import { useState } from 'react';
import { ApplicationCard } from '@/components/ApplicationCard';
import { AddApplicationDialog } from '@/components/AddApplicationDialog';
import { Button } from '@/components/ui/button';
// Sample data to test the UI before connecting to the API
const mockApplications = [
{
id: 1,
company: 'Acme Corp',
position: 'Software Engineer',
status: 'applied',
job_url: 'https://acme.com/careers/swe',
salary_range: '$90k - $120k',
contact_name: 'Jane Smith',
contact_email: 'jane@acme.com',
notes: 'Referred by a friend',
applied_date: '2025-01-15',
created_at: '2025-01-15T10:00:00',
},
{
id: 2,
company: 'Globex Inc',
position: 'Frontend Developer',
status: 'interviewing',
job_url: null,
salary_range: '$80k - $110k',
contact_name: null,
contact_email: null,
notes: null,
applied_date: '2025-01-10',
created_at: '2025-01-10T09:00:00',
},
];
What does this mock data represent?
Each object in the array mimics exactly what your Express API returns from the applications table. Every field matches the database schema you built in Step 2. Using mock data first means you can see the UI without needing the API running.
- Now paste the App component below the mock data in the same file:
function App() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Placeholder handlers that log actions to the console
function handleCreate(data: any) {
console.log('Create:', data);
setIsDialogOpen(false);
}
function handleStatusChange(id: number, status: string) {
console.log('Status change:', id, status);
}
function handleDelete(id: number) {
console.log('Delete:', id);
}
return (
<div className="min-h-screen bg-background p-8">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">Job Application Tracker</h1>
<Button onClick={() => setIsDialogOpen(true)}>Add Application</Button>
</div>
<div className="grid gap-4">
{mockApplications.map(app => (
<ApplicationCard key={app.id} application={app} onStatusChange={handleStatusChange} onDelete={handleDelete} />
))}
</div>
<AddApplicationDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} onSubmit={handleCreate} />
</div>
</div>
);
}
export default App;
What does this component do?
The App component manages one piece of state: whether the add-application dialog is open. The three handler functions just log to the console for now. In the next step, you will replace them with real API calls.
The mockApplications.map() loop renders one ApplicationCard per item, passing each application's data and the handler functions as props.
- Save src/App.tsx.
Before you run the dev server, what do you expect to see in the browser? Think about what the mock data contains.
- Start the Vite dev server by running this command inside client/:
npm run dev
Open http://localhost:5173 in your browser. You should see a page titled "Job Application Tracker" with two cards showing Acme Corp and Globex Inc, each with a status badge, and an Add Application button in the top right corner.
Seeing import errors or a blank page?
The most common cause is a mismatch between the import paths v0 generated and where the shadcn primitives live. Open your browser's developer console (F12) and check the error message.
- If you see an error about a missing module from @/components/ui/, check that the file exists in src/components/ui/. You may need to re-run the npx shadcn@latest add command for the missing primitive.
- If the import paths in your v0 code use something like ../ui/card instead of @/components/ui/card, update them to use the @/ alias.
Help me fix import errors in my v0-generated shadcn/ui components.
- Click the Add Application button. You should see a dialog/modal appear with form fields for company, position, status, and other details.
- Open your browser's developer console (F12), then click the delete button on one of the cards.
You should see Delete: 1 (or the card's id) logged to the console. The buttons are wired up, they just do not call the API yet.
✔️ Awesome, I've got everything!
Great. Make sure you have saved App.tsx, ApplicationCard.tsx, and AddApplicationDialog.tsx.
ⓧ I'd like to double check the full code
Your v0-generated components will be unique to you, so there is no exact reference to compare against. However, confirm these pieces are in place:
- src/components/ApplicationCard.tsx exports a component that accepts application, onStatusChange, and onDelete props.
- src/components/AddApplicationDialog.tsx exports a component that accepts open, onOpenChange, and onSubmit props.
- src/App.tsx imports both components and Button, defines mockApplications, and renders the layout with a title, cards, and the dialog.
Your frontend now has a polished UI with real-looking data. Next up, you will connect it to your Express API so that adding, updating, and deleting applications persists in the SQLite database.
Connect Frontend to Backend
Your UI renders hardcoded mock data and your API stores real data in SQLite. Right now they live in separate worlds with no bridge between them.
In this step, you'll connect them with fetch(). Along the way you'll hit the single most common integration bug in full-stack development: a CORS error. Every full-stack developer encounters this the first time their browser talks to a separate server.
In this step, get ready to:
- Create an API client that communicates with Express from React.
- Hit and fix the CORS error that blocks browser requests.
- Wire up full CRUD so the UI creates, reads, updates, and deletes from the database.
Create the API type definition
Before your React app can talk to the Express server, it needs to know the shape of the data it will receive. A TypeScript interface defines this contract.
- Create a new file at client/src/lib/api.ts by right-clicking the src folder in the VS Code Explorer sidebar, selecting New Folder, naming it lib, then right-clicking lib and selecting New File named api.ts.
- Paste the following code into api.ts:
const API_BASE = 'http://localhost:3001/api';
// Define the shape of an application record from the database
export interface Application {
id: number;
company: string;
position: string;
status: string;
job_url: string | null;
salary_range: string | null;
contact_name: string | null;
contact_email: string | null;
notes: string | null;
applied_date: string | null;
created_at: string;
}
What does this code do?
API_BASE stores the address of your Express server so you don't repeat it in every function.
The Application interface tells TypeScript exactly what fields each job application has. This catches typos at compile time instead of at runtime.
Add the fetch functions
- Below the interface in api.ts, add these four CRUD functions:
// Fetch all applications from the server
export async function getApplications(): Promise<Application[]> {
const res = await fetch(`${API_BASE}/applications`);
return res.json();
}
// Create a new application
export async function createApplication(data: Omit<Application, 'id' | 'created_at'>): Promise<Application> {
const res = await fetch(`${API_BASE}/applications`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
// Update an existing application
export async function updateApplication(id: number, data: Omit<Application, 'id' | 'created_at'>): Promise<Application> {
const res = await fetch(`${API_BASE}/applications/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
// Delete an application by ID
export async function deleteApplication(id: number): Promise<void> {
await fetch(`${API_BASE}/applications/${id}`, { method: 'DELETE' });
}
What do these functions do?
- getApplications() sends a GET request and returns the full list.
- createApplication() sends a POST with the form data as JSON in the body.
- updateApplication() sends a PUT to a specific /api/applications/:id endpoint.
- deleteApplication() sends a DELETE request. It returns nothing because the record is gone.
- Save api.ts.
Seeing a red underline on the import path?
If VS Code shows an error on '@/lib/api' later, confirm that tsconfig.json (created by the shadcn scaffold) has the @/* path alias configured. The shadcn CLI sets this up automatically.
help me fix a path alias error in my Vite + shadcn project
Replace App.tsx with real API calls
Now you'll swap the hardcoded mock data for live calls to your Express server. Replace the entire contents of App.tsx with new code that fetches real data on load.
- Open client/src/App.tsx and select all the existing code (Cmd+A on macOS or Ctrl+A on Windows).
- Delete it and paste the following imports and state setup:
import { useEffect, useState } from 'react';
import { getApplications, createApplication, updateApplication, deleteApplication } from '@/lib/api';
import type { Application } from '@/lib/api';
import { ApplicationCard } from '@/components/ApplicationCard';
import { AddApplicationDialog } from '@/components/AddApplicationDialog';
import { Button } from '@/components/ui/button';
function App() {
// Store the list of applications fetched from the API
const [applications, setApplications] = useState<Application[]>([]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Fetch applications when the component first loads
useEffect(() => {
loadApplications();
}, []);
async function loadApplications() {
const data = await getApplications();
setApplications(data);
}
What's happening here?
useEffect with an empty dependency array [] runs once when the component mounts. It calls loadApplications() which fetches from your Express API and stores the result in state.
This replaces the hardcoded array you had before. Now the data comes from SQLite through your API.
- Below loadApplications, add the handler functions that create, update, and delete applications:
// Create a new application and refresh the list
async function handleCreate(data: Omit<Application, 'id' | 'created_at'>) {
await createApplication(data);
await loadApplications();
setIsDialogOpen(false);
}
// Update just the status field of an application
async function handleStatusChange(id: number, status: string) {
const app = applications.find(a => a.id === id);
if (!app) return;
await updateApplication(id, { ...app, status });
await loadApplications();
}
// Delete an application and refresh the list
async function handleDelete(id: number) {
await deleteApplication(id);
await loadApplications();
}
Why call loadApplications() after every action?
Each handler sends a request to the API, then re-fetches the full list. This keeps the UI in sync with the database without manually updating local state. It's simple and reliable for a small app.
- Below handleDelete, add the return statement that renders the UI and closes the component:
return (
<div className="min-h-screen bg-background p-8">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">Job Application Tracker</h1>
<Button onClick={() => setIsDialogOpen(true)}>Add Application</Button>
</div>
<div className="grid gap-4">
{applications.length === 0 ? (
<p className="text-muted-foreground text-center py-12">No applications yet. Add your first one!</p>
) : (
applications.map(app => (
<ApplicationCard
key={app.id}
application={app}
onStatusChange={handleStatusChange}
onDelete={handleDelete}
/>
))
)}
</div>
<AddApplicationDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
onSubmit={handleCreate}
/>
</div>
</div>
);
}
export default App;
What does the JSX do?
- If applications is empty, it shows a placeholder message instead of a blank screen.
- Otherwise it maps over the array and renders one ApplicationCard per record, passing the handler functions as props.
- AddApplicationDialog is controlled by isDialogOpen state and calls handleCreate on submit.
- Save App.tsx.
Before you check the browser, what do you think you'll see? The dashboard loading applications from the API, or something else?
- Open your browser at http://localhost:5173 and open the browser console (F12 or Cmd+Option+I on macOS, Ctrl+Shift+I on Windows).
You'll see a red error in the console that says something like:
Access to fetch at 'http://localhost:3001/api/applications' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
This is the designed obstacle. Your browser refuses to load data from port 3001 because it was loaded from port 5173. The browser's same-origin policy blocks cross-origin requests unless the server explicitly allows them.
What is CORS?
CORS stands for Cross-Origin Resource Sharing. Browsers enforce a security rule: JavaScript on one origin (like localhost:5173) cannot fetch from a different origin (like localhost:3001) unless the server sends back a header saying it's allowed.
This is why curl worked fine in Step 2. curl is not a browser and does not enforce CORS rules.
Fix the CORS block
The fix is a one-line middleware addition on the server. The cors npm package tells Express to send the Access-Control-Allow-Origin header that the browser requires.
- Switch to the terminal where your Express server is running.
- Stop the server with Ctrl+C.
- Install the cors package by running:
npm install cors
- Open server/index.js and find this line near the top:
const express = require('express');
- Add the cors require directly below it, so the top of your file looks like this:
const express = require('express');
const cors = require('cors');
- Now find this line:
app.use(express.json());
- Add app.use(cors()) directly above it, so the middleware section looks like this:
app.use(cors());
app.use(express.json());
What does this middleware do?
app.use(cors()) adds the Access-Control-Allow-Origin: * header to every response. This tells the browser that any origin is allowed to call your API.
- Save server/index.js.
- Restart the server by running:
node index.js
- Switch back to your browser at http://localhost:5173 and refresh the page.
The CORS error is gone. You should see the empty-state message: "No applications yet. Add your first one!" This means the fetch succeeded and returned an empty array from your database.
Still seeing the CORS error?
- Confirm you saved server/index.js after adding the two lines.
- Make sure you stopped the old server and restarted it. The changes only take effect on restart.
- Check that app.use(cors()) appears before your route definitions. Middleware runs in order.
help me fix a CORS error between my Vite frontend on port 5173 and Express backend on port 3001
Verify the full data lifecycle
Your frontend and backend are now connected. Every action in the UI flows through your API to the SQLite database and back. Time to prove the entire lifecycle works end-to-end.
Before you test, predict: if you add an application through the form and then refresh the page, will it still be there? Why or why not?
- Click the Add Application button in your app.
- Fill in Acme Corp for company and Software Engineer for position.
- Select applied as the status and submit the form.
The application appears in your list immediately. No page refresh needed.
- Now hard-refresh the page (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows).
Your application is still there. The data survived the refresh because it was saved to the SQLite database file on disk, not just held in browser memory.
Application disappears after refresh?
- Open the browser console and check for errors. A network error means the Express server may have stopped.
- Confirm the applications.db file exists in your server/ directory. This is where SQLite stores your data.
- Make sure the POST handler in server/index.js returns status 201. Check the Network tab in DevTools to see the response.
my application saves but disappears after I refresh the page
✔️ Awesome, I've got everything!
Great. Confirm you've saved both client/src/lib/api.ts and client/src/App.tsx.
ⓧ I'd like to double check the full code
Scroll up and confirm your files contain each chunk in order:
- client/src/lib/api.ts: The API_BASE constant, the Application interface, then the four exported async functions (getApplications, createApplication, updateApplication, deleteApplication).
- client/src/App.tsx: Six imports at the top, then function App() with two useState calls, a useEffect, loadApplications, handleCreate, handleStatusChange, handleDelete, then the JSX return with the grid and dialog.
- server/index.js: const cors = require('cors') after the express require, and app.use(cors()) before app.use(express.json()).
You now have a fully connected full-stack application. Data flows from the React form, through your Express API, into SQLite, and back out to the browser. Next up, you'll extend this with search and filtering in the Secret Mission.
Secret mission
Add Search and Status Filtering
Once your tracker has 10+ applications, scrolling through all of them becomes a chore. Real apps need search and filtering. Can you add a text search bar and a status dropdown that work together to narrow down your list instantly?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine. There are no cloud resources, no paid services, and no ongoing costs.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Express server running on localhost:3001 (in the server/ directory).
- SQLite database file at server/applications.db containing your saved application data.
- Vite dev server running on localhost:5173 (in the client/ directory).
- Node modules installed in both server/node_modules and client/node_modules.
Keep everything running
No action needed. Choose this if you are still actively building or want to add more features to your tracker.
- Your SQLite database file persists between runs. All your saved applications stay in server/applications.db.
- If you close your terminal, just start both servers again to pick up where you left off.
- Run node server/index.js from the project root for the backend, and npm run dev from the client/ directory for the frontend.
Pause - I'll come back to this later
Stop the running servers to free up terminal sessions and memory. Your project files and database stay on disk.
- Press Ctrl+C in the terminal running the Express server to stop it.
- Press Ctrl+C in the terminal running the Vite dev server to stop it.
- Your project files and SQLite database remain on your Desktop. Start both servers again whenever you want to resume.
Delete - I don't want to use this again
Remove all project files from your machine. There are no external services to tear down.
- Press Ctrl+C in both terminal sessions to stop the running servers.
- Navigate to your Desktop and delete the project folder by running:
macOS
cd ~/Desktop
rm -rf job-tracker
Windows
cd ~\Desktop
Remove-Item -Recurse -Force job-tracker
This removes the server/ directory (including your SQLite database and all saved applications), the client/ directory, and all installed node_modules.
Nice Work!
Nice Work!
Nice work! You just built a full-stack web application from scratch. You have a working job application tracker with a React frontend, an Express API, and a SQLite database all running on your local machine.
You've learned how to:
- Built a REST API from scratch with Express.js that handles full CRUD operations, persisting data permanently in SQLite using better-sqlite3.
- Generated production-quality UI components with v0.dev and integrated them into a React + TypeScript + Tailwind project scaffolded with the shadcn CLI.
- Diagnosed and fixed a CORS error and connected your React frontend to your Express backend with fetch() for real-time data flow.
- Secret Mission: Added combined search and status filtering to handle growing data sets.
Ready to quiz yourself?