Build a Job Application Tracker

Build a full-stack dashboard that saves and tracks job applications locally.

Introduction

30 Second Summary

Job searches quickly spread across browser tabs, inboxes, and half-finished notes. A missed follow-up can make a promising opportunity disappear into the pile.

In this project, you will build a full-stack job application dashboard with React inside Next.js. It persists each record through a JSON API backed by SQLite.

What You'll Build

You'll demo a polished dashboard where a saved application survives a browser refresh while search controls narrow the pipeline to the opportunities you need.

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

  • A responsive application pipeline where new opportunities appear immediately. Summary counters update with every new opportunity. Search controls find a company or role. A status filter narrows the cards by stage.
  • A persistent application history that returns after every refresh. A browser-readable JSON endpoint shows the same records behind the interface.
  • A public GitHub repository that makes the project easy to review. Its README gives reviewers a clear local setup guide. The extension roadmap shows how the project can grow.
  • Secret Mission: Turn each status badge into an editor that saves workflow changes through a PATCH request.

Are there any prerequisites?

No previous full-stack experience is required.

The Windows setup covers every required tool before you build.

Before We Start

This first checkpoint helps you define who your Application Tracker serves. It also gives your build a clear engineering purpose before the hands-on work begins.

Set Up Your Windows Development Environment

A reliable tracker starts with a reliable development toolchain. Missing tools can interrupt the first dashboard build with avoidable installation errors.

This step prepares Node.js 24.20.0 LTS with npm. This runtime powers the dashboard you start in the next step.

Visual Studio Code gives you a place to build the project. Git 2.55.0 records your work. GitHub gives that work a public home.

In this step, get ready to:
  • Install Node.js 24.20.0 LTS. Verify Node.js plus npm in PowerShell.
  • Install Visual Studio Code. Confirm that its command is available in PowerShell.
  • Install Git 2.55.0. Configure your commit identity. Finish setting up your verified GitHub account.
Install Node.js and verify npm

Node.js runs the Next.js application on your computer. npm reads the project's package list when it installs the libraries your dashboard needs.

A version check reveals whether the required Node.js release is already available. Start from PowerShell so you can test the same command environment used throughout the project.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open PowerShell.
  • Check the installed Node.js version by running this command:
node --version

PowerShell prints the installed Node.js version when the command is available. Choose the tab that matches your result.

✔️ I see the required Node.js version

If PowerShell shows v24.20.0, Node.js is already on the required release.

ⓧ I see an older version

The dashboard needs Node.js 24.20.0 LTS. Installing the required release updates the runtime used by this project.

  • Open the official Node.js release page for version v24.20.0.
  • Download the Windows installer that matches your processor. Most Windows PCs use node-v24.20.0-x64.msi.
  • Run the downloaded installer from your browser's downloads list.
  • Keep the default options selected on each installer screen.
  • Complete the installation.

ⓧ Command not found

Windows cannot find Node.js yet. The official installer adds the runtime plus npm to your computer.

  • Open the official Node.js release page for version v24.20.0.
  • Download the Windows installer that matches your processor. Most Windows PCs use node-v24.20.0-x64.msi.
  • Run the downloaded installer from your browser's downloads list.
  • Keep the default options selected on each installer screen.
  • Complete the installation.

A fresh PowerShell window reloads the command path changed by the installer. This gives the new Node.js installation a clean verification.

  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell window.
  • Verify the active Node.js version by running this command:
node --version

You should now see v24.20.0 in PowerShell. This confirms that Windows can find the required Node.js runtime.

  • Verify npm by running this command:
npm

You should see npm's help output with a list of commands. This confirms that the package manager installed alongside Node.js.

npm does not open its help output?

  • Close every PowerShell window.
  • Open a fresh PowerShell window through Windows search.
  • Run the Node.js installer again with its default options selected if PowerShell still cannot find npm.

Get help diagnosing the installation with this npm setup question.

Install Visual Studio Code

Visual Studio Code includes the editor used to create the dashboard files. Its Windows User setup also makes the code command available from a fresh PowerShell window.

  • Return to the PowerShell window from the Node.js check.
  • Check whether the Visual Studio Code command is available by running:
code --version

PowerShell prints Visual Studio Code version information when the command is ready. Choose the tab that matches your result.

✔️ I see a version number

Visual Studio Code is installed. Its command is available from PowerShell.

ⓧ Command not found

The recommended Windows User setup installs Visual Studio Code without requiring administrator permissions. It also adds the editor command to your path.

  • Open the official Visual Studio Code Windows setup guide in your browser.
  • Download the recommended Windows User setup.
  • Run the downloaded installer from your browser's downloads list.
  • Keep the default options selected on each installer screen.
  • Complete the installation.

The installer changes the command path used by new terminal sessions. A fresh PowerShell window proves that Windows loaded the change.

  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell window.
  • Verify the Visual Studio Code command by running:
code --version

You should see version information on separate lines. This confirms that the code command is available for opening project folders later.

PowerShell still cannot find Visual Studio Code?

  • Confirm that you completed the recommended Windows User setup.
  • Close every PowerShell window before checking the command again.
  • Restart Windows if the command path still uses the previous system state.

Get help checking the command path with this Visual Studio Code setup question.

Configure Git and verify GitHub

Git records the changes you make to the tracker. Your global identity attaches your name plus email address to each future commit.

GitHub receives those commits when you publish the finished project. A verified email address allows your account to create the public repository in the final step.

  • Return to the fresh PowerShell window from the Visual Studio Code check.
  • Check the installed Git version by running:
git --version

PowerShell prints the installed Git version when the command is available. Choose the tab that matches your result.

✔️ I see the required Git version

If the output includes 2.55.0, Git is already on the required release.

ⓧ I see an older version

The project uses Git for Windows 2.55.0. Installing the current release updates the command used by PowerShell.

  • Open the official Git for Windows installation page in your browser.
  • Download the installer for Git for Windows 2.55.0.
  • Run the downloaded installer from your browser's downloads list.
  • Complete the installer using its default choices.

ⓧ Command not found

Windows cannot find Git yet. The official Git for Windows installer adds it to your computer.

  • Open the official Git for Windows installation page in your browser.
  • Download the installer for Git for Windows 2.55.0.
  • Run the downloaded installer from your browser's downloads list.
  • Complete the installer using its default choices.

Git's installer updates the command path used by new terminal sessions. Verify the installation from a fresh PowerShell window before configuring your identity.

  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open a fresh PowerShell window.
  • Verify the active Git version by running:
git --version

You should see Git version information containing 2.55.0. This confirms that the required Git release is active.

  • Replace Your Name with the name you want attached to your commits.
  • Replace your.email@example.com with the verified email address used by your GitHub account.
  • Configure your global Git identity by running both edited commands:
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"

What do these commands do?

  • The first command sets the author name Git attaches to your commits.
  • The second command sets the author email Git attaches to your commits.
  • The global setting applies this identity to repositories created with your Windows account.

PowerShell should return to the prompt without an error after each command. Git now has the identity it needs for your project history.

Git rejected an identity command?

  • Keep the quotation marks around names that contain spaces.
  • Use the email address associated with your GitHub account.
  • Run each identity command separately if PowerShell stopped after the first command.

Get help checking the identity commands with this Git configuration question.

Your GitHub email must be verified before you publish the tracker. Use the account path that matches your current state.

  • Visit GitHub in your browser.

✔️ I am signed in with a verified email

Your GitHub account is ready to receive the public repository you publish later.

ⓧ I have an account to finish setting up

  • Sign in to your GitHub account.
  • Click your profile picture in the upper-right corner.
  • Click Settings.
  • Click Emails under Access in the sidebar.
  • Click Resend verification email beneath your email address if GitHub marks it as unverified.
  • Open the verification message in your email inbox.
  • Click the verification link in the message.

GitHub returns you to your dashboard with a confirmation banner. Your email address is now verified.

ⓧ I need a GitHub account

  • Open GitHub's account creation guide.
  • Create a personal account using an email address you can access.
  • Open the verification message in your email inbox.
  • Click the verification link in the message.
  • Sign in to your new GitHub account.

You should reach your signed-in GitHub dashboard with a verified email address.

Before you run the final checks, which three version results do you expect PowerShell to print?

  • Verify Node.js by running this command:
node --version

PowerShell should print v24.20.0 for Node.js.

  • Verify Git separately by running this command:
git --version

PowerShell should print Git version information containing 2.55.0.

  • Verify Visual Studio Code separately by running this command:
code --version

PowerShell should print the installed Visual Studio Code version. All three commands now resolve from the same terminal environment.

  • Return to the signed-in GitHub page from earlier.
  • Confirm that your account email is verified.

Your version results plus the verified GitHub account prove that the Windows development environment is ready.

That's the setup barrier cleared. Next, you'll turn this toolchain into the first visible version of your Application Tracker.

Launch the First Dashboard

Your Windows development environment is ready. You can now turn that toolchain into the first visible version of your job application tracker.

A static dashboard gives you something concrete to improve before state or persistence enters the project. You will build the interface with Next.js and React so you can see the product take shape immediately.

Why Next.js for this dashboard?

Next.js keeps the React interface and future server routes inside one project. This gives you a visible client-server boundary without managing two separate development servers.

In this step, get ready to:
  • Create the local project workspace.
  • Build the root layout and dashboard styling.
  • Run the static dashboard in your browser.
Create the project workspace

The project folder gives every source file and installed dependency one predictable home. Keeping it on your Desktop also makes it easy to find throughout the project.

  • Press Windows+E to open File Explorer.
  • Select Desktop in the navigation pane.
  • Select New in the command bar.
  • Select Folder.
  • Enter application-tracker as the folder name.
  • Press Enter.

You should see the empty application-tracker folder on your Desktop.

  • Press the Windows key to open Search.
  • Type Visual Studio Code into Search.
  • Press Enter to open Visual Studio Code.
  • Select File from the menu bar.
  • Select Open Folder....
  • Select the application-tracker folder on your Desktop.
  • Select Select Folder.

Visual Studio Code now treats application-tracker as the active workspace. Its integrated terminal starts from this folder automatically.

  • Select the New File icon in the Explorer sidebar.
  • Enter package.json as the file name.
  • Build the project manifest by pasting this code into package.json:
{
  "name": "application-tracker",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.3.3",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}

What does this file define?

  • The scripts object defines commands for development, production builds, and the production server.
  • The dependencies object pins the framework and interface libraries to exact versions.
  • The private setting protects the project from accidental publication as an npm package.
  • Save package.json by pressing Ctrl+S.
  • Select View from the menu bar.
  • Select Terminal.
  • Install the dependencies from package.json by running:
npm install

The first installation can take a minute while npm downloads the packages. When the PowerShell prompt returns, the Explorer sidebar should contain node_modules and package-lock.json.

Dependency installation failed?

Confirm that the terminal path ends with application-tracker. Check that package.json contains valid JSON with matching braces.

Help me diagnose an npm install failure in this Next.js project.

Your project now has its pinned dependencies and runnable scripts. That foundation supports every dashboard feature you add next.

✔️ Awesome, I've got everything!

Great. Save package.json before continuing.

ⓧ I'd like to double check the full code

{
  "name": "application-tracker",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.3.3",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}

This reference matches the complete package.json for this step.

Build the dashboard shell

A Next.js App Router project needs a root layout that wraps every page. This layout also loads the shared stylesheet for the entire dashboard.

  • Select the New Folder icon in the Explorer sidebar.
  • Enter app as the folder name.
  • Select the new app folder.
  • Select the New File icon.
  • Enter layout.js as the file name.
  • Create the root layout by pasting this code into app/layout.js:
import './globals.css'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

What does the root layout do?

  • The stylesheet import applies globals.css to every page inside the app folder.
  • The RootLayout function provides the required HTML document structure.
  • The children value marks the place where each page renders.
  • Save app/layout.js.
  • Confirm that layout.js appears inside the app folder in the Explorer sidebar.

Layout file in the wrong place?

Check that the Explorer shows app/layout.js. A second nested app folder changes the expected path.

Help me check the folder structure for my Next.js root layout.

✔️ Awesome, I've got everything!

Your root layout is ready to wrap the dashboard page.

ⓧ I'd like to double check the full code

import './globals.css'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

This reference matches the complete app/layout.js for this step.

The shared stylesheet starts with page-wide color and typography rules. These defaults give the first browser run a clean foundation.

  • Select the app folder in the Explorer sidebar.
  • Select the New File icon.
  • Enter globals.css as the file name.
  • Add the global foundation by pasting this code into app/globals.css:
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  background: #f5f7fb;
  color: #172033;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, rgba(95, 91, 255, 0.16), transparent 32rem),
    #f5f7fb;
}

button,
input,
select {
  font: inherit;
}

button {
  cursor: pointer;
}

What do these global styles control?

  • The :root rules establish the dashboard font and base colors.
  • The universal selector keeps element dimensions predictable.
  • The body rules create the page background.
  • The control rules keep future form elements consistent with the page typography.
  • Save app/globals.css.
  • Confirm that globals.css appears beside layout.js in the Explorer sidebar.

Stylesheet import showing an error?

Confirm that globals.css and layout.js are inside the same app folder. Check that the import uses the exact relative path from the code block.

Help me fix the stylesheet import in app/layout.js.

Render and run the dashboard

The page component supplies the content that appears inside the root layout. You will start with the hero and summary cards so the first server run produces a visible result.

  • Select the app folder in the Explorer sidebar.
  • Select the New File icon.
  • Enter page.js as the file name.
  • Create the first page view by pasting this code into app/page.js:
const sampleApplication = {
  company: 'Acme',
  role: 'Software Engineer',
  status: 'Applied',
  applied_at: '2026-01-15',
}

export default function Home() {
  return (
    <main className="page-shell">
      <section className="hero">
        <p className="eyebrow">Career command center</p>
        <h1>Application Tracker</h1>
        <p>Keep every opportunity, deadline, and interview stage in one focused dashboard.</p>
      </section>
      <section className="stats" aria-label="Application summary">
        <article><span>Total</span><strong>1</strong></article>
        <article><span>Interviews</span><strong>0</strong></article>
        <article><span>Offers</span><strong>0</strong></article>
      </section>
    </main>
  )
}

What does this page render?

  • The sampleApplication object holds the fictional record used later in the page.
  • The Home component renders the main dashboard page.
  • The summary section shows one total application with no interviews or offers.
  • Save app/page.js.
  • Select View from the menu bar.
  • Select Terminal.

The development server keeps control of this terminal while it runs. You can stop it later by pressing Ctrl+C.

Before you start the server, what dashboard content do you expect the browser to show from the page component?

  • Start the Next.js development server by running:
npm run dev

The first run can take a short moment while Next.js prepares the development build. Keep this terminal running for the rest of the step.

  • Press the Windows key to open Search.
  • Type the name of your preferred browser.
  • Press Enter to open the browser.
  • Enter http://localhost:3000 in the address bar.
  • Press Enter.

You should see the Application Tracker heading and three summary values. The page background should use a pale gradient.

Dashboard not loading?

Confirm that the terminal is still running from the application-tracker folder. Check the terminal for a file path that points to app/page.js or app/layout.js.

Help me diagnose why my local Next.js dashboard is not loading.

The summary proves that the browser can render your local Next.js project. The next addition turns this shell into a recognizable application pipeline.

  • Return to app/page.js in Visual Studio Code.
  • Find the closing </section> immediately after the three summary cards.
  • Add the dashboard workspace below that closing tag by pasting:
      <section className="workspace">
        <section className="application-form">
          <p className="section-label">New opportunity</p>
          <h2>Add an application</h2>
          <p>Interactive form controls arrive in the next step.</p>
        </section>
        <section className="pipeline">
          <p className="section-label">Your pipeline</p>
          <h2>Applications</h2>
          <div className="application-list">
            <article className="application-card">
              <div><h3>{sampleApplication.role}</h3><p>{sampleApplication.company}</p></div>
              <div className="card-meta"><span className="status status-applied">{sampleApplication.status}</span><time dateTime={sampleApplication.applied_at}>{sampleApplication.applied_at}</time></div>
            </article>
          </div>
        </section>
      </section>

How does the workspace use the sample?

  • The left section reserves space for the form you build in the next step.
  • The pipeline reads the role and company from sampleApplication.
  • The status badge and date complete the first application card.
  • Save app/page.js.
  • Return to http://localhost:3000 in your browser.
  • Refresh the page.

You should see an Add an application panel beside an Applications panel. The Applications panel should show the Acme Software Engineer record.

Sample application missing?

Confirm that the workspace section sits inside <main className="page-shell">. Check that every opening section has a matching closing tag.

Help me find a JSX structure error in app/page.js.

✔️ Awesome, I've got everything!

Your page now contains the complete static dashboard structure.

ⓧ I'd like to double check the full code

const sampleApplication = {
  company: 'Acme',
  role: 'Software Engineer',
  status: 'Applied',
  applied_at: '2026-01-15',
}

export default function Home() {
  return (
    <main className="page-shell">
      <section className="hero">
        <p className="eyebrow">Career command center</p>
        <h1>Application Tracker</h1>
        <p>Keep every opportunity, deadline, and interview stage in one focused dashboard.</p>
      </section>
      <section className="stats" aria-label="Application summary">
        <article><span>Total</span><strong>1</strong></article>
        <article><span>Interviews</span><strong>0</strong></article>
        <article><span>Offers</span><strong>0</strong></article>
      </section>
      <section className="workspace">
        <section className="application-form">
          <p className="section-label">New opportunity</p>
          <h2>Add an application</h2>
          <p>Interactive form controls arrive in the next step.</p>
        </section>
        <section className="pipeline">
          <p className="section-label">Your pipeline</p>
          <h2>Applications</h2>
          <div className="application-list">
            <article className="application-card">
              <div><h3>{sampleApplication.role}</h3><p>{sampleApplication.company}</p></div>
              <div className="card-meta"><span className="status status-applied">{sampleApplication.status}</span><time dateTime={sampleApplication.applied_at}>{sampleApplication.applied_at}</time></div>
            </article>
          </div>
        </section>
      </section>
    </main>
  )
}

This reference matches the complete app/page.js for this step.

The dashboard structure is visible. The remaining CSS turns its sections into a responsive product interface.

  • Return to app/globals.css.
  • Add the page shell and section labels below the existing button rule by pasting:
.page-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 48px 0 72px;
}

.hero {
  max-width: 720px;
}

.eyebrow,
.section-label {
  margin: 0 0 8px;
  color: #5b55db;
  font-size: 0.76rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

How does the shell create focus?

The page shell centers the dashboard within a maximum width. The label styles create a consistent visual cue above major headings.

  • Save app/globals.css.
  • Refresh the browser.

The dashboard should now sit inside a centered column with purple uppercase section labels.

Shell still touching the edges?

Check that the page uses className="page-shell". Confirm that the CSS selector begins with a full stop.

Help me connect the page-shell class to its CSS styles.

  • Add the heading and hero typography below the section-label rules by pasting:
h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 12px;
  font-size: clamp(2.4rem, 6vw, 4.6rem);
  line-height: 0.98;
  letter-spacing: -0.055em;
}

.hero > p:last-child {
  color: #5d6678;
  font-size: 1.05rem;
  line-height: 1.7;
}

How does the hero typography work?

The heading uses a responsive size that grows with the viewport. The supporting sentence uses a softer color and wider line spacing.

  • Save app/globals.css.
  • Refresh the browser.

The Application Tracker heading should become larger. Its supporting sentence should become easier to read.

Heading size unchanged?

Check that the page heading uses an h1 element. Confirm that the new rules appear after the section-label block.

Help me find why my h1 CSS rule is not applying.

  • Add the summary grid and shared card surfaces below the hero rules by pasting:
.stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  margin: 32px 0 24px;
}

.stats article,
.application-form,
.pipeline {
  border: 1px solid #e1e5ee;
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.94);
  box-shadow: 0 16px 40px rgba(31, 42, 68, 0.08);
}

.stats article {
  padding: 20px;
}

How do the cards share one design?

The grouped selector gives the summary cards and workspace panels the same border treatment. The grid keeps the three summary values aligned.

  • Save app/globals.css.
  • Refresh the browser.

The three summary values should appear in separate white cards. The two workspace sections should gain matching rounded surfaces.

Cards still look like plain text?

Confirm that each summary value is inside an article element within the stats section. Check the commas in the grouped selector.

Help me diagnose why the dashboard card styles are missing.

  • Add the summary typography and workspace columns below the card rules by pasting:
.stats span {
  display: block;
  color: #71798a;
  font-size: 0.84rem;
}

.stats strong {
  display: block;
  margin-top: 4px;
  font-size: 2rem;
}

.workspace {
  display: grid;
  grid-template-columns: minmax(280px, 0.85fr) minmax(0, 1.5fr);
  gap: 24px;
  align-items: start;
}

.application-form,
.pipeline {
  padding: 24px;
}

How does the workspace divide the page?

The summary numbers gain stronger visual weight. The workspace grid gives the application pipeline more room than the form panel.

  • Save app/globals.css.
  • Refresh the browser.

The summary numbers should look larger. The workspace should form a narrow left column and a wider right column.

Workspace panels still stacked?

Confirm that both panels sit inside <section className="workspace">. Check that the browser window is wider than the responsive breakpoint.

Help me diagnose why the workspace grid is not forming two columns.

  • Add the form panel spacing and future label styles below the panel padding rules by pasting:
.application-form {
  display: grid;
  gap: 16px;
}

.application-form h2,
.pipeline h2 {
  margin-bottom: 0;
}

label {
  display: grid;
  gap: 7px;
  color: #454d5f;
  font-size: 0.86rem;
  font-weight: 700;
}

Why prepare form styles now?

The form panel already benefits from consistent spacing. The label rules prepare the same panel for the interactive controls added in the next step.

  • Save app/globals.css.
  • Refresh the browser.

The content inside the Add an application panel should gain more breathing room.

Form panel spacing unchanged?

Confirm that the left panel uses className="application-form". Check that the selector includes the leading full stop.

Help me connect the application-form class to its CSS rule.

  • Add the future input and select styles below the label rules by pasting:
input,
select {
  width: 100%;
  border: 1px solid #ccd2df;
  border-radius: 11px;
  background: #ffffff;
  color: #172033;
  padding: 11px 12px;
  outline: none;
}

input:focus,
select:focus {
  border-color: #655ee8;
  box-shadow: 0 0 0 3px rgba(101, 94, 232, 0.14);
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

What do these control rules prepare?

The input and select rules establish consistent dimensions for the upcoming form. The focus treatment makes the active field easier to identify.

  • Save app/globals.css.
  • Check the bottom of the file for the input and .form-row selectors.

Control rules showing CSS errors?

Check that each selector block has one opening brace and one closing brace. Confirm that the input:focus selector includes its colon.

Help me find a syntax error in the form control CSS.

  • Add the future button and notice styles below the form-row rules by pasting:
.application-form button {
  border: 0;
  border-radius: 11px;
  background: #5b55db;
  color: #ffffff;
  padding: 12px 16px;
  font-weight: 800;
}

.application-form button:hover {
  background: #4c46c6;
}

.notice {
  min-height: 20px;
  margin: -4px 0 0;
  color: #626b7d;
  font-size: 0.84rem;
}

What feedback styles are ready?

The button rules define a clear primary action for the upcoming form. The notice area reserves space for loading and save feedback.

  • Save app/globals.css.
  • Check the bottom of the file for the .application-form button and .notice selectors.

Button rules showing CSS errors?

Confirm that the hover selector contains one colon. Check that every declaration ends with a colon-separated value.

Help me find a syntax error in the button or notice CSS.

  • Add the pipeline layout and application card structure below the notice rules by pasting:
.pipeline-heading {
  display: flex;
  gap: 20px;
  align-items: end;
  justify-content: space-between;
  margin-bottom: 20px;
}

.filters {
  display: grid;
  grid-template-columns: minmax(170px, 1fr) minmax(130px, 0.65fr);
  gap: 10px;
}

.application-list {
  display: grid;
  gap: 12px;
}

.application-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  border: 1px solid #e6e9f0;
  border-radius: 14px;
  padding: 16px;
}

How does the application card take shape?

The pipeline rules prepare space for future filters. The application card uses flexible alignment to separate job details from status metadata.

  • Save app/globals.css.
  • Refresh the browser.

The Acme application should now sit inside its own bordered card. Its details and status should appear on opposite sides.

Application card still unstyled?

Confirm that the record uses className="application-card". Check that the card rules sit outside the preceding .notice block.

Help me diagnose why the application-card styles are not appearing.

  • Add the card typography and status badge shape below the application-card rules by pasting:
.application-card h3 {
  margin-bottom: 4px;
  font-size: 1rem;
}

.application-card p,
.application-card time {
  margin-bottom: 0;
  color: #737b8c;
  font-size: 0.86rem;
}

.card-meta {
  display: grid;
  justify-items: end;
  gap: 8px;
}

.status {
  border-radius: 999px;
  padding: 5px 9px;
  font-size: 0.75rem;
  font-weight: 800;
}

How does the card establish hierarchy?

The role remains the strongest text inside the card. The company and date use a quieter tone while the status takes the shape of a compact badge.

  • Save app/globals.css.
  • Refresh the browser.

The Software Engineer role should stand above the company name. The Applied status should appear as a rounded badge beside the date.

Status still looks like plain text?

Confirm that the span includes the status class. Check that .status includes its leading full stop.

Help me diagnose why the status badge shape is missing.

  • Add the status colors and empty-state design below the status rules by pasting:
.status-applied {
  background: #e9edff;
  color: #4540a9;
}

.status-interview {
  background: #fff0cb;
  color: #855b00;
}

.status-offer {
  background: #d9f7e7;
  color: #187445;
}

.status-rejected {
  background: #ffe2e2;
  color: #a23333;
}

.empty-state {
  border: 1px dashed #cbd1dd;
  border-radius: 14px;
  color: #687184;
  padding: 30px;
  text-align: center;
}

Why define every status color?

Each workflow status receives a distinct color treatment. The empty-state rule prepares the pipeline to explain when no records match a future filter.

  • Save app/globals.css.
  • Refresh the browser.

The Applied badge should now use a pale purple background with dark purple text.

Applied badge has no color?

Confirm that the badge includes both status and status-applied classes. Check the hyphen in .status-applied.

Help me diagnose why the status-applied colors are not showing.

  • Finish the stylesheet with responsive rules by pasting this code below the empty-state block:
@media (max-width: 820px) {
  .workspace {
    grid-template-columns: 1fr;
  }

  .pipeline-heading {
    align-items: stretch;
    flex-direction: column;
  }
}

@media (max-width: 560px) {
  .stats,
  .form-row,
  .filters {
    grid-template-columns: 1fr;
  }

  .application-card {
    align-items: flex-start;
    flex-direction: column;
  }

  .card-meta {
    justify-items: start;
  }
}

How does the dashboard adapt?

The first breakpoint stacks the two workspace panels. The smaller breakpoint also stacks summary cards and application metadata for narrow screens.

  • Save app/globals.css.
  • Refresh the browser.
  • Narrow the browser window until the dashboard changes to one column.

The workspace panels should stack vertically. At a narrower width, the summary cards and application metadata should also stack.

Dashboard not responding to width changes?

Confirm that both media blocks appear at the bottom of app/globals.css. Check every closing brace near the end of the file.

Help me debug the responsive CSS breakpoints.

✔️ Awesome, I've got everything!

Your dashboard stylesheet is complete. Save the file before the final check.

ⓧ I'd like to double check the full code

:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  background: #f5f7fb;
  color: #172033;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top left, rgba(95, 91, 255, 0.16), transparent 32rem),
    #f5f7fb;
}

button,
input,
select {
  font: inherit;
}

button {
  cursor: pointer;
}

.page-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 48px 0 72px;
}

.hero {
  max-width: 720px;
}

.eyebrow,
.section-label {
  margin: 0 0 8px;
  color: #5b55db;
  font-size: 0.76rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 12px;
  font-size: clamp(2.4rem, 6vw, 4.6rem);
  line-height: 0.98;
  letter-spacing: -0.055em;
}

.hero > p:last-child {
  color: #5d6678;
  font-size: 1.05rem;
  line-height: 1.7;
}

.stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  margin: 32px 0 24px;
}

.stats article,
.application-form,
.pipeline {
  border: 1px solid #e1e5ee;
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.94);
  box-shadow: 0 16px 40px rgba(31, 42, 68, 0.08);
}

.stats article {
  padding: 20px;
}

.stats span {
  display: block;
  color: #71798a;
  font-size: 0.84rem;
}

.stats strong {
  display: block;
  margin-top: 4px;
  font-size: 2rem;
}

.workspace {
  display: grid;
  grid-template-columns: minmax(280px, 0.85fr) minmax(0, 1.5fr);
  gap: 24px;
  align-items: start;
}

.application-form,
.pipeline {
  padding: 24px;
}

.application-form {
  display: grid;
  gap: 16px;
}

.application-form h2,
.pipeline h2 {
  margin-bottom: 0;
}

label {
  display: grid;
  gap: 7px;
  color: #454d5f;
  font-size: 0.86rem;
  font-weight: 700;
}

input,
select {
  width: 100%;
  border: 1px solid #ccd2df;
  border-radius: 11px;
  background: #ffffff;
  color: #172033;
  padding: 11px 12px;
  outline: none;
}

input:focus,
select:focus {
  border-color: #655ee8;
  box-shadow: 0 0 0 3px rgba(101, 94, 232, 0.14);
}

.form-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.application-form button {
  border: 0;
  border-radius: 11px;
  background: #5b55db;
  color: #ffffff;
  padding: 12px 16px;
  font-weight: 800;
}

.application-form button:hover {
  background: #4c46c6;
}

.notice {
  min-height: 20px;
  margin: -4px 0 0;
  color: #626b7d;
  font-size: 0.84rem;
}

.pipeline-heading {
  display: flex;
  gap: 20px;
  align-items: end;
  justify-content: space-between;
  margin-bottom: 20px;
}

.filters {
  display: grid;
  grid-template-columns: minmax(170px, 1fr) minmax(130px, 0.65fr);
  gap: 10px;
}

.application-list {
  display: grid;
  gap: 12px;
}

.application-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  border: 1px solid #e6e9f0;
  border-radius: 14px;
  padding: 16px;
}

.application-card h3 {
  margin-bottom: 4px;
  font-size: 1rem;
}

.application-card p,
.application-card time {
  margin-bottom: 0;
  color: #737b8c;
  font-size: 0.86rem;
}

.card-meta {
  display: grid;
  justify-items: end;
  gap: 8px;
}

.status {
  border-radius: 999px;
  padding: 5px 9px;
  font-size: 0.75rem;
  font-weight: 800;
}

.status-applied {
  background: #e9edff;
  color: #4540a9;
}

.status-interview {
  background: #fff0cb;
  color: #855b00;
}

.status-offer {
  background: #d9f7e7;
  color: #187445;
}

.status-rejected {
  background: #ffe2e2;
  color: #a23333;
}

.empty-state {
  border: 1px dashed #cbd1dd;
  border-radius: 14px;
  color: #687184;
  padding: 30px;
  text-align: center;
}

@media (max-width: 820px) {
  .workspace {
    grid-template-columns: 1fr;
  }

  .pipeline-heading {
    align-items: stretch;
    flex-direction: column;
  }
}

@media (max-width: 560px) {
  .stats,
  .form-row,
  .filters {
    grid-template-columns: 1fr;
  }

  .application-card {
    align-items: flex-start;
    flex-direction: column;
  }

  .card-meta {
    justify-items: start;
  }
}

This reference matches the complete app/globals.css for this step.

Before the final check, which heading, summary values, and sample record do you expect to remain visible after a refresh?

  • Return the browser window to its normal width.
  • Refresh http://localhost:3000.
  • Confirm that the page shows the Application Tracker heading.
  • Confirm that the summary cards show one total application.
  • Confirm that the pipeline shows Acme and Software Engineer with an Applied badge.

That is your first visible product milestone. The Next.js development server is running a responsive dashboard with a real application-shaped interface.

Your static dashboard is ready. Next, you will replace the fixed content with React state and an application form.

Make the Tracker Interactive

Your static dashboard is running in the browser. Its form area still cannot capture an application.

Now React state will connect the form to the dashboard. A refresh at the end will test how far that state reaches.

In this step, get ready to:
  • Convert app/page.js into a client component with a controlled form.
  • Render the dashboard from application state.
  • Test the browser refresh boundary.
Turn the page into a client component

A client component can respond to browser events. The useState hook lets the component remember values while it remains loaded.

  • In the Visual Studio Code Explorer sidebar, select app/page.js.
  • Select the existing object from const sampleApplication = { through its closing brace.
  • Replace the selected object with the client setup by pasting this code:
'use client'

import { useState } from 'react'

const EMPTY_FORM = {
  company: '',
  role: '',
  status: 'Applied',
  appliedAt: '',
}

const STATUS_OPTIONS = ['Applied', 'Interview', 'Offer', 'Rejected']

const SAMPLE_APPLICATION = {
  id: 1,
  company: 'Acme',
  role: 'Software Engineer',
  status: 'Applied',
  applied_at: '2026-01-15',
}

What does this setup do?

  • The 'use client' directive enables state plus event handling inside the page.
  • EMPTY_FORM stores the blank values used by the form.
  • STATUS_OPTIONS stores the four workflow stages shown by the status field.
  • SAMPLE_APPLICATION provides the record loaded whenever this component starts.
  • Change every remaining lowercase sampleApplication reference to uppercase SAMPLE_APPLICATION.
  • Save app/page.js.
  • Return to the browser tab at http://localhost:3000.

You should see the Application Tracker heading with the original Acme card. A total of 1 confirms that the new client boundary compiles.

Seeing a compilation overlay?

Check that 'use client' is the first line in app/page.js. Confirm that every sample reference uses SAMPLE_APPLICATION.

Help me check the client setup in app/page.js.

Connect the form to state

A controlled form reads each field from state. Every field change updates the matching value inside one form object.

  • Inside Home(), place your cursor immediately after its opening brace.
  • Add the application state plus form handlers by pasting this code:
  const [applications, setApplications] = useState([SAMPLE_APPLICATION])
  const [form, setForm] = useState(EMPTY_FORM)
  const [notice, setNotice] = useState('')

  function updateForm(event) {
    const { name, value } = event.target
    setForm((current) => ({ ...current, [name]: value }))
  }

  function handleSubmit(event) {
    event.preventDefault()
    const application = {
      id: Date.now(),
      company: form.company,
      role: form.role,
      status: form.status,
      applied_at: form.appliedAt,
    }
    setApplications((current) => [application, ...current])
    setForm(EMPTY_FORM)
    setNotice('Application added to temporary browser state.')
  }

  const interviewCount = applications.filter(
    (application) => application.status === 'Interview'
  ).length
  const offerCount = applications.filter(
    (application) => application.status === 'Offer'
  ).length

How does the state flow work?

  • applications holds the records displayed by the dashboard.
  • form holds the current values from the four form fields.
  • updateForm() updates the field named by each input event.
  • handleSubmit() creates a new application from the current form values.
  • setApplications() places the new record at the start of the list.
  • interviewCount calculates the number of applications at the Interview stage.
  • offerCount calculates the number of applications at the Offer stage.
  • Select the existing stats section in app/page.js.
  • Connect the summary cards to state by replacing the selected section with this code:
      <section className="stats" aria-label="Application summary">
        <article><span>Total</span><strong>{applications.length}</strong></article>
        <article><span>Interviews</span><strong>{interviewCount}</strong></article>
        <article><span>Offers</span><strong>{offerCount}</strong></article>
      </section>

What do the counters measure?

  • applications.length displays the total number of records in state.
  • interviewCount displays the number of Interview records.
  • offerCount displays the number of Offer records.
  • Save app/page.js.
  • Return to the browser tab at http://localhost:3000.

You should see Total at 1. Interviews plus Offers should each show 0.

Is the page failing to render?

Make sure the new state logic sits inside Home() before the existing return statement. Check that each function has its closing brace.

Help me find the syntax problem in my state logic.

  • Select the existing application-form section in app/page.js.
  • Replace the placeholder form area with this controlled form:
        <form className="application-form" onSubmit={handleSubmit}>
          <div><p className="section-label">New opportunity</p><h2>Add an application</h2></div>
          <label>Company<input name="company" value={form.company} onChange={updateForm} required /></label>
          <label>Role<input name="role" value={form.role} onChange={updateForm} required /></label>
          <div className="form-row">
            <label>Status<select name="status" value={form.status} onChange={updateForm}>{STATUS_OPTIONS.map((status) => <option key={status} value={status}>{status}</option>)}</select></label>
            <label>Applied date<input type="date" name="appliedAt" value={form.appliedAt} onChange={updateForm} required /></label>
          </div>
          <button type="submit">Save application</button>
          <p className="notice" aria-live="polite">{notice}</p>
        </form>

What does the form connect?

  • Each field reads its current value from form.
  • Each field sends changes through updateForm().
  • onSubmit sends the completed form to handleSubmit().
  • notice provides visible feedback after submission.
  • Select the existing pipeline section in app/page.js.
  • Render every application from state by replacing the selected section with this code:
        <section className="pipeline">
          <div className="pipeline-heading"><div><p className="section-label">Your pipeline</p><h2>Applications</h2></div></div>
          <div className="application-list">
            {applications.length === 0 ? <p className="empty-state">No applications match this view.</p> : applications.map((application) => (
              <article className="application-card" key={application.id}>
                <div><h3>{application.role}</h3><p>{application.company}</p></div>
                <div className="card-meta"><span className={`status status-${application.status.toLowerCase()}`}>{application.status}</span><time dateTime={application.applied_at}>{application.applied_at}</time></div>
              </article>
            ))}
          </div>
        </section>

How does the list render?

  • applications.map() creates one card for each application in state.
  • application.id gives each card a unique rendering key.
  • application.status.toLowerCase() selects the matching status colour from the existing stylesheet.
  • The empty-state message covers any future view with no application records.
  • Save app/page.js.
  • Return to the browser tab at http://localhost:3000.

Before you submit, make a quick prediction about which summary counts will change.

  • Enter Acme Labs in the Company field.
  • Enter Frontend Developer in the Role field.
  • Select Interview in the Status field.
  • Choose 2026-08-30 in the Applied date field.
  • Click Save application.

You should see the Acme Labs card at the top of the pipeline. Total should show 2.

Interviews should show 1. The notice should confirm that the application entered temporary browser state.

That interaction loop is working. One form submission now updates the card list plus its summary immediately.

Did the new card stay missing?

Check that the form uses onSubmit={handleSubmit}. Confirm that each input name matches a key in EMPTY_FORM.

Help me trace why the form is not updating state.

✔️ Awesome, I've got everything!

Your interactive page is complete. Save app/page.js before testing the refresh boundary.

ⓧ I'd like to double check the full code

'use client'

import { useState } from 'react'

const EMPTY_FORM = {
  company: '',
  role: '',
  status: 'Applied',
  appliedAt: '',
}

const STATUS_OPTIONS = ['Applied', 'Interview', 'Offer', 'Rejected']

const SAMPLE_APPLICATION = {
  id: 1,
  company: 'Acme',
  role: 'Software Engineer',
  status: 'Applied',
  applied_at: '2026-01-15',
}

export default function Home() {
  const [applications, setApplications] = useState([SAMPLE_APPLICATION])
  const [form, setForm] = useState(EMPTY_FORM)
  const [notice, setNotice] = useState('')

  function updateForm(event) {
    const { name, value } = event.target
    setForm((current) => ({ ...current, [name]: value }))
  }

  function handleSubmit(event) {
    event.preventDefault()
    const application = {
      id: Date.now(),
      company: form.company,
      role: form.role,
      status: form.status,
      applied_at: form.appliedAt,
    }
    setApplications((current) => [application, ...current])
    setForm(EMPTY_FORM)
    setNotice('Application added to temporary browser state.')
  }

  const interviewCount = applications.filter(
    (application) => application.status === 'Interview'
  ).length
  const offerCount = applications.filter(
    (application) => application.status === 'Offer'
  ).length

  return (
    <main className="page-shell">
      <section className="hero">
        <p className="eyebrow">Career command center</p>
        <h1>Application Tracker</h1>
        <p>Keep every opportunity, deadline, and interview stage in one focused dashboard.</p>
      </section>
      <section className="stats" aria-label="Application summary">
        <article><span>Total</span><strong>{applications.length}</strong></article>
        <article><span>Interviews</span><strong>{interviewCount}</strong></article>
        <article><span>Offers</span><strong>{offerCount}</strong></article>
      </section>
      <section className="workspace">
        <form className="application-form" onSubmit={handleSubmit}>
          <div><p className="section-label">New opportunity</p><h2>Add an application</h2></div>
          <label>Company<input name="company" value={form.company} onChange={updateForm} required /></label>
          <label>Role<input name="role" value={form.role} onChange={updateForm} required /></label>
          <div className="form-row">
            <label>Status<select name="status" value={form.status} onChange={updateForm}>{STATUS_OPTIONS.map((status) => <option key={status} value={status}>{status}</option>)}</select></label>
            <label>Applied date<input type="date" name="appliedAt" value={form.appliedAt} onChange={updateForm} required /></label>
          </div>
          <button type="submit">Save application</button>
          <p className="notice" aria-live="polite">{notice}</p>
        </form>
        <section className="pipeline">
          <div className="pipeline-heading"><div><p className="section-label">Your pipeline</p><h2>Applications</h2></div></div>
          <div className="application-list">
            {applications.length === 0 ? <p className="empty-state">No applications match this view.</p> : applications.map((application) => (
              <article className="application-card" key={application.id}>
                <div><h3>{application.role}</h3><p>{application.company}</p></div>
                <div className="card-meta"><span className={`status status-${application.status.toLowerCase()}`}>{application.status}</span><time dateTime={application.applied_at}>{application.applied_at}</time></div>
              </article>
            ))}
          </div>
        </section>
      </section>
    </main>
  )
}
Test the refresh boundary

The Acme Labs application currently belongs to the running component. A full page reload tests whether another part of the system stores that record.

Before you refresh, make a quick prediction about whether the Acme Labs card will remain in the pipeline.

  • Refresh http://localhost:3000 using the browser's Refresh control.

The Acme Labs card disappears. You should see the original Acme sample with Total restored to 1.

Why did the application disappear?

The submitted record existed only inside the component's state. Reloading the page created a fresh component from SAMPLE_APPLICATION.

This reset is the intended persistence gap. A server-side storage layer can keep records beyond one page session.

Your tracker can collect applications plus update its dashboard immediately. Next, an API backed by SQLite will make those records survive a refresh.

Persist Applications with an API and SQLite

Your dashboard can add applications through React state. A browser refresh still erases every new record because that state only lives in the current page session.

This step moves each record through a server-side HTTP API into SQLite. The file-backed database gives the dashboard a source of data that survives refreshes.

Why SQLite for this tracker?

SQLite stores the tracker data in one local file. It gives you SQL persistence without requiring a separate database server.

The browser talks to a Next.js Route Handler. Only the server-side code opens the database file.

In this step, get ready to:
  • Install the SQLite database driver.
  • Create an API that validates application records.
  • Connect the dashboard to persistent records.
Build the SQLite storage layer

The better-sqlite3 package lets server-side JavaScript open a SQLite file. Version 13.0.3 provides the prepared statement methods used by the storage layer.

  • In package.json, replace the current contents with this manifest:
{
  "name": "application-tracker",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "better-sqlite3": "13.0.3",
    "next": "16.3.3",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}

What does this manifest change?

  • The new dependency pins the database driver to version 13.0.3.
  • The existing scripts continue to run the development server and production build.
  • The framework packages remain pinned to the versions already used by the project.
  • Save package.json.
  • Create a second terminal in Visual Studio Code so the existing development server can keep running.
  • Install the dependency listed in the updated manifest by running:
npm install

What does this command do?

The command reads package.json. It installs the listed packages into the existing node_modules folder.

It also updates package-lock.json with the resolved dependency tree.

The terminal returns to its prompt when the installation finishes. Your project can now import the SQLite driver.

Database driver did not install?

  • Check that the terminal is inside the application-tracker folder.
  • Confirm that package.json contains the exact better-sqlite3 dependency entry.

Ask for help with the installation details here.

✔️ Awesome, I've got everything!

Your manifest now includes the database driver. Keep the development server running for the next check.

ⓧ I'd like to double check the full code

Compare your complete package.json with this reference.

{
  "name": "application-tracker",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "better-sqlite3": "13.0.3",
    "next": "16.3.3",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}

The storage module owns the database connection and its SQL statements. Keeping that work in lib/db.js gives the API one place to read or create records.

  • In the Visual Studio Code file tree, create a lib folder inside application-tracker.
  • Inside lib, create a db.js file.
  • Set up the database schema and read query by pasting this code into lib/db.js:
import Database from 'better-sqlite3'

const db = new Database('applications.db')
db.pragma('journal_mode = WAL')

db.exec(`
  CREATE TABLE IF NOT EXISTS applications (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    company TEXT NOT NULL,
    role TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'Applied',
    applied_at TEXT NOT NULL
  )
`)

export function listApplications() {
  return db
    .prepare(
      `SELECT id, company, role, status, applied_at
       FROM applications
       ORDER BY id DESC`
    )
    .all()
}

What does this database code do?

  • The database constructor opens applications.db. It creates that file when the file does not exist.
  • The journal setting enables write-ahead logging for the database.
  • The schema stores the company and role for each application. It also stores the workflow status and application date.
  • The listApplications() function returns the newest records first.
  • Save lib/db.js.
  • Confirm that db.js is listed inside the lib folder.

Database file missing from the folder?

The database file is created when the server imports lib/db.js. The API check in the next substep triggers that import.

Ask for help if db.js is missing from the file tree.

A prepared insert statement sends each form value separately from the SQL instruction. The follow-up query returns the complete row that the database created.

  • Add the create function below listApplications() in lib/db.js by pasting:
export function createApplication({ company, role, status, appliedAt }) {
  const result = db
    .prepare(
      `INSERT INTO applications (company, role, status, applied_at)
       VALUES (?, ?, ?, ?)`
    )
    .run(company, role, status, appliedAt)

  return db
    .prepare(
      `SELECT id, company, role, status, applied_at
       FROM applications
       WHERE id = ?`
    )
    .get(result.lastInsertRowid)
}

How does the insert stay predictable?

  • The question marks reserve positions for the four application values.
  • The run() call binds the values in the same order.
  • The returned insert ID identifies the row that was just created.
  • The final prepared statement reads that row back for the API response.
  • Save lib/db.js.
  • Confirm that the file now exports listApplications() and createApplication().

Seeing a database code warning?

  • Check that both exported functions sit outside the schema template string.
  • Check that the applications table name uses the same spelling in every query.

Ask for help reviewing the module structure.

✔️ Awesome, I've got everything!

Your storage module now defines the schema and both database operations. Save the file before moving to the API.

ⓧ I'd like to double check the full code

Compare your complete lib/db.js with this reference.

import Database from 'better-sqlite3'

const db = new Database('applications.db')
db.pragma('journal_mode = WAL')

db.exec(`
  CREATE TABLE IF NOT EXISTS applications (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    company TEXT NOT NULL,
    role TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'Applied',
    applied_at TEXT NOT NULL
  )
`)

export function listApplications() {
  return db
    .prepare(
      `SELECT id, company, role, status, applied_at
       FROM applications
       ORDER BY id DESC`
    )
    .all()
}

export function createApplication({ company, role, status, appliedAt }) {
  const result = db
    .prepare(
      `INSERT INTO applications (company, role, status, applied_at)
       VALUES (?, ?, ?, ?)`
    )
    .run(company, role, status, appliedAt)

  return db
    .prepare(
      `SELECT id, company, role, status, applied_at
       FROM applications
       WHERE id = ?`
    )
    .get(result.lastInsertRowid)
}
Create the applications API

A Route Handler gives the browser a controlled path to the database. Its read handler returns stored rows. Its write handler validates the submitted values before creating a row.

  • Inside the existing app folder, create an api folder.
  • Inside api, create an applications folder.
  • Inside applications, create a route.js file.
  • Build the read and validated create endpoints by pasting this code into app/api/applications/route.js:
import { createApplication, listApplications } from '../../../lib/db.js'

export const runtime = 'nodejs'

const ALLOWED_STATUSES = new Set(['Applied', 'Interview', 'Offer', 'Rejected'])

export async function GET() {
  return Response.json(listApplications())
}

export async function POST(request) {
  const body = await request.json()
  const company = typeof body.company === 'string' ? body.company.trim() : ''
  const role = typeof body.role === 'string' ? body.role.trim() : ''
  const appliedAt = typeof body.appliedAt === 'string' ? body.appliedAt.trim() : ''
  const status = ALLOWED_STATUSES.has(body.status) ? body.status : 'Applied'

  if (!company || !role || !appliedAt) {
    return Response.json(
      { error: 'Company, role, and applied date are required.' },
      { status: 400 }
    )
  }

  const application = createApplication({ company, role, status, appliedAt })
  return Response.json(application, { status: 201 })
}

What does this API do?

  • The runtime setting keeps the database code on the Node.js server.
  • The GET() handler returns every stored application as JSON.
  • The POST() handler trims the required text values. It accepts only a status from the allowed set.
  • Invalid required fields produce a validation response. Valid fields produce the newly saved record.
  • Save app/api/applications/route.js.

Before you check the endpoint, what shape do you expect from an application list that has no saved records?

  • Open http://localhost:3000/api/applications in your browser.

You should see an empty JSON array. This request also creates applications.db inside the application-tracker folder.

That is the server boundary working. Your browser can now read the database through a JSON response.

API page did not return JSON?

  • Confirm that the original development server is still running.
  • Check that the route file is located at app/api/applications/route.js.
  • Check that the database module is located at lib/db.js.

Ask for help tracing the API import path.

✔️ Awesome, I've got everything!

The endpoint returns JSON. Your local database file now exists.

ⓧ I'd like to double check the full code

Compare your complete app/api/applications/route.js with this reference.

import { createApplication, listApplications } from '../../../lib/db.js'

export const runtime = 'nodejs'

const ALLOWED_STATUSES = new Set(['Applied', 'Interview', 'Offer', 'Rejected'])

export async function GET() {
  return Response.json(listApplications())
}

export async function POST(request) {
  const body = await request.json()
  const company = typeof body.company === 'string' ? body.company.trim() : ''
  const role = typeof body.role === 'string' ? body.role.trim() : ''
  const appliedAt = typeof body.appliedAt === 'string' ? body.appliedAt.trim() : ''
  const status = ALLOWED_STATUSES.has(body.status) ? body.status : 'Applied'

  if (!company || !role || !appliedAt) {
    return Response.json(
      { error: 'Company, role, and applied date are required.' },
      { status: 400 }
    )
  }

  const application = createApplication({ company, role, status, appliedAt })
  return Response.json(application, { status: 201 })
}
Connect the dashboard to persistence

The API now owns application records. The client component needs an effect to load those records when the page starts.

  • In app/page.js, delete the SAMPLE_APPLICATION object.
  • Replace the code from the top of app/page.js through the current notice state with this updated section:
'use client'

import { useEffect, useState } from 'react'

const EMPTY_FORM = {
  company: '',
  role: '',
  status: 'Applied',
  appliedAt: '',
}

const STATUS_OPTIONS = ['Applied', 'Interview', 'Offer', 'Rejected']

export default function Home() {
  const [applications, setApplications] = useState([])
  const [form, setForm] = useState(EMPTY_FORM)
  const [search, setSearch] = useState('')
  const [statusFilter, setStatusFilter] = useState('All')
  const [notice, setNotice] = useState('Loading applications...')

Why does the list start empty?

  • The sample record is gone because the database becomes the source of saved applications.
  • The empty array gives the page a valid list before the API response arrives.
  • The notice tells you when the initial database request is in progress.
  • The search values control which downloaded records the page displays.
  • Save app/page.js.
  • Return to http://localhost:3000 in your browser.

You should see the dashboard with an empty application list. The notice currently remains on its loading message because the effect comes next.

Dashboard stopped rendering?

  • Check that 'use client' remains on the first line.
  • Check that both hooks are imported from react.
  • Confirm that the opening brace for Home() remains below the state declarations.

Ask for help reviewing the updated client component header.

The loading effect requests the saved rows after the component reaches the browser. Its cleanup flag prevents an outdated request from changing state after the effect has ended.

  • Add this effect immediately below the notice state in app/page.js:
  useEffect(() => {
    let ignore = false

    async function loadApplications() {
      try {
        const response = await fetch('/api/applications')
        if (!response.ok) {
          throw new Error('Could not load applications.')
        }

        const data = await response.json()
        if (!ignore) {
          setApplications(data)
          setNotice('')
        }
      } catch (error) {
        if (!ignore) {
          setNotice(error instanceof Error ? error.message : 'Could not load applications.')
        }
      }
    }

    loadApplications()
    return () => {
      ignore = true
    }
  }, [])

How does loading work?

  • The effect calls the applications endpoint once after the page loads.
  • A successful response replaces the empty list with the stored rows.
  • A failed response places a useful message in the existing notice area.
  • The cleanup function blocks a late response from updating an inactive effect.
  • Save app/page.js.
  • Return to the dashboard in your browser.

The loading notice should clear. You should see the empty-state message because the API returned an empty list.

Loading message stays visible?

  • Check that loadApplications() is called inside the effect.
  • Confirm that the fetch path is /api/applications.
  • Return to the API URL to confirm that it still returns JSON.

Ask for help tracing the loading request.

The submit handler now sends the controlled form as JSON. A successful response contains the database row with its permanent ID.

  • In app/page.js, replace the current handleSubmit() function with this version:
  async function handleSubmit(event) {
    event.preventDefault()
    setNotice('Saving application...')

    try {
      const response = await fetch('/api/applications', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form),
      })
      const data = await response.json()

      if (!response.ok) {
        throw new Error(data.error || 'Could not save the application.')
      }

      setApplications((current) => [data, ...current])
      setForm(EMPTY_FORM)
      setNotice('Application saved.')
    } catch (error) {
      setNotice(error instanceof Error ? error.message : 'Could not save the application.')
    }
  }

How does saving work?

  • The handler sends the form to the same endpoint with a create request.
  • The JSON header tells the server how to read the request body.
  • A successful response is placed at the start of the application list.
  • The notice shows whether the save succeeded or failed.
  • Save app/page.js.
  • Compare the remaining component with the full-file reference below.

✔️ Awesome, I've got everything!

Your client now loads records from the API. New form submissions also travel through the API.

ⓧ I'd like to double check the full code

Make your complete app/page.js match this reference. Save the file after comparing every section.

'use client'

import { useEffect, useState } from 'react'

const EMPTY_FORM = {
  company: '',
  role: '',
  status: 'Applied',
  appliedAt: '',
}

const STATUS_OPTIONS = ['Applied', 'Interview', 'Offer', 'Rejected']

export default function Home() {
  const [applications, setApplications] = useState([])
  const [form, setForm] = useState(EMPTY_FORM)
  const [search, setSearch] = useState('')
  const [statusFilter, setStatusFilter] = useState('All')
  const [notice, setNotice] = useState('Loading applications...')

  useEffect(() => {
    let ignore = false

    async function loadApplications() {
      try {
        const response = await fetch('/api/applications')
        if (!response.ok) {
          throw new Error('Could not load applications.')
        }

        const data = await response.json()
        if (!ignore) {
          setApplications(data)
          setNotice('')
        }
      } catch (error) {
        if (!ignore) {
          setNotice(error instanceof Error ? error.message : 'Could not load applications.')
        }
      }
    }

    loadApplications()
    return () => {
      ignore = true
    }
  }, [])

  function updateForm(event) {
    const { name, value } = event.target
    setForm((current) => ({ ...current, [name]: value }))
  }

  async function handleSubmit(event) {
    event.preventDefault()
    setNotice('Saving application...')

    try {
      const response = await fetch('/api/applications', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form),
      })
      const data = await response.json()

      if (!response.ok) {
        throw new Error(data.error || 'Could not save the application.')
      }

      setApplications((current) => [data, ...current])
      setForm(EMPTY_FORM)
      setNotice('Application saved.')
    } catch (error) {
      setNotice(error instanceof Error ? error.message : 'Could not save the application.')
    }
  }

  const normalizedSearch = search.trim().toLowerCase()
  const visibleApplications = applications.filter((application) => {
    const matchesSearch = `${application.company} ${application.role}`
      .toLowerCase()
      .includes(normalizedSearch)
    const matchesStatus =
      statusFilter === 'All' || application.status === statusFilter

    return matchesSearch && matchesStatus
  })

  const interviewCount = applications.filter(
    (application) => application.status === 'Interview'
  ).length
  const offerCount = applications.filter(
    (application) => application.status === 'Offer'
  ).length

  return (
    <main className="page-shell">
      <section className="hero">
        <p className="eyebrow">Career command center</p>
        <h1>Application Tracker</h1>
        <p>
          Keep every opportunity, deadline, and interview stage in one focused dashboard.
        </p>
      </section>

      <section className="stats" aria-label="Application summary">
        <article>
          <span>Total</span>
          <strong>{applications.length}</strong>
        </article>
        <article>
          <span>Interviews</span>
          <strong>{interviewCount}</strong>
        </article>
        <article>
          <span>Offers</span>
          <strong>{offerCount}</strong>
        </article>
      </section>

      <section className="workspace">
        <form className="application-form" onSubmit={handleSubmit}>
          <div>
            <p className="section-label">New opportunity</p>
            <h2>Add an application</h2>
          </div>

          <label>
            Company
            <input
              name="company"
              value={form.company}
              onChange={updateForm}
              placeholder="Example: Acme"
              required
            />
          </label>

          <label>
            Role
            <input
              name="role"
              value={form.role}
              onChange={updateForm}
              placeholder="Example: Software Engineer"
              required
            />
          </label>

          <div className="form-row">
            <label>
              Status
              <select name="status" value={form.status} onChange={updateForm}>
                {STATUS_OPTIONS.map((status) => (
                  <option key={status} value={status}>
                    {status}
                  </option>
                ))}
              </select>
            </label>

            <label>
              Applied date
              <input
                type="date"
                name="appliedAt"
                value={form.appliedAt}
                onChange={updateForm}
                required
              />
            </label>
          </div>

          <button type="submit">Save application</button>
          <p className="notice" aria-live="polite">{notice}</p>
        </form>

        <section className="pipeline">
          <div className="pipeline-heading">
            <div>
              <p className="section-label">Your pipeline</p>
              <h2>Applications</h2>
            </div>

            <div className="filters">
              <input
                type="search"
                value={search}
                onChange={(event) => setSearch(event.target.value)}
                placeholder="Search company or role"
                aria-label="Search applications"
              />
              <select
                value={statusFilter}
                onChange={(event) => setStatusFilter(event.target.value)}
                aria-label="Filter by status"
              >
                <option value="All">All statuses</option>
                {STATUS_OPTIONS.map((status) => (
                  <option key={status} value={status}>
                    {status}
                  </option>
                ))}
              </select>
            </div>
          </div>

          <div className="application-list">
            {visibleApplications.length === 0 ? (
              <p className="empty-state">No applications match this view.</p>
            ) : (
              visibleApplications.map((application) => (
                <article className="application-card" key={application.id}>
                  <div>
                    <h3>{application.role}</h3>
                    <p>{application.company}</p>
                  </div>
                  <div className="card-meta">
                    <span
                      className={`status status-${application.status
                        .toLowerCase()
                        .replaceAll(' ', '-')}`}
                    >
                      {application.status}
                    </span>
                    <time dateTime={application.applied_at}>
                      {application.applied_at}
                    </time>
                  </div>
                </article>
              ))
            )}
          </div>
        </section>
      </section>
    </main>
  )
}
  • Enter Northstar Labs in the Company field.
  • Enter Frontend Engineer in the Role field.
  • Keep Applied selected in the Status field.
  • Enter 2026-08-30 in the Applied date field.
  • Click Save application.

You should see the new card at the top of the list. The notice should read Application saved..

Before you refresh, do you expect the new card to disappear again?

  • Refresh http://localhost:3000.

The Northstar Labs application should remain in the list. That persistence proves the page loaded the record from SQLite.

  • Return to http://localhost:3000/api/applications.

You should see a JSON array containing the Northstar Labs record. The API response and refreshed card now represent the same stored row.

Application disappears after refresh?

  • Confirm that the save notice reported a successful application save.
  • Open the API URL to check whether the record exists in the database response.
  • Check that handleSubmit() sends the form to /api/applications.

Ask for help tracing the record from the form to SQLite.

Your tracker now saves records through a server-side API. Next, you will make the project easier to evaluate during a short portfolio review.

Make It Recruiter-Ready on GitHub

Your persistent dashboard now proves that the full data flow works from the browser to SQLite. A recruiter still needs to understand that value within a short review.

You will verify the dashboard's reviewer-facing controls. You will document how the project works before publishing the source in a public GitHub repository.

In this step, get ready to:
  • Verify the dashboard's recruiter-facing controls.
  • Prepare the repository for a public review.
  • Publish a validated production build to GitHub.
Test the recruiter-facing dashboard

A recruiter can test the dashboard by finding one application within seconds. The counters make each saved record visible in the summary.

  • Switch back to the browser tab showing http://localhost:3000.
  • Enter Portfolio Labs in the Company field.
  • Enter Frontend Engineer in the Role field.
  • Select Interview in the Status field.
  • Enter 2026-08-30 in the Applied date field.
  • Click Save application.

Your new card appears immediately. The Total counter rises by one.

The Interviews counter also rises by one. You now have a short demo that proves the summary responds to saved data.

  • Enter Portfolio in the search box.

You should see the Portfolio Labs card while non-matching cards disappear.

  • Clear the search box.
  • Select Interview in the status filter.

You should see only applications with an Interview status.

  • Select All statuses to restore the complete pipeline.

All saved cards return. The search control, status filter, and dynamic summaries are ready for a portfolio review.

Document and protect the repository

A .gitignore file keeps generated files and local data outside Git. A README.md file gives reviewers a map of the finished system.

  • In the Explorer sidebar in Visual Studio Code, select the application-tracker folder.
  • Click the New File control.
  • Enter .gitignore as the file name.
  • Press Enter to create the file.
  • Protect the local artifacts by pasting these exclusions into .gitignore:
node_modules/
.next/
applications.db*
npm-debug.log*

What Does This File Protect?

  • The node_modules/ pattern excludes installed dependencies.
  • The .next/ pattern excludes generated build output.
  • The applications.db* pattern keeps the database and its journal files local.
  • The npm-debug.log* pattern excludes generated diagnostic logs.
  • Press Ctrl+S to save .gitignore.
  • Confirm that the editor shows all four exclusion patterns.

Missing an Exclusion?

Check each pattern against the reference below. A missing slash or wildcard can expose generated files when you initialize the repository.

Help me check my .gitignore patterns.

✔️ Awesome, I've got everything!

Your local database and generated folders now have a clear privacy boundary.

ⓧ I'd like to double check the full code

node_modules/
.next/
applications.db*
npm-debug.log*
  • Select the application-tracker folder in the Explorer sidebar.
  • Click the New File control.
  • Enter README.md as the file name.
  • Press Enter to create the file.
  • Start the project overview by pasting this content into README.md:
# Application Tracker

A full-stack dashboard for organizing job applications and demonstrating end-to-end software delivery.

## Features

- Create an application with a company, role, date, and status
- Persist records in a local SQLite database
- Search by company or role
- Filter by application status
- View live application, interview, and offer counts

## Stack

- Next.js 16.3.3
- React 19.2.8
- better-sqlite3 13.0.3
- SQLite

What Does This Section Explain?

The opening states the problem that the project solves. The feature list turns the implementation into capabilities a reviewer can test.

The stack section names the exact technologies behind those capabilities. This gives a recruiter a quick technical summary.

  • Press Ctrl+S to save README.md.
  • Confirm that the file includes the Features heading.
  • Confirm that the file includes the Stack heading.

Markdown Formatting Looks Flat?

Check that each heading starts with the same number of hash symbols shown above. Keep the blank lines between headings and lists.

Help me fix my README formatting.

  • In README.md, place your cursor after the - SQLite line.
  • Complete the documentation by pasting this content below that line:
## Run locally

1. Install Node.js 24.20.0 LTS.
2. Clone or download this repository.
3. Run `npm install`.
4. Run `npm run dev`.
5. Open `http://localhost:3000`.

## API

- `GET /api/applications` returns saved applications.
- `POST /api/applications` validates and creates an application.

## Data and privacy

The local `applications.db` file is excluded from Git. Use fictional application data in public demonstrations.

## Extension roadmap

1. Add AI-assisted resume and job-description matching.
2. Extract and harden a dedicated backend service with tests and observability.
3. Containerize the system and deploy it through a cloud CI/CD pipeline.

Why Include These Sections?

The local setup gives another developer a reproducible path to the running dashboard. The API section exposes the client-server boundary.

The privacy note explains why the database stays local. The roadmap shows how this project can grow into later engineering work.

  • Press Ctrl+S to save README.md.
  • Scroll to the bottom of README.md.
  • Confirm that the final section contains three extension ideas.

README Section Missing?

Check that the new content begins after the Stack list. Compare every heading against the full file below.

Help me compare my complete README.

✔️ Awesome, I've got everything!

Your repository now explains the product, its architecture, and its privacy boundary.

ⓧ I'd like to double check the full code

# Application Tracker

A full-stack dashboard for organizing job applications and demonstrating end-to-end software delivery.

## Features

- Create an application with a company, role, date, and status
- Persist records in a local SQLite database
- Search by company or role
- Filter by application status
- View live application, interview, and offer counts

## Stack

- Next.js 16.3.3
- React 19.2.8
- better-sqlite3 13.0.3
- SQLite

## Run locally

1. Install Node.js 24.20.0 LTS.
2. Clone or download this repository.
3. Run `npm install`.
4. Run `npm run dev`.
5. Open `http://localhost:3000`.

## API

- `GET /api/applications` returns saved applications.
- `POST /api/applications` validates and creates an application.

## Data and privacy

The local `applications.db` file is excluded from Git. Use fictional application data in public demonstrations.

## Extension roadmap

1. Add AI-assisted resume and job-description matching.
2. Extract and harden a dedicated backend service with tests and observability.
3. Containerize the system and deploy it through a cloud CI/CD pipeline.
  • Press Ctrl+C in the terminal running the development server.

Before you run the production check, consider whether the new documentation files can affect compilation.

  • Check production readiness by running this command:
npm run build

What Does This Check?

The build command asks Next.js to compile the application for production. A successful result proves that the documented source still forms a valid application.

  • Confirm that the production build finishes without an error.

The build should complete successfully. Your documentation and ignore rules stay outside the runtime path.

Build Did Not Complete?

Check the first reported file path in the terminal. Compare that file with the saved project source before running the build again.

Help me diagnose my build failure.

Publish the repository

Git records the finished project as local history. GitHub makes that history available to recruiters through a public repository.

  • Click Source Control in the Visual Studio Code Activity Bar.
  • Select Initialize Repository.
  • Inspect the files under Changes.

You should see the project source and documentation. The applications.db, node_modules, and .next artifacts should be absent.

  • Select the plus control beside Changes to stage every listed file.
  • Enter Build full-stack application tracker in the commit message field.
  • Select Commit.

Your source now has a clear starting point in Git history. That commit captures the working application without exposing local application data.

Publishing may open a browser permission page that connects Visual Studio Code to the GitHub account you already use.

  • Select Publish to GitHub.
  • Complete the browser authorization if prompted.
  • Enter application-tracker if a repository-name prompt appears.
  • Choose a public repository.

Before you follow the repository link, predict which project files GitHub should show.

  • Follow the repository link in the Visual Studio Code publishing confirmation.

You should see README.md, .gitignore, app/, lib/, and package.json in the public repository. The README should render below the file list.

The applications.db file should be absent. That is the hard part complete: your working full-stack dashboard is public while its local application history stays private.

Repository Did Not Publish?

Confirm that the commit appears in Source Control before trying to publish again. Complete any GitHub authorization request in your browser.

Help me publish my repository.

Secret mission

Make Statuses Editable

Turn every status badge into a workflow control that saves changes through the existing API. Move an application from Applied to Interview and prove the new stage survives a browser refresh.

Clean Up Your Resources

Clean Up Your Resources

This project has no ongoing costs because it uses local tools plus the GitHub Free plan. Decide whether to keep everything available, pause local development, or delete both copies.

Resources you used:

  • The local application-tracker project folder.
  • The generated node_modules folder inside application-tracker.
  • The generated .next folder inside application-tracker.
  • The local SQLite database stored in applications.db.
  • The public application-tracker repository on GitHub.

Keep everything running

No action needed. Choose this if you plan to keep building the dashboard or showing its source to other people.

  • Leave the public application-tracker repository available on GitHub.
  • Keep the local application-tracker folder for continued development.
  • Retain applications.db to preserve your saved application records plus status updates.

Pause - I'll come back to this later

Shut down the local development process to free up memory while keeping every project file.

  • Return to the terminal panel in Visual Studio Code that is running the development server.
  • Press Ctrl+C to stop the development server.
  • Close Visual Studio Code.
  • Leave the local application-tracker folder unchanged.
  • Leave the public repository available on GitHub.

Delete - I don't want to use this again

Deleting both copies is permanent. Your saved application records disappear with applications.db.

  • Close Visual Studio Code.
  • Press the Windows key to open Windows search.
  • Type File Explorer into the search field.
  • Press Enter to open File Explorer.
  • Navigate to the location containing the application-tracker folder.
  • Select the application-tracker folder.
  • Press the Delete key.
  • Confirm the folder deletion if Windows asks.

The local cleanup is complete. Your local copy is already gone, so this is the final chance to keep the source history.

  • Return to your public application-tracker repository on GitHub in your browser.
  • Select Settings.
  • Select General.
  • Scroll to Danger Zone.
  • Select Delete this repository.
  • Read the deletion warnings.
  • Enter application-tracker when GitHub asks for the repository name.
  • Complete the final deletion confirmation shown by GitHub.

Nice Work!

Nice Work!

You did it! This full-stack application tracker is now the first working foundation in your portfolio sequence.

You've learned how to:

  • Build a responsive React dashboard inside Next.js. The form creates application records. Company-or-role search narrows the pipeline. A status filter narrows it further.
  • Design a validated HTTP API for application records. Store those records in SQLite through prepared statements. Saved data survives a refresh.
  • Use Git to version the source. Publish setup details on GitHub. Document the architecture clearly. Keep applications.db outside the public repository.
  • Secret Mission: Extend the database with persistent status updates. Route each change through a PATCH request. Replace the returned record in React state. Confirm each status survives a refresh.

Ready to quiz yourself?