Build an Inventory Manager with Next.js
Build a persistent inventory manager with React, Next.js, and Vercel.
Introduction
30 Second Summary
Keeping count in a notebook works until items move faster than the notes can keep up. The moment a number is stale, low stock becomes easy to miss.
In this project, you will build Stockroom Lite, a personal inventory manager with React and Next.js. You will preserve stock changes with localStorage before publishing the app through GitHub and Vercel.
What You'll Build
You will demo a public stock dashboard whose totals respond instantly to quantity changes while saved items survive a browser refresh.
By the end of this project, you'll have:
- A working inventory manager where you can add products. Quantity controls raise or lower stock without going below zero.
- Instant stock insights that recalculate summary cards after every change. Search locates products by name or SKU. A low-stock filter isolates urgent rows.
- Saved, shareable inventory that remains after a refresh in the same browser. A public Vercel URL lets other people open the finished app.
- Secret Mission: Add a sorting control for alphabetical, lowest-stock, and highest-stock views.
Are there any prerequisites?
You need Windows with Node.js 20.9 or later plus Visual Studio Code.
You also need GitHub and Vercel accounts, plus basic JavaScript and introductory familiarity with React components and JSX.
Before We Start
Before the hands-on work begins, this checkpoint helps you choose a real personal inventory for Stockroom Lite to track. It also helps you connect each inventory action to the interface, browser storage, and project tools.
Set Up the App and Repository
Stockroom Lite needs a stable workspace before its inventory features can grow. Git for Windows records who changes the project.
You will use Visual Studio Code to hold a manually initialized Next.js app. Exact dependency versions keep every installation aligned.
In this step, get ready to:
- Install Git for Windows when needed.
- Configure Git with your identity.
- Create the pinned app files before launching the starter route.
Check and configure Git
Git attaches an identity to each commit. Checking the current user name reveals whether Git is available before you configure that identity.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Select Terminal from the top menu.
- Select New Terminal.
- Check for an existing Git user name by running this command:
git config --global user.name
What does this command check?
The command reads the user name stored in your global Git configuration. A printed name proves Git is available.
✔️ I see my Git user name
Git is installed. Continue below to set the identity you want to use for this project.
ⓧ The result is blank
A blank result means Git is installed without a global user name. Continue below to add your identity.
ⓧ Command not found
Git is unavailable in the current terminal. Install Git for Windows before continuing.
- Install Git for Windows by running this command:
winget install --id Git.Git -e --source winget
What does this command do?
Windows Package Manager finds the exact Git package identifier. It installs Git for Windows from the configured Windows package source.
- Approve the Windows permission request if one appears.
- Close Visual Studio Code after the installation completes.
- Press the Windows key to reopen Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to reopen Visual Studio Code.
- Select Terminal from the top menu.
- Select New Terminal.
- Confirm Git is now available by running this command:
git config --global user.name
Why might the result still be blank?
The reopened terminal can now find Git. A blank result is expected until you save a user name below.
Git installation still failing?
Make sure the installation finished before reopening Visual Studio Code. Restart Windows if the reopened terminal still cannot find Git.
Help me install Git for Windows with winget.
Your Git name identifies your work in commit history. Your GitHub commit email links that work to the matching GitHub account.
- Replace Mona Lisa with your name in this command before running it:
git config --global user.name "Mona Lisa"
What does this command save?
The command stores your chosen name in the global Git configuration. Future commits on this computer use that name unless a repository overrides it.
- Replace YOUR_EMAIL with your GitHub commit email in this command before running it:
git config --global user.email "YOUR_EMAIL"
What does this command save?
The command stores your commit email in the global Git configuration. Use an email connected to your GitHub account so GitHub can attribute your future commits.
Before you check the configuration, what two values do you expect the terminal to print?
- Confirm the saved Git user name by running:
git config --global user.name
What should this return?
Git reads the user name you just saved. The terminal should print your chosen name.
- Confirm the saved Git email by running:
git config --global user.email
What should this return?
Git reads the email you just saved. The terminal should print your GitHub commit email.
Seeing the wrong Git identity?
Run the matching setter command again with the correct value. Confirm that you kept the quotation marks around names containing spaces.
Help me correct my global Git identity.
Your Git identity is ready. Future commits can now carry the correct authorship.
Create the project files
A manual setup gives you control over every project file. The package.json manifest pins the framework plus each supporting dependency.
- Select File from the Visual Studio Code menu bar.
- Select Open Folder....
- Select your Desktop in the Windows folder dialog.
- Select New Folder.
- Enter stockroom-lite as the folder name.
- Select Select Folder.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
The Explorer now shows stockroom-lite as the open workspace. Every file you add next belongs to this folder.
- Select the stockroom-lite folder in the Explorer.
- Select New File... in the Explorer.
- Enter package.json as the file name.
The new package.json file opens in the editor.
- Define the pinned project manifest by pasting this code into package.json:
{
"name": "stockroom-lite",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.3.8",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@types/node": "26.6.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"typescript": "7.0.2"
}
}
What does this manifest control?
- The scripts section defines the development server plus production commands.
- The dependencies pin Next.js at 16.3.8.
- The dependencies pin React plus React DOM at 19.3.0.
- The development dependencies provide TypeScript at 7.0.2 plus matching type definitions.
- Save package.json by pressing Ctrl+S.
- Confirm the editor tab no longer shows an unsaved-change dot.
Seeing red lines in package.json?
Check that every property name remains inside double quotes. Confirm that the final property in each object has no trailing comma.
Help me compare my package.json with the pinned Stockroom Lite manifest.
- Select the stockroom-lite folder in the Explorer.
- Select New File....
- Enter .gitignore as the file name.
The Explorer now lists .gitignore beside package.json.
- Define the generated files Git should ignore by pasting this code into .gitignore:
node_modules
.next
next-env.d.ts
.env*
!.env.example
*.log
Why ignore these files?
Dependency folders plus build output can be recreated from the manifest. Ignoring environment files also reduces the risk of committing credentials later.
- Save .gitignore by pressing Ctrl+S.
- Confirm .gitignore remains visible in the Explorer.
Cannot see .gitignore?
Confirm the file name begins with a single dot. Remove any added file extension such as .txt.
Help me create a correctly named .gitignore file in Visual Studio Code.
The source files belong inside src/app. A single terminal command creates both nested folders from the open workspace.
- Return to the Visual Studio Code terminal from earlier.
- Create the nested source folders by running this command:
New-Item -Path "src/app" -ItemType Directory
What does this command create?
PowerShell creates the src folder inside stockroom-lite. It also creates the nested app folder used by the Next.js App Router.
- Confirm the Explorer now shows src.
- Expand src to confirm it contains app.
Created the folders elsewhere?
Confirm the Visual Studio Code title bar names the stockroom-lite workspace. The integrated terminal starts from the folder open in the Explorer.
Help me create src/app inside my stockroom-lite workspace.
- Select the app folder inside src.
- Select New File....
- Enter layout.tsx as the file name.
The new layout file opens inside src/app.
- Create the document shell by pasting this code into src/app/layout.tsx:
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Stockroom Lite",
description: "A lightweight personal inventory manager.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
What does this layout provide?
- The metadata supplies the browser title plus the page description.
- The stylesheet import applies global styles throughout the app.
- The RootLayout component wraps every route in the required document structure.
- Save src/app/layout.tsx by pressing Ctrl+S.
- Confirm layout.tsx appears under src/app in the Explorer.
Seeing TypeScript warnings?
Warnings can remain until the pinned packages are installed. Confirm the file name ends in .tsx before continuing.
Help me check my Stockroom Lite root layout.
- Select the app folder inside src.
- Select New File....
- Enter page.tsx as the file name.
The route file now sits beside layout.tsx.
- Create the starter route by pasting this code into src/app/page.tsx:
export default function Home() {
return <h1>Stockroom Lite</h1>;
}
What does this route render?
The Home component handles the root route. It renders the starter heading that proves the app can load.
- Save src/app/page.tsx by pressing Ctrl+S.
- Confirm page.tsx appears under src/app.
Route file in the wrong folder?
Move page.tsx into src/app if it appears at the top level of the Explorer. The root route depends on this exact location.
Help me place page.tsx in the correct Next.js App Router folder.
- Select the app folder inside src.
- Select New File....
- Enter globals.css as the file name.
The global stylesheet now appears beside the two TypeScript files.
- Add the starter page styles by pasting this code into src/app/globals.css:
* { box-sizing: border-box; }
body { margin: 0; padding: 32px; font-family: Arial, sans-serif; }
What do these styles change?
The universal rule makes element dimensions include their borders plus padding. The body rule removes the browser margin before adding consistent page spacing.
- Save src/app/globals.css by pressing Ctrl+S.
- Confirm globals.css appears under src/app.
Stylesheet showing syntax errors?
Confirm each rule ends with a closing brace. Keep the semicolons between the body declarations.
Help me check the starter globals.css syntax.
Your project skeleton is complete. The manifest plus source files now describe a runnable starter app.
Launch the pinned app
The manifest only lists the packages the app needs. npm now downloads those exact packages before Next.js starts the local development server.
- Return to the integrated terminal in the stockroom-lite workspace.
- Install the pinned project dependencies by running:
npm install
What does npm install create?
npm reads package.json before downloading the declared packages. It creates package-lock.json to record the resolved dependency tree.
- Confirm the terminal finishes without an installation failure.
- Confirm package-lock.json appears in the Explorer.
Dependency installation failed?
Confirm the terminal is inside the open stockroom-lite workspace. Check that package.json contains valid JSON.
Help me diagnose my npm install failure.
The development server compiles the root route while you work. It keeps the current terminal busy so the app can stay available in your browser.
Before you start the server, what should a successful root route display?
- Start the development server by running:
npm run dev
What happens when the server starts?
npm runs the dev script from package.json. Next.js compiles the app before serving it locally.
The first run also generates tsconfig.json plus next-env.d.ts for the TypeScript project.
- Select the http://localhost:3000 link shown in the terminal.
Your browser should show the Stockroom Lite heading with simple page spacing. This proves the pinned app can compile locally.
- Return to Visual Studio Code.
- Confirm tsconfig.json now appears in the Explorer.
- Confirm next-env.d.ts now appears in the Explorer.
Starter heading not loading?
Keep the development server running while the browser is open. Confirm the address bar contains http://localhost:3000.
Check that page.tsx sits inside src/app. Save every open file before reloading the browser.
Help me fix my local Stockroom Lite page.
Use this comparison after the starter page loads. The generated lock file plus TypeScript files should remain as Next.js and npm created them.
✔️ Awesome, I've got everything!
Great work. Save each source file while leaving the development server running.
ⓧ I'd like to double check the full code
{
"name": "stockroom-lite",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.3.8",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@types/node": "26.6.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"typescript": "7.0.2"
}
}
Manifest checkpoint
Your manifest should match every script plus pinned version shown above. npm uses this file to reproduce the dependency setup.
node_modules
.next
next-env.d.ts
.env*
!.env.example
*.log
Git ignore checkpoint
This file keeps generated dependencies plus build output outside future commits. It also excludes environment files by default.
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Stockroom Lite",
description: "A lightweight personal inventory manager.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Layout checkpoint
The layout imports the global stylesheet. It also supplies the document shell plus metadata.
export default function Home() {
return <h1>Stockroom Lite</h1>;
}
Route checkpoint
The root route should contain one component. That component renders the starter heading visible in your browser.
* { box-sizing: border-box; }
body { margin: 0; padding: 32px; font-family: Arial, sans-serif; }
Stylesheet checkpoint
The starter stylesheet should contain these two rules. The dashboard step replaces them with the full visual design.
The Explorer should also contain package-lock.json from npm. It should contain tsconfig.json plus next-env.d.ts from the first Next.js development run.
You have the first working slice of Stockroom Lite. Its pinned Next.js starter route is live in your browser.
Your development workspace is ready. Next up, you will turn the starter heading into a responsive inventory dashboard.
Build the Inventory Dashboard
Your Next.js starter is live. You now have a working route that can become the inventory manager.
The single starter heading does not yet resemble a usable product. This step creates an early visual version with summaries, inventory rows, form controls, status badges, and responsive styling.
In this step, get ready to:
- Build the static dashboard shell with summary cards and an add-inventory form.
- Display three starter products in a stock table.
- Style the dashboard for desktop and mobile screens.
Build the dashboard shell
The route stays a Server Component while the dashboard is static. This gives you a visible product layout before React state adds behavior in the next step.
Why build the static view first?
A static view isolates the page structure from its future interactions. You can confirm that every dashboard section is present before state changes begin updating it.
- Select src/app/page.tsx in the VS Code Explorer sidebar.
- Replace the file contents with this dashboard shell:
export default function Home() {
return (
<main className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">Personal inventory</p>
<h1>Stockroom Lite</h1>
<p className="hero-copy">Track what you have, update quantities, and catch low stock early.</p>
</div>
</header>
<section className="summary-grid" aria-label="Inventory summary">
<article className="summary-card"><span>Products</span><strong>3</strong></article>
<article className="summary-card"><span>Total units</span><strong>22</strong></article>
<article className="summary-card warning-card"><span>Low stock</span><strong>1</strong></article>
</section>
</main>
);
}
What does this code do?
- The app-shell class provides one outer container for the dashboard.
- The hero introduces the purpose of Stockroom Lite.
- The three cards show the product count, total number of units, and number of low-stock products.
- Save src/app/page.tsx.
- Switch back to the browser tab from Step 1.
- Refresh the local page.
You should see the Stockroom Lite heading above three unstyled summary values. The cards show 3 products, 22 total units, and 1 low-stock product.
Still seeing only the starter heading?
- Confirm that you edited src/app/page.tsx inside the stockroom-lite folder.
- Check the VS Code tab for an unsaved-change indicator.
- Confirm that the development server from Step 1 is still running in the terminal.
Help me find why my updated Next.js page is not appearing in the browser.
The dashboard also needs a visible place for new stock. The form remains static here so you can focus on its fields and layout.
- Find the summary section immediately above the closing </main> tag in src/app/page.tsx.
- Paste this form section below the summary section:
<section className="panel">
<div className="section-heading">
<div><p className="eyebrow">New product</p><h2>Add inventory</h2></div>
</div>
<form className="item-form">
<label>Item name<input name="name" placeholder="Packing tape" required /></label>
<label>SKU<input name="sku" placeholder="PT-401" required /></label>
<label>Starting quantity<input name="quantity" type="number" min="0" required /></label>
<label>Low-stock threshold<input name="minimum" type="number" min="0" required /></label>
<button className="primary-button" type="submit">Add item</button>
</form>
</section>
How is the form structured?
- Each label identifies the value that belongs in its input.
- The number fields use a minimum value of 0 because inventory quantities cannot be negative.
- The button completes the layout that becomes interactive in the next step.
- Save src/app/page.tsx.
- Return to the local page in your browser.
You should see an Add inventory section below the summary values. It contains four inputs and an Add item button.
Form missing from the page?
- Check that the new section sits inside <main>.
- Compare every opening JSX tag with its closing tag.
- Check the terminal for a compilation problem mentioning src/app/page.tsx.
Help me correct the JSX structure in my static inventory form.
Add the inventory table
An inventory dashboard needs a quick way to scan products and identify low stock. The toolbar establishes the future search and filter controls while the table shows the current stock values.
- Find the closing </main> tag in src/app/page.tsx.
- Paste this inventory section immediately above it:
<section className="panel">
<div className="section-heading inventory-heading">
<div><p className="eyebrow">Current stock</p><h2>Inventory</h2></div>
<div className="toolbar">
<input type="search" placeholder="Search name or SKU" aria-label="Search inventory" />
<label className="toggle"><input type="checkbox" />Low stock only</label>
</div>
</div>
</section>
What does the toolbar establish?
The search field provides a visible place to find products by name or SKU. The checkbox provides the control that later limits the table to low-stock products.
- Save src/app/page.tsx.
- Return to the local page in your browser.
You should see a new Inventory section with a search field and a Low stock only checkbox.
Inventory toolbar not visible?
- Confirm that the inventory section appears before the closing </main> tag.
- Check that className uses a capital N.
Help me find why the inventory toolbar is missing from my page.
- Find the section-heading inventory-heading container in the new inventory section.
- Paste this table immediately below that container:
<div className="table-wrap">
<table>
<thead><tr><th>Product</th><th>SKU</th><th>Quantity</th><th>Threshold</th><th>Status</th></tr></thead>
<tbody>
<tr>
<td className="product-name">Wireless Mouse</td><td>WM-101</td><td><div className="stock-control"><button type="button">−</button><strong>8</strong><button type="button">+</button></div></td>
<td>3</td><td><span className="status status-ok">Healthy</span></td>
</tr>
<tr>
<td className="product-name">USB-C Cable</td><td>UC-204</td><td><div className="stock-control"><button type="button">−</button><strong>2</strong><button type="button">+</button></div></td>
<td>4</td><td><span className="status status-low">Low stock</span></td>
</tr>
<tr>
<td className="product-name">Notebook</td><td>NB-310</td><td><div className="stock-control"><button type="button">−</button><strong>12</strong><button type="button">+</button></div></td>
<td>5</td><td><span className="status status-ok">Healthy</span></td>
</tr>
</tbody>
</table>
</div>
How does the table communicate stock?
- Each row keeps the product name, SKU, quantity, threshold, and status together.
- The minus and plus buttons establish the quantity control layout.
- The USB-C Cable receives the low-stock class because its quantity of 2 is below its threshold of 4.
- Save src/app/page.tsx.
- Return to the local page in your browser.
You should see rows for Wireless Mouse, USB-C Cable, and Notebook. USB-C Cable should display Low stock while the other two rows display Healthy.
Table rows missing or misaligned?
- Confirm that the table sits inside the inventory <section>.
- Count five cells in each row to match the five table headings.
- Check that every <tr> closes before the next row begins.
Help me compare the table rows with the five inventory columns.
✔️ Awesome, I've got everything!
Your static page now contains every dashboard section needed for this step.
ⓧ I'd like to double check the full code
export default function Home() {
return (
<main className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">Personal inventory</p>
<h1>Stockroom Lite</h1>
<p className="hero-copy">Track what you have, update quantities, and catch low stock early.</p>
</div>
</header>
<section className="summary-grid" aria-label="Inventory summary">
<article className="summary-card"><span>Products</span><strong>3</strong></article>
<article className="summary-card"><span>Total units</span><strong>22</strong></article>
<article className="summary-card warning-card"><span>Low stock</span><strong>1</strong></article>
</section>
<section className="panel">
<div className="section-heading">
<div><p className="eyebrow">New product</p><h2>Add inventory</h2></div>
</div>
<form className="item-form">
<label>Item name<input name="name" placeholder="Packing tape" required /></label>
<label>SKU<input name="sku" placeholder="PT-401" required /></label>
<label>Starting quantity<input name="quantity" type="number" min="0" required /></label>
<label>Low-stock threshold<input name="minimum" type="number" min="0" required /></label>
<button className="primary-button" type="submit">Add item</button>
</form>
</section>
<section className="panel">
<div className="section-heading inventory-heading">
<div><p className="eyebrow">Current stock</p><h2>Inventory</h2></div>
<div className="toolbar">
<input type="search" placeholder="Search name or SKU" aria-label="Search inventory" />
<label className="toggle"><input type="checkbox" />Low stock only</label>
</div>
</div>
<div className="table-wrap">
<table>
<thead><tr><th>Product</th><th>SKU</th><th>Quantity</th><th>Threshold</th><th>Status</th></tr></thead>
<tbody>
<tr>
<td className="product-name">Wireless Mouse</td><td>WM-101</td><td><div className="stock-control"><button type="button">−</button><strong>8</strong><button type="button">+</button></div></td>
<td>3</td><td><span className="status status-ok">Healthy</span></td>
</tr>
<tr>
<td className="product-name">USB-C Cable</td><td>UC-204</td><td><div className="stock-control"><button type="button">−</button><strong>2</strong><button type="button">+</button></div></td>
<td>4</td><td><span className="status status-low">Low stock</span></td>
</tr>
<tr>
<td className="product-name">Notebook</td><td>NB-310</td><td><div className="stock-control"><button type="button">−</button><strong>12</strong><button type="button">+</button></div></td>
<td>5</td><td><span className="status status-ok">Healthy</span></td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
);
}
What should match?
Your file should contain one hero, three summary cards, one add-inventory panel, and one inventory table. The class names connect each element to the stylesheet you add next.
Style the dashboard
The page structure now contains every visible feature. CSS turns that structure into a responsive dashboard with clear spacing, status colors, focus indicators, and mobile layouts.
- Select src/app/globals.css in the VS Code Explorer sidebar.
- Replace the starter styles with these foundations:
:root {
color-scheme: light;
--background: #f5f7fb;
--panel: #ffffff;
--text: #172033;
--muted: #667085;
--border: #e4e7ec;
--primary: #3659e3;
--primary-dark: #2745bd;
--warning: #b54708;
--warning-bg: #fff4e5;
--success: #067647;
--success-bg: #ecfdf3;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--background); }
body { margin: 0; color: var(--text); background: var(--background); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button, input, select { font: inherit; }
button, input[type="checkbox"] { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid rgba(54, 89, 227, 0.25); outline-offset: 2px; }
.app-shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 64px; }
.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 10px; font-size: clamp(2rem, 5vw, 3.4rem); line-height: 1; letter-spacing: -0.04em; }
h2 { margin-bottom: 0; font-size: 1.25rem; }
.eyebrow { margin-bottom: 8px; color: var(--primary); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.hero-copy { max-width: 620px; margin-bottom: 0; color: var(--muted); font-size: 1.05rem; line-height: 1.6; }
.save-status { flex: none; padding: 9px 12px; border: 1px solid #b7ebcd; border-radius: 999px; color: var(--success); background: var(--success-bg); font-size: 0.85rem; font-weight: 700; }
What do these styles establish?
- The color variables keep the dashboard palette consistent.
- The app shell limits the page width while retaining space at the screen edges.
- The focus rules make keyboard-selected controls easier to identify.
- Save src/app/globals.css.
- Return to the local page in your browser.
You should see a light gray page with a centered content area. The Stockroom Lite heading should appear larger with blue eyebrow text above it.
Page still using the starter styles?
- Confirm that src/app/layout.tsx still imports ./globals.css.
- Check that the stylesheet is saved as src/app/globals.css.
- Inspect the terminal for a CSS parsing problem.
Help me diagnose why globals.css is not styling my Next.js page.
- Move to the final rule in src/app/globals.css.
- Add these card, panel, form, and toolbar styles below it:
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 20px; }
.summary-card, .panel { border: 1px solid var(--border); border-radius: 18px; background: var(--panel); box-shadow: 0 10px 30px rgba(16, 24, 40, 0.06); }
.summary-card { display: flex; align-items: center; justify-content: space-between; padding: 20px; }
.summary-card span { color: var(--muted); font-size: 0.9rem; font-weight: 700; }
.summary-card strong { font-size: 2rem; }
.warning-card strong { color: var(--warning); }
.panel { margin-top: 20px; padding: 24px; }
.section-heading { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
.item-form { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr auto; align-items: end; gap: 12px; }
.item-form label { display: grid; gap: 7px; color: #344054; font-size: 0.82rem; font-weight: 700; }
.item-form input, .toolbar input, .toolbar select { width: 100%; border: 1px solid #d0d5dd; border-radius: 10px; color: var(--text); background: #ffffff; }
.item-form input { min-height: 42px; padding: 9px 11px; }
.primary-button { min-height: 42px; padding: 9px 18px; border: 0; border-radius: 10px; color: #ffffff; background: var(--primary); font-weight: 800; }
.primary-button:hover { background: var(--primary-dark); }
.inventory-heading { align-items: flex-end; }
.toolbar { display: flex; align-items: center; gap: 12px; }
.toolbar input[type="search"] { min-width: 230px; min-height: 40px; padding: 8px 11px; }
.toolbar select { min-height: 40px; padding: 8px 30px 8px 11px; }
.toggle { display: flex; align-items: center; gap: 7px; color: #344054; font-size: 0.88rem; font-weight: 700; white-space: nowrap; }
How do these styles shape the controls?
- The summary grid gives each metric equal horizontal space.
- The shared panel styles place the form and table on white cards.
- The form grid gives the product name more room than the numeric fields.
- The toolbar keeps search and filtering controls together.
- Save src/app/globals.css.
- Return to the local page in your browser.
You should see three white summary cards above two rounded panels. The form fields should share one row on a wide browser window.
Cards or form still look unstyled?
- Compare summary-grid in the stylesheet with the page class name.
- Compare item-form in the stylesheet with the form class name.
- Check that the new rules appear after the closing brace for save-status.
Help me find mismatched class names between page.tsx and globals.css.
- Move to the final rule in src/app/globals.css.
- Add these table and responsive styles below it:
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 15px 12px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
th { color: var(--muted); font-size: 0.75rem; letter-spacing: 0.06em; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
.product-name { color: var(--text); font-weight: 800; }
.stock-control { display: inline-flex; align-items: center; gap: 10px; }
.stock-control button { width: 30px; height: 30px; border: 1px solid #d0d5dd; border-radius: 8px; color: var(--text); background: #ffffff; font-weight: 800; }
.stock-control button:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
.stock-control button:disabled { cursor: not-allowed; opacity: 0.4; }
.status { display: inline-flex; padding: 5px 9px; border-radius: 999px; font-size: 0.78rem; font-weight: 800; }
.status-low { color: var(--warning); background: var(--warning-bg); }
.status-ok { color: var(--success); background: var(--success-bg); }
.empty-state { padding: 34px 12px; color: var(--muted); text-align: center; }
@media (max-width: 860px) {
.item-form { grid-template-columns: repeat(2, 1fr); }
.primary-button { width: 100%; }
.inventory-heading, .toolbar { align-items: stretch; flex-direction: column; }
.toolbar input[type="search"] { min-width: 0; }
}
@media (max-width: 620px) {
.app-shell { width: min(100% - 20px, 1120px); padding-top: 28px; }
.hero { flex-direction: column; }
.summary-grid, .item-form { grid-template-columns: 1fr; }
.panel { padding: 18px; }
}
What completes the dashboard?
- The table rules align each inventory value while allowing horizontal scrolling on narrow screens.
- The status classes use orange for low stock and green for healthy stock.
- The first responsive breakpoint rearranges the form and toolbar.
- The second responsive breakpoint stacks cards and form fields into one column.
- Save src/app/globals.css.
- Return to the local page in your browser.
- Narrow the browser window to test the mobile layout.
You should see styled quantity buttons and colored status badges. On a narrow window, the summary cards and form fields should stack into one column.
Responsive layout not stacking?
- Check that both @media blocks include their closing braces.
- Confirm that the responsive rules appear after the main table styles.
- Reduce the browser width below 620px to test the single-column layout.
Help me diagnose why the Stockroom Lite responsive styles are not applying.
✔️ Awesome, I've got everything!
Your stylesheet now covers the complete static dashboard and its responsive layouts.
ⓧ I'd like to double check the full code
:root {
color-scheme: light;
--background: #f5f7fb;
--panel: #ffffff;
--text: #172033;
--muted: #667085;
--border: #e4e7ec;
--primary: #3659e3;
--primary-dark: #2745bd;
--warning: #b54708;
--warning-bg: #fff4e5;
--success: #067647;
--success-bg: #ecfdf3;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--background); }
body { margin: 0; color: var(--text); background: var(--background); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button, input, select { font: inherit; }
button, input[type="checkbox"] { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid rgba(54, 89, 227, 0.25); outline-offset: 2px; }
.app-shell { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 64px; }
.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 10px; font-size: clamp(2rem, 5vw, 3.4rem); line-height: 1; letter-spacing: -0.04em; }
h2 { margin-bottom: 0; font-size: 1.25rem; }
.eyebrow { margin-bottom: 8px; color: var(--primary); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.hero-copy { max-width: 620px; margin-bottom: 0; color: var(--muted); font-size: 1.05rem; line-height: 1.6; }
.save-status { flex: none; padding: 9px 12px; border: 1px solid #b7ebcd; border-radius: 999px; color: var(--success); background: var(--success-bg); font-size: 0.85rem; font-weight: 700; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 20px; }
.summary-card, .panel { border: 1px solid var(--border); border-radius: 18px; background: var(--panel); box-shadow: 0 10px 30px rgba(16, 24, 40, 0.06); }
.summary-card { display: flex; align-items: center; justify-content: space-between; padding: 20px; }
.summary-card span { color: var(--muted); font-size: 0.9rem; font-weight: 700; }
.summary-card strong { font-size: 2rem; }
.warning-card strong { color: var(--warning); }
.panel { margin-top: 20px; padding: 24px; }
.section-heading { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
.item-form { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr auto; align-items: end; gap: 12px; }
.item-form label { display: grid; gap: 7px; color: #344054; font-size: 0.82rem; font-weight: 700; }
.item-form input, .toolbar input, .toolbar select { width: 100%; border: 1px solid #d0d5dd; border-radius: 10px; color: var(--text); background: #ffffff; }
.item-form input { min-height: 42px; padding: 9px 11px; }
.primary-button { min-height: 42px; padding: 9px 18px; border: 0; border-radius: 10px; color: #ffffff; background: var(--primary); font-weight: 800; }
.primary-button:hover { background: var(--primary-dark); }
.inventory-heading { align-items: flex-end; }
.toolbar { display: flex; align-items: center; gap: 12px; }
.toolbar input[type="search"] { min-width: 230px; min-height: 40px; padding: 8px 11px; }
.toolbar select { min-height: 40px; padding: 8px 30px 8px 11px; }
.toggle { display: flex; align-items: center; gap: 7px; color: #344054; font-size: 0.88rem; font-weight: 700; white-space: nowrap; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 15px 12px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
th { color: var(--muted); font-size: 0.75rem; letter-spacing: 0.06em; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
.product-name { color: var(--text); font-weight: 800; }
.stock-control { display: inline-flex; align-items: center; gap: 10px; }
.stock-control button { width: 30px; height: 30px; border: 1px solid #d0d5dd; border-radius: 8px; color: var(--text); background: #ffffff; font-weight: 800; }
.stock-control button:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
.stock-control button:disabled { cursor: not-allowed; opacity: 0.4; }
.status { display: inline-flex; padding: 5px 9px; border-radius: 999px; font-size: 0.78rem; font-weight: 800; }
.status-low { color: var(--warning); background: var(--warning-bg); }
.status-ok { color: var(--success); background: var(--success-bg); }
.empty-state { padding: 34px 12px; color: var(--muted); text-align: center; }
@media (max-width: 860px) {
.item-form { grid-template-columns: repeat(2, 1fr); }
.primary-button { width: 100%; }
.inventory-heading, .toolbar { align-items: stretch; flex-direction: column; }
.toolbar input[type="search"] { min-width: 0; }
}
@media (max-width: 620px) {
.app-shell { width: min(100% - 20px, 1120px); padding-top: 28px; }
.hero { flex-direction: column; }
.summary-grid, .item-form { grid-template-columns: 1fr; }
.panel { padding: 18px; }
}
What should match?
Your stylesheet should progress from shared colors and page foundations to dashboard components. The final two media queries provide the tablet and mobile layouts.
Before you refresh, which product do you expect to carry the orange low-stock badge?
- Switch back to the browser tab showing http://localhost:3000.
- Refresh the page.
You should see three summary cards and a styled table containing Wireless Mouse, USB-C Cable, and Notebook. USB-C Cable should have the orange Low stock badge because its quantity is below its threshold.
That is the first complete visual version of Stockroom Lite. Next, you will turn its form and quantity controls into a working inventory manager.
Make Stock Changes Interactive
Your Stockroom Lite dashboard now looks like a real inventory tool. Its form and stock buttons still cannot change the inventory.
React component state lets the form create products. It also lets each stock button update a row.
A final refresh tests whether those updates exist beyond the current page load.
In this step, get ready to:
- Move the dashboard into an interactive Client Component.
- Add products through controlled form fields.
- Update quantities with immutable state changes.
Create the client state foundation
Next.js renders route files as Server Components by default. Browser event handlers need a Client Component that can use state.
- In Visual Studio Code, select the Explorer sidebar.
- Right-click the src/app folder.
- Select New File.
- Enter inventory-manager.tsx as the file name.
- Define the client boundary and inventory types by pasting this code into src/app/inventory-manager.tsx:
"use client";
import { useState } from "react";
type InventoryItem = {
id: string;
name: string;
sku: string;
quantity: number;
minimum: number;
};
type DraftItem = {
name: string;
sku: string;
quantity: string;
minimum: string;
};
What does this code do?
- The "use client" directive allows this file to use browser events and React state.
- InventoryItem defines the data stored for each product.
- DraftItem defines the text values held by the form while someone types.
- TypeScript checks that every product and draft follows these shapes.
The component also needs initial data for its first render. The draft begins with safe numeric defaults for quantity fields.
- Add the starter inventory and empty draft below the DraftItem type by pasting this code:
const starterItems: InventoryItem[] = [
{ id: "wireless-mouse", name: "Wireless Mouse", sku: "WM-101", quantity: 8, minimum: 3 },
{ id: "usb-c-cable", name: "USB-C Cable", sku: "UC-204", quantity: 2, minimum: 4 },
{ id: "notebook", name: "Notebook", sku: "NB-310", quantity: 12, minimum: 5 },
];
const emptyDraft: DraftItem = {
name: "",
sku: "",
quantity: "0",
minimum: "2",
};
How is the starting state shaped?
starterItems provides three complete inventory records. Each record has a stable row ID plus the values displayed in the table.
emptyDraft supplies the form values used before a product is added. Reusing this object also clears the form after submission.
- Save src/app/inventory-manager.tsx.
You should see inventory-manager.tsx inside the src/app folder in the Explorer sidebar.
Cannot find the new file?
Check that inventory-manager.tsx appears directly inside src/app. A file created elsewhere will not share the same import path.
Help me locate or create inventory-manager.tsx inside src/app.
The first component slice owns the item array and draft object. Its summary values are calculated from the current item state on every render.
- Add the initial InventoryManager component below emptyDraft by pasting this code:
export default function InventoryManager() {
const [items, setItems] = useState<InventoryItem[]>(starterItems);
const [draft, setDraft] = useState<DraftItem>(emptyDraft);
const totalUnits = items.reduce((total, item) => total + item.quantity, 0);
const lowStockCount = items.filter((item) => item.quantity <= item.minimum).length;
return (
<main className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">Personal inventory</p>
<h1>Stockroom Lite</h1>
<p className="hero-copy">Track what you have, update quantities, and catch low stock early.</p>
</div>
</header>
<section className="summary-grid" aria-label="Inventory summary">
<article className="summary-card"><span>Products</span><strong>{items.length}</strong></article>
<article className="summary-card"><span>Total units</span><strong>{totalUnits}</strong></article>
<article className="summary-card warning-card"><span>Low stock</span><strong>{lowStockCount}</strong></article>
</section>
</main>
);
}
How does the summary stay current?
- useState gives the component its current values plus functions for replacing them.
- items.length produces the product count from the array.
- reduce() adds every product quantity to calculate total units.
- filter() counts products whose quantity is at or below their threshold.
The route can remain a Server Component while rendering this interactive feature. That boundary keeps the route entry small.
- Select src/app/page.tsx in the Explorer sidebar.
- Replace its contents with this code:
import InventoryManager from "./inventory-manager";
export default function Home() {
return <InventoryManager />;
}
What does the route do now?
Home stays as the route entry for /. It delegates the interactive interface to InventoryManager.
- Save src/app/inventory-manager.tsx.
- Save src/app/page.tsx.
- Return to http://localhost:3000 in the browser from earlier.
You should see the Stockroom Lite header plus three summary cards. The cards show 3 products, 22 total units, and 1 low-stock product.
Your Server Component now hands the route to a working Client Component. The summary is reading live state.
Seeing a compile error?
Check that the file name is exactly inventory-manager.tsx. Confirm that it sits beside page.tsx inside src/app.
Help me fix the Client Component import in my Stockroom Lite page.
✔️ Awesome, I've got everything!
Your route now renders InventoryManager from the new client-side file.
ⓧ I'd like to double check the full code
import InventoryManager from "./inventory-manager";
export default function Home() {
return <InventoryManager />;
}
How to use this reference
Compare this reference with src/app/page.tsx. The import path and component name must match exactly.
Add products and update quantities
A controlled form reads each input value from state. Every input event replaces the matching field in draft.
An immutable array update creates a new array for React to render. The existing items array remains unchanged.
- Switch back to src/app/inventory-manager.tsx.
- Find the lowStockCount calculation.
- Add the quantity update function below that calculation by pasting this code:
function changeQuantity(id: string, change: number) {
setItems((currentItems) =>
currentItems.map((item) =>
item.id === id
? { ...item, quantity: Math.max(0, item.quantity + change) }
: item,
),
);
}
How are quantities updated safely?
- map() creates a new array while checking each product ID.
- The matching product is copied with a new quantity.
- Math.max(0, ...) prevents the updated quantity from falling below zero.
- Every unmatched product keeps its existing object.
- Find the closing tag for the summary-grid section.
- Add the controlled product form below that section by pasting this code:
<section className="panel">
<div className="section-heading">
<div><p className="eyebrow">New product</p><h2>Add inventory</h2></div>
</div>
<form
className="item-form"
onSubmit={(event) => {
event.preventDefault();
const name = draft.name.trim();
const sku = draft.sku.trim().toUpperCase();
if (!name || !sku) return;
const newItem: InventoryItem = {
id: `${sku.toLowerCase()}-${Date.now()}`,
name,
sku,
quantity: Math.max(0, Number(draft.quantity)),
minimum: Math.max(0, Number(draft.minimum)),
};
setItems((currentItems) => [newItem, ...currentItems]);
setDraft(emptyDraft);
}}
>
<label>Item name<input name="name" value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="Packing tape" required /></label>
<label>SKU<input name="sku" value={draft.sku} onChange={(event) => setDraft({ ...draft, sku: event.target.value })} placeholder="PT-401" required /></label>
<label>Starting quantity<input name="quantity" type="number" min="0" value={draft.quantity} onChange={(event) => setDraft({ ...draft, quantity: event.target.value })} required /></label>
<label>Low-stock threshold<input name="minimum" type="number" min="0" value={draft.minimum} onChange={(event) => setDraft({ ...draft, minimum: event.target.value })} required /></label>
<button className="primary-button" type="submit">Add item</button>
</form>
</section>
How does item creation work?
- preventDefault() keeps the form submission on the current page.
- trim() removes extra spaces from the product name and SKU.
- toUpperCase() gives every displayed SKU consistent capital letters.
- setItems() places the new product at the start of a new array.
- Save src/app/inventory-manager.tsx.
- Return to the browser.
- Enter Packing tape in the Item name field.
- Enter PT-401 in the SKU field.
- Enter 4 in the Starting quantity field.
- Enter 2 in the Low-stock threshold field.
- Click Add item.
The form clears after submission. The cards now show 4 products, 26 total units, and 1 low-stock product.
That is your first live inventory update working through React state. The interface recalculates its summary immediately.
Form does not update the cards?
Confirm that every input has its matching draft value. Check that the form calls setItems() inside its submission handler.
Help me debug my controlled Stockroom Lite form.
The updated item array now needs a visible table. Each row can calculate its own status from the current quantity and threshold.
- Find the closing tag for the add-inventory panel.
- Add the interactive inventory table below that panel by pasting this code:
<section className="panel">
<div className="section-heading inventory-heading">
<div><p className="eyebrow">Current stock</p><h2>Inventory</h2></div>
</div>
<div className="table-wrap">
<table>
<thead><tr><th>Product</th><th>SKU</th><th>Quantity</th><th>Threshold</th><th>Status</th></tr></thead>
<tbody>
{items.map((item) => {
const isLow = item.quantity <= item.minimum;
return (
<tr key={item.id}>
<td className="product-name">{item.name}</td>
<td>{item.sku}</td>
<td>
<div className="stock-control">
<button type="button" onClick={() => changeQuantity(item.id, -1)} disabled={item.quantity === 0} aria-label={`Decrease ${item.name} quantity`}>−</button>
<strong>{item.quantity}</strong>
<button type="button" onClick={() => changeQuantity(item.id, 1)} aria-label={`Increase ${item.name} quantity`}>+</button>
</div>
</td>
<td>{item.minimum}</td>
<td><span className={`status ${isLow ? "status-low" : "status-ok"}`}>{isLow ? "Low stock" : "Healthy"}</span></td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
How do the table controls work?
- items.map() renders one table row for every product in state.
- isLow selects the status text and badge style for each row.
- The minus button sends -1 to changeQuantity().
- The plus button sends 1 to the same function.
- Save src/app/inventory-manager.tsx.
- Return to the browser.
- Click the plus button for Wireless Mouse once.
- Click the minus button for Wireless Mouse until its quantity reaches 0.
Wireless Mouse stops at 0. Its minus button becomes disabled.
Quantity goes below zero?
Check that changeQuantity() wraps the new value in Math.max(0, ...). Confirm that the minus button is disabled when item.quantity === 0.
Help me fix the quantity floor in Stockroom Lite.
✔️ Awesome, I've got everything!
Your client component can now add products and change stock quantities without reloading the page.
ⓧ I'd like to double check the full code
"use client";
import { useState } from "react";
type InventoryItem = {
id: string;
name: string;
sku: string;
quantity: number;
minimum: number;
};
type DraftItem = {
name: string;
sku: string;
quantity: string;
minimum: string;
};
const starterItems: InventoryItem[] = [
{ id: "wireless-mouse", name: "Wireless Mouse", sku: "WM-101", quantity: 8, minimum: 3 },
{ id: "usb-c-cable", name: "USB-C Cable", sku: "UC-204", quantity: 2, minimum: 4 },
{ id: "notebook", name: "Notebook", sku: "NB-310", quantity: 12, minimum: 5 },
];
const emptyDraft: DraftItem = {
name: "",
sku: "",
quantity: "0",
minimum: "2",
};
export default function InventoryManager() {
const [items, setItems] = useState<InventoryItem[]>(starterItems);
const [draft, setDraft] = useState<DraftItem>(emptyDraft);
const totalUnits = items.reduce((total, item) => total + item.quantity, 0);
const lowStockCount = items.filter((item) => item.quantity <= item.minimum).length;
function changeQuantity(id: string, change: number) {
setItems((currentItems) =>
currentItems.map((item) =>
item.id === id
? { ...item, quantity: Math.max(0, item.quantity + change) }
: item,
),
);
}
return (
<main className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">Personal inventory</p>
<h1>Stockroom Lite</h1>
<p className="hero-copy">Track what you have, update quantities, and catch low stock early.</p>
</div>
</header>
<section className="summary-grid" aria-label="Inventory summary">
<article className="summary-card"><span>Products</span><strong>{items.length}</strong></article>
<article className="summary-card"><span>Total units</span><strong>{totalUnits}</strong></article>
<article className="summary-card warning-card"><span>Low stock</span><strong>{lowStockCount}</strong></article>
</section>
<section className="panel">
<div className="section-heading">
<div><p className="eyebrow">New product</p><h2>Add inventory</h2></div>
</div>
<form
className="item-form"
onSubmit={(event) => {
event.preventDefault();
const name = draft.name.trim();
const sku = draft.sku.trim().toUpperCase();
if (!name || !sku) return;
const newItem: InventoryItem = {
id: `${sku.toLowerCase()}-${Date.now()}`,
name,
sku,
quantity: Math.max(0, Number(draft.quantity)),
minimum: Math.max(0, Number(draft.minimum)),
};
setItems((currentItems) => [newItem, ...currentItems]);
setDraft(emptyDraft);
}}
>
<label>Item name<input name="name" value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="Packing tape" required /></label>
<label>SKU<input name="sku" value={draft.sku} onChange={(event) => setDraft({ ...draft, sku: event.target.value })} placeholder="PT-401" required /></label>
<label>Starting quantity<input name="quantity" type="number" min="0" value={draft.quantity} onChange={(event) => setDraft({ ...draft, quantity: event.target.value })} required /></label>
<label>Low-stock threshold<input name="minimum" type="number" min="0" value={draft.minimum} onChange={(event) => setDraft({ ...draft, minimum: event.target.value })} required /></label>
<button className="primary-button" type="submit">Add item</button>
</form>
</section>
<section className="panel">
<div className="section-heading inventory-heading">
<div><p className="eyebrow">Current stock</p><h2>Inventory</h2></div>
</div>
<div className="table-wrap">
<table>
<thead><tr><th>Product</th><th>SKU</th><th>Quantity</th><th>Threshold</th><th>Status</th></tr></thead>
<tbody>
{items.map((item) => {
const isLow = item.quantity <= item.minimum;
return (
<tr key={item.id}>
<td className="product-name">{item.name}</td>
<td>{item.sku}</td>
<td>
<div className="stock-control">
<button type="button" onClick={() => changeQuantity(item.id, -1)} disabled={item.quantity === 0} aria-label={`Decrease ${item.name} quantity`}>−</button>
<strong>{item.quantity}</strong>
<button type="button" onClick={() => changeQuantity(item.id, 1)} aria-label={`Increase ${item.name} quantity`}>+</button>
</div>
</td>
<td>{item.minimum}</td>
<td><span className={`status ${isLow ? "status-low" : "status-ok"}`}>{isLow ? "Low stock" : "Healthy"}</span></td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</main>
);
}
How to use this reference
Compare the reference with src/app/inventory-manager.tsx from top to bottom. Pay close attention to braces around the form handler and changeQuantity().
Test the state boundary with a refresh
The form and quantity buttons now update one shared item array. This final test checks how that array behaves when the page reloads.
- Enter Printer paper in the Item name field.
- Enter PP-550 in the SKU field.
- Enter 6 in the Starting quantity field.
- Enter 3 in the Low-stock threshold field.
- Click Add item.
- Click the plus button for Printer paper once.
You should see Printer paper at the top of the table with a quantity of 7. The summary cards update to include the new product.
Pause for one prediction: whether the new row will survive the page reload.
- Refresh the page at http://localhost:3000.
Printer paper and Packing tape disappear. Wireless Mouse returns to its starting quantity of 8.
Why did the items reset?
React state belongs to the currently mounted component. Reloading the page creates a new component with starterItems again.
This is the intended shortfall for this step. The next step connects the inventory to localStorage so the browser can restore saved items.
Your inventory controls now respond to real user actions. Next up, you will preserve those changes across refreshes and add tools for narrowing the visible rows.
Persist and Filter the Inventory
Your controls now add products and adjust quantities without mutating the inventory array. The refresh test exposed the remaining problem because every change disappeared.
This step synchronizes the full inventory with localStorage. It also derives the visible table from search text or stock status.
In this step, get ready to:
- Load saved inventory after the Client Component hydrates.
- Write later inventory changes to browser storage.
- Search the inventory and show only low-stock products.
Load saved inventory after hydration
An effect runs browser-side logic after hydration. This timing lets the component read window.localStorage after the browser takes control.
- In src/app/inventory-manager.tsx, update the React import so the component can use useEffect:
import { useEffect, useState } from "react";
Why add useEffect?
React uses useEffect to synchronize a component with browser systems. Browser storage is the external system in this step.
- Add the storage key below the DraftItem type:
const STORAGE_KEY = "stockroom-lite-items";
Why use one storage key?
The STORAGE_KEY constant gives every storage operation the same identifier. A single source prevents a spelling mismatch from separating reads from writes.
- Add the storage readiness state after the existing draft state:
const [storageReady, setStorageReady] = useState(false);
What does storageReady track?
The storageReady value records whether the initial browser storage read has finished. The write effect uses this signal to protect previously saved inventory.
- Insert the storage-reading effect below the component state declarations:
useEffect(() => {
try {
const savedItems = window.localStorage.getItem(STORAGE_KEY);
if (savedItems) {
const parsedItems = JSON.parse(savedItems) as InventoryItem[];
if (Array.isArray(parsedItems)) setItems(parsedItems);
}
} catch {
// Keep the starter inventory if saved browser data cannot be read.
}
setStorageReady(true);
}, []);
How does the storage read work?
- The empty dependency array runs this effect after the component first mounts.
- The storage lookup retrieves the saved JSON string under STORAGE_KEY.
- The array check protects items from an unexpected parsed value.
- The try block keeps the starter inventory available when browser storage cannot be read.
- Save src/app/inventory-manager.tsx.
- Return to the browser from earlier to confirm the dashboard loads with the three starter products and no development error overlay.
The dashboard still renders after its first client-side storage check. The component is now ready to distinguish startup from later inventory changes.
Dashboard stopped loading?
Check that the import contains both useEffect and useState. Confirm that STORAGE_KEY appears above starterItems.
Compare the closing braces around the new effect with the code above. A missing brace prevents the component from compiling.
Help me check my inventory storage effect.
Write inventory changes safely
The first effect may replace the starter list with saved items. A guarded second effect waits for that read before writing inventory back to the same key.
- Insert the storage-writing effect immediately below the storage-reading effect:
useEffect(() => {
if (!storageReady) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch {
// Keep the app usable even when browser storage is unavailable.
}
}, [items, storageReady]);
How does the guard protect saved items?
The first render still contains starterItems. The early return stops that temporary value from overwriting stored inventory.
Later changes to items trigger the effect. JSON.stringify(items) converts the array into a string that browser storage can keep.
- In the header JSX, set the existing save-status span to this completed block:
<span className="save-status">
{storageReady ? "Saved in this browser" : "Loading inventory"}
</span>
What does the status show?
The label reflects the storage lifecycle already tracked by storageReady. Once the first read finishes, you see that inventory changes can be saved in this browser.
- Save src/app/inventory-manager.tsx.
- Enter Packing Labels in the Item name field.
- Enter PL-901 in the SKU field.
- Enter 1 in the Starting quantity field.
- Enter 2 in the Low-stock threshold field.
- Click Add item.
Before you refresh, do you expect Packing Labels to survive or disappear?
- Refresh http://localhost:3000.
You should still see Packing Labels in the table. The save status should read Saved in this browser.
Item disappeared after refresh?
Confirm that the read effect appears before the write effect. Check that both effects use the same STORAGE_KEY constant.
Make sure the write effect includes storageReady in its dependency array. The guard must appear before the storage write.
Help me debug inventory persistence.
Derive the visible inventory
A derived UI calculates what to display from existing state. The full items array can keep driving the summary cards while a filtered array drives the table.
- Insert the search states immediately before the existing storageReady state:
const [search, setSearch] = useState("");
const [showLowOnly, setShowLowOnly] = useState(false);
What do these states control?
The search state stores the current search text. The showLowOnly state records whether the low-stock checkbox is selected.
- Add the filtering calculations below the existing lowStockCount calculation:
const normalizedSearch = search.trim().toLowerCase();
const filteredItems = items.filter((item) => {
const matchesSearch =
item.name.toLowerCase().includes(normalizedSearch) ||
item.sku.toLowerCase().includes(normalizedSearch);
const matchesStockFilter = !showLowOnly || item.quantity <= item.minimum;
return matchesSearch && matchesStockFilter;
});
How is the visible list derived?
- The normalized search removes surrounding spaces and ignores letter case.
- The name or SKU must contain the current search text.
- The stock condition accepts every item until showLowOnly becomes true.
- The filter call creates a new array without changing stored inventory.
- Add the toolbar inside the existing inventory-heading section beside the inventory heading:
<div className="toolbar">
<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search name or SKU" aria-label="Search inventory" />
<label className="toggle"><input type="checkbox" checked={showLowOnly} onChange={(event) => setShowLowOnly(event.target.checked)} />Low stock only</label>
</div>
How are the controls connected?
Both controls are controlled inputs. Their displayed values come from React state.
Each change updates its matching state value. React then recalculates filteredItems for the next render.
- In the table body, replace the items array at the start of the row mapping expression with this final line:
{filteredItems.map((item) => {
Why render filteredItems?
The table now renders the derived subset. The original items array remains intact for persistence and summary calculations.
- Add the empty-results row after the filtered row mapping expression:
{filteredItems.length === 0 && <tr><td className="empty-state" colSpan={5}>No items match the current filters.</td></tr>}
Why add an empty state?
A valid filter can produce zero rows. The empty state confirms that the controls worked even when nothing matches.
- Save src/app/inventory-manager.tsx.
Before you test the controls, which parts of the dashboard should change when the table is filtered?
- Type Packing Labels in the Search inventory field.
You should see only the Packing Labels row. The summary cards should continue to describe the full inventory.
- Clear the Search inventory field.
- Select the Low stock only checkbox.
You should see low-stock rows such as USB-C Cable and Packing Labels. Healthy rows should leave the table while the summary totals stay unchanged.
Filters not changing the table?
Confirm that the table maps over filteredItems. Mapping over items keeps every row visible.
Check that the search input updates setSearch. Check that the checkbox updates setShowLowOnly.
Help me debug the inventory filters.
✔️ Awesome, I've got everything!
Your inventory file now reads saved items before writing changes. It also derives table rows from the search and low-stock controls.
ⓧ I'd like to double check the full code
The complete src/app/inventory-manager.tsx file should match this reference.
"use client";
import { useEffect, useState } from "react";
type InventoryItem = {
id: string;
name: string;
sku: string;
quantity: number;
minimum: number;
};
type DraftItem = {
name: string;
sku: string;
quantity: string;
minimum: string;
};
const STORAGE_KEY = "stockroom-lite-items";
const starterItems: InventoryItem[] = [
{ id: "wireless-mouse", name: "Wireless Mouse", sku: "WM-101", quantity: 8, minimum: 3 },
{ id: "usb-c-cable", name: "USB-C Cable", sku: "UC-204", quantity: 2, minimum: 4 },
{ id: "notebook", name: "Notebook", sku: "NB-310", quantity: 12, minimum: 5 },
];
const emptyDraft: DraftItem = {
name: "",
sku: "",
quantity: "0",
minimum: "2",
};
export default function InventoryManager() {
const [items, setItems] = useState<InventoryItem[]>(starterItems);
const [draft, setDraft] = useState<DraftItem>(emptyDraft);
const [search, setSearch] = useState("");
const [showLowOnly, setShowLowOnly] = useState(false);
const [storageReady, setStorageReady] = useState(false);
useEffect(() => {
try {
const savedItems = window.localStorage.getItem(STORAGE_KEY);
if (savedItems) {
const parsedItems = JSON.parse(savedItems) as InventoryItem[];
if (Array.isArray(parsedItems)) setItems(parsedItems);
}
} catch {
// Keep the starter inventory if saved browser data cannot be read.
}
setStorageReady(true);
}, []);
useEffect(() => {
if (!storageReady) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch {
// Keep the app usable even when browser storage is unavailable.
}
}, [items, storageReady]);
const totalUnits = items.reduce((total, item) => total + item.quantity, 0);
const lowStockCount = items.filter((item) => item.quantity <= item.minimum).length;
const normalizedSearch = search.trim().toLowerCase();
const filteredItems = items.filter((item) => {
const matchesSearch =
item.name.toLowerCase().includes(normalizedSearch) ||
item.sku.toLowerCase().includes(normalizedSearch);
const matchesStockFilter = !showLowOnly || item.quantity <= item.minimum;
return matchesSearch && matchesStockFilter;
});
function changeQuantity(id: string, change: number) {
setItems((currentItems) =>
currentItems.map((item) =>
item.id === id
? { ...item, quantity: Math.max(0, item.quantity + change) }
: item,
),
);
}
return (
<main className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">Personal inventory</p>
<h1>Stockroom Lite</h1>
<p className="hero-copy">Track what you have, update quantities, and catch low stock early.</p>
</div>
<span className="save-status">
{storageReady ? "Saved in this browser" : "Loading inventory"}
</span>
</header>
<section className="summary-grid" aria-label="Inventory summary">
<article className="summary-card"><span>Products</span><strong>{items.length}</strong></article>
<article className="summary-card"><span>Total units</span><strong>{totalUnits}</strong></article>
<article className="summary-card warning-card"><span>Low stock</span><strong>{lowStockCount}</strong></article>
</section>
<section className="panel">
<div className="section-heading">
<div><p className="eyebrow">New product</p><h2>Add inventory</h2></div>
</div>
<form
className="item-form"
onSubmit={(event) => {
event.preventDefault();
const name = draft.name.trim();
const sku = draft.sku.trim().toUpperCase();
if (!name || !sku) return;
const newItem: InventoryItem = {
id: `${sku.toLowerCase()}-${Date.now()}`,
name,
sku,
quantity: Math.max(0, Number(draft.quantity)),
minimum: Math.max(0, Number(draft.minimum)),
};
setItems((currentItems) => [newItem, ...currentItems]);
setDraft(emptyDraft);
}}
>
<label>Item name<input name="name" value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="Packing tape" required /></label>
<label>SKU<input name="sku" value={draft.sku} onChange={(event) => setDraft({ ...draft, sku: event.target.value })} placeholder="PT-401" required /></label>
<label>Starting quantity<input name="quantity" type="number" min="0" value={draft.quantity} onChange={(event) => setDraft({ ...draft, quantity: event.target.value })} required /></label>
<label>Low-stock threshold<input name="minimum" type="number" min="0" value={draft.minimum} onChange={(event) => setDraft({ ...draft, minimum: event.target.value })} required /></label>
<button className="primary-button" type="submit">Add item</button>
</form>
</section>
<section className="panel">
<div className="section-heading inventory-heading">
<div><p className="eyebrow">Current stock</p><h2>Inventory</h2></div>
<div className="toolbar">
<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search name or SKU" aria-label="Search inventory" />
<label className="toggle"><input type="checkbox" checked={showLowOnly} onChange={(event) => setShowLowOnly(event.target.checked)} />Low stock only</label>
</div>
</div>
<div className="table-wrap">
<table>
<thead><tr><th>Product</th><th>SKU</th><th>Quantity</th><th>Threshold</th><th>Status</th></tr></thead>
<tbody>
{filteredItems.map((item) => {
const isLow = item.quantity <= item.minimum;
return (
<tr key={item.id}>
<td className="product-name">{item.name}</td>
<td>{item.sku}</td>
<td>
<div className="stock-control">
<button type="button" onClick={() => changeQuantity(item.id, -1)} disabled={item.quantity === 0} aria-label={`Decrease ${item.name} quantity`}>−</button>
<strong>{item.quantity}</strong>
<button type="button" onClick={() => changeQuantity(item.id, 1)} aria-label={`Increase ${item.name} quantity`}>+</button>
</div>
</td>
<td>{item.minimum}</td>
<td><span className={`status ${isLow ? "status-low" : "status-ok"}`}>{isLow ? "Low stock" : "Healthy"}</span></td>
</tr>
);
})}
{filteredItems.length === 0 && <tr><td className="empty-state" colSpan={5}>No items match the current filters.</td></tr>}
</tbody>
</table>
</div>
</section>
</main>
);
}
Your inventory now survives refreshes and narrows to the products you need. Next up, you will publish Stockroom Lite through GitHub and Vercel.
Publish Stockroom Lite
Stockroom Lite now preserves inventory changes after a browser refresh. Its search tools also help you focus on the stock that matters.
The app still lives on your computer. Publishing gives other people a public address where they can use it.
Git records the release snapshot. GitHub gives Vercel Hobby the source it needs to publish the app.
In this step, get ready to:
- Prepare a production build and commit the complete project.
- Publish the source and lockfile to GitHub.
- Deploy Stockroom Lite to a public Vercel URL.
Build and commit the production app
A production build asks Next.js to compile the app using its release settings. This catches problems before the same source reaches Vercel.
- Return to the VS Code terminal running the development server.
- Stop the development server by pressing Ctrl+C.
Before you run the production check, do you expect the current project to build successfully?
- Create the production build by running this command:
npm run build
What Does the Build Check?
- Next.js compiles the application using its production settings.
- A successful build confirms that the current source is ready for Vercel to process.
You should see the production build complete without an error. That release gate is now cleared.
Build Did Not Complete?
- Read the first reported problem in the terminal output.
- Check the named file for an unsaved change or a TypeScript problem.
Get help with the build output by asking: Help me diagnose my Stockroom Lite production build.
Keep Credentials Out of Git
Stockroom Lite needs no passwords or API keys. Never add credentials to a commit or push them to GitHub.
- Create the local release snapshot by running these commands:
git init -b main
git add .
git commit -m "Build Stockroom Lite"
What Does the Release Snapshot Contain?
- The first command creates a local repository on the main branch.
- The second command stages the project source and package-lock.json.
- The final command records the staged files with the message Build Stockroom Lite.
- The .gitignore file keeps generated output and local environment files outside the snapshot.
You should see a new commit confirmed on the main branch. Your complete project now has a version-controlled release snapshot.
Commit Did Not Complete?
- Confirm that the terminal is inside the stockroom-lite folder.
- Confirm that your Git name and commit email remain configured.
Get help with the commit output by asking: Help me fix my Stockroom Lite commit.
Publish the source to GitHub
The local commit stores your project history on this computer. A remote repository makes the same snapshot available to Vercel.
- Open GitHub's repository creation flow in your browser.
- Enter stockroom-lite as the repository name.
- Leave the repository initialization options disabled.
- Create the repository using the completed form.
You should see an empty repository with a remote URL. Your first push supplies the complete project.
- Record the repository URL here: your GitHub repository URL.
- Connect the local repository by replacing REMOTE-URL with the repository URL before running this command:
git remote add origin REMOTE-URL
What Does Origin Mean?
The name origin identifies the GitHub repository connected to this local project. Git can use that short name for future pushes.
- Confirm the remote connection by running this command:
git remote -v
What Does the Remote Check Prove?
The command prints the repository address attached to origin. This confirms that Git knows where to send the release snapshot.
You should see your GitHub repository URL listed for the remote connection.
The first push may open a GitHub authentication flow in your browser. Complete the approval without copying credentials into the project.
- Publish the main branch by running this command:
git push -u origin main
What Does the Push Publish?
The push copies the committed source to GitHub. It also connects the local main branch to the remote branch.
You should see the complete Stockroom Lite project in GitHub. The repository file list should include package-lock.json.
Push Did Not Reach GitHub?
- Confirm that the remote URL belongs to the empty stockroom-lite repository.
- Complete the browser authentication flow if GitHub paused the push for approval.
Get help with the failed push by asking: Help me publish Stockroom Lite to GitHub.
Deploy and verify Stockroom Lite
Connecting Vercel to GitHub creates a continuous deployment path. Future pushes to the connected branch can trigger new deployments.
This import creates a Vercel Hobby project. It costs $0/mo. for personal, non-commercial use.
- Open the New Project flow in Vercel.
- Connect GitHub as the source provider.
- Approve the requested GitHub access if an authorization flow opens.
- Select the stockroom-lite repository.
- Confirm that Vercel identifies the project as Next.js.
- Click Deploy.
Vercel builds the committed source before publishing it. Keep the build logs open until Vercel provides the public production URL.
What Does the Git Connection Add?
Vercel watches the connected GitHub branch for new commits. A later push can create a fresh deployment from the updated source.
Deployment Did Not Complete?
- Review the earliest failed task in the Vercel build logs.
- Confirm that the imported repository contains package-lock.json and the complete source.
Get help with the deployment logs by asking: Help me diagnose my Stockroom Lite deployment.
- Record the deployed address here: your Vercel production URL.
Before you open the deployed app, do you expect inventory saved at localhost to appear at the production URL?
- Open your Vercel production URL in your browser.
Your localhost inventory does not carry over to the deployed app. Each address has a separate browser storage area.
- Enter Deployment Test in the Item name field.
- Enter DEP-501 in the SKU field.
- Enter 1 in the Starting quantity field.
- Enter 2 in the Low-stock threshold field.
- Click Add item.
You should see Deployment Test at the top of the inventory table with a low-stock status.
Before you refresh the production page, do you expect the new item to remain in the table?
- Refresh the production browser tab.
You should still see Deployment Test after the refresh. The deployed app has restored it from browser storage.
- Type Deployment Test into the Search inventory field.
- Select the Low stock only checkbox.
You should see only the matching low-stock item in the table. The summary cards should continue to describe the full inventory.
Why Is the Deployed Inventory Separate?
localStorage keeps a separate storage area for each app origin. Inventory saved at the Vercel production URL persists there without copying data from localhost.
Item Missing After the Refresh?
- Confirm that you refreshed the same Vercel production URL where you added the item.
- Check whether the browser is blocking site storage for the deployed app.
Get help with browser persistence by asking: Help me debug deployed Stockroom Lite persistence.
Stockroom Lite is now public and fully usable. Its deployed inventory survives refreshes in the browser where it was created.
Secret mission
Add Inventory Sorting
Add a sorting control that orders the visible inventory by product name or quantity. The new display order will preserve every stored quantity and work alongside the existing search and low-stock filter.
Clean Up Your Resources
Clean Up Your Resources
Your Vercel Hobby deployment costs $0/mo. for personal, non-commercial use within its included limits. Decide whether to keep each project copy or remove it completely.
Resources you used:
- The local stockroom-lite folder containing the complete project.
- The GitHub repository named stockroom-lite.
- The Vercel Hobby project named stockroom-lite.
- Browser localStorage inventory saved under stockroom-lite-items for the localhost origin.
- Browser inventory saved separately for the Vercel production origin.
Keep everything running
No action needed. Choose this if you want Stockroom Lite to remain available for future inventory tracking.
- Keep the local stockroom-lite folder as your editable source.
- Leave the GitHub repository available for future commits.
- Leave the Vercel deployment available at its public production URL.
- Retain the browser data for each origin to preserve its saved inventory.
Pause - I'll come back to this later
Pausing means stepping away without deleting any project copy. Your public deployment remains available while development is paused.
- Close Visual Studio Code to pause local development.
- Stop pushing new commits to the GitHub repository.
- Leave the Vercel deployment active at its current version.
- Keep the browser data for each origin so its inventory remains available.
Delete - I don't want to use this again
Deletion is the permanent option. Choose it only when you no longer need Stockroom Lite.
Delete the local project:
- Use File Explorer to navigate to the location containing stockroom-lite.
- Select the stockroom-lite folder.
- Delete the selected folder.
The stockroom-lite folder should no longer appear in File Explorer. Its local Git history is removed with it.
Clear the saved browser inventory:
- Open your browser's site-data settings to locate data for the two Stockroom Lite origins.
- Remove stored data for http://localhost:3000.
- Remove stored data for your Vercel production URL.
The inventory stored under stockroom-lite-items is now removed separately from both origins.
Delete the GitHub repository:
- Navigate to the repository settings for stockroom-lite on GitHub to locate the deletion controls.
- Use the repository deletion control to permanently delete stockroom-lite.
The repository URL should no longer open the Stockroom Lite source.
Delete the Vercel project:
- Navigate to the project settings for stockroom-lite on Vercel to locate the deletion controls.
- Use the project deletion control to permanently delete stockroom-lite.
The production URL should stop serving Stockroom Lite.
Nice Work!
Nice Work!
You did it! Stockroom Lite is now a deployed inventory manager for tracking personal stock.
You've learned how to:
- Build a responsive inventory dashboard with the Next.js App Router. A React Client Component powers every stock interaction.
- Manage product creation with controlled forms. Apply immutable array updates to quantity changes. Derived summaries, search results, and low-stock views stay in sync with inventory state.
- Preserve inventory for each browser origin with guarded localStorage effects. Publish the source on GitHub. Deploy the app to Vercel.
- Secret Mission: Added immutable inventory sorting by name or quantity without changing stored stock data.
Ready to quiz yourself?