Build a MERN Incident Triage Board
Build a MERN incident board with validation, errors, and MongoDB indexes.
Introduction
30 Second Summary
An incident form can look complete while bad data slips through unnoticed. The real challenge begins when several systems must preserve the same rules under failure.
In this project, you will build a production-minded MERN architecture that follows incidents from React through Express to MongoDB Atlas. You will expose a weak validation boundary before hardening the API with domain validation, consistent errors, database uniqueness, and query-aligned indexes.
What You'll Build
You'll demo a working incident board from browser input through persistence, filtering, and visible failure handling.
By the end of this project, you'll have:
- A persistent incident workflow where you create a record in Safari and confirm that it remains after a refresh.
- A hardened API boundary that rejects invalid severities and returns a clear conflict when an external incident ID already exists.
- An indexed status filter that narrows the incident queue through a database query designed around its filter and sort order.
- Secret Mission: Add a guarded workflow that permits only open to investigating to resolved status transitions.
Do I need anything before starting?
You need senior React experience plus basic familiarity with Node.js, Express, and MongoDB CRUD. You also need a Mac with Node.js, Visual Studio Code, Safari, and access to MongoDB Atlas.
Before We Start
Before the hands-on work begins, this step locks in your goal for the Incident Triage Board. It also focuses your production-minded MERN demo on the backend theory gap you want to strengthen.
Set Up the MERN Workspace
A production-minded MERN request path depends on a predictable local runtime. Version drift can make an environment problem look like an application bug.
This step verifies Node.js before you create the workspace. You will finish with React rendering through Vite on a fixed port in Safari.
In this step, get ready to:
- Verify the Node.js runtime plus the npm package manager.
- Create the project files inside a local VS Code workspace.
- Install the pinned packages plus run the starter client.
Verify Node.js and npm
This project uses Node.js 24 LTS to run its development tools plus backend code. Vite supports Node.js 20.19+ or 22.12+. The pinned LTS release keeps every later layer on one known runtime.
- Open macOS search by pressing Cmd+Space.
- Type Terminal into the search field.
- Press Enter to open Terminal.
- Check the installed Node.js version plus npm availability by running these commands:
node -v
npm -v
What do these checks prove?
- The first command prints the installed Node.js version.
- The second command confirms that npm is available for installing packages plus running project scripts.
- Two version lines prove that the runtime plus package manager can both be reached from Terminal.
✔️ I see a compatible version
Your first line should begin with v24. Your second line should contain an npm version number.
Your local toolchain is ready. You can now build the workspace without carrying a hidden runtime mismatch into later steps.
ⓧ I see an older version
The installed runtime is older than the Node.js release pinned for this project. Install the current LTS release before creating the workspace.
- Open the Node.js download page in your browser.
- Download the macOS installer for v24.22.1 LTS.
- Run the downloaded installer.
- Close Terminal after the installation finishes.
- Reopen Terminal through macOS search.
- Run the same two version checks again.
ⓧ Command not found
Terminal cannot reach Node.js yet. Installing the current LTS release provides Node.js plus npm.
- Open the Node.js download page in your browser.
- Download the macOS installer for v24.22.1 LTS.
- Run the downloaded installer.
- Close Terminal after the installation finishes.
- Reopen Terminal through macOS search.
- Run the same two version checks again.
Still missing Node.js or npm?
A terminal opened before installation can retain its previous command search path. Close that window completely before repeating the checks in a new Terminal window.
If Node.js works but npm does not, rerun the Node.js LTS installer. Help me diagnose my Node.js and npm installation.
Create the project workspace
A Visual Studio Code workspace gives the editor plus its integrated terminal the same folder context. You will create incident-triage-board on your Desktop so every project file has a predictable location.
- Open macOS search by pressing Cmd+Space.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Click File in the macOS menu bar.
- Select Open Folder....
The folder picker now controls where the project lives. Choose the Desktop so the workspace remains easy to find.
- Select Desktop in the folder picker.
- Select New Folder.
- Enter incident-triage-board as the folder name.
- Select the new incident-triage-board folder.
- Click Open.
VS Code may show a Workspace Trust dialog because this folder can run code. You created the empty folder yourself.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
- Confirm that the Explorer sidebar shows incident-triage-board.
Your workspace now has a stable home on the Desktop. Every file created from the Explorer belongs to the same project.
- Create package.json inside incident-triage-board using the New File... control in the Explorer.
- Define the project scripts plus pinned packages by pasting this code into package.json:
{
"name": "incident-triage-board",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"client": "vite",
"server": "node server/index.js",
"build": "vite build"
},
"dependencies": {
"express": "5.3.0",
"mongoose": "9.11.4",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.2",
"vite": "8.3.5"
}
}
What does this manifest control?
- The package name ties installed dependencies to this workspace.
- The module setting lets Node.js load the import syntax used throughout the project.
- The scripts provide one entry point for the client plus one for the server plus one for the production build.
- The dependency versions keep every installation on the same React, Vite, Express, plus Mongoose releases.
- Save package.json by pressing Cmd+S.
- Confirm that package.json remains listed under incident-triage-board in the Explorer.
Does package.json show a warning?
A missing quote or comma can make the file invalid JSON. Compare the opening braces plus closing braces with the code above.
Use the complete-code comparison below if the warning remains. Help me find the JSON syntax problem.
- Click View in the VS Code menu bar.
- Select Terminal.
- Install the dependencies declared in package.json by running this command:
npm install
What does this install do?
npm reads the dependency lists from package.json. It downloads those packages into the local workspace.
The pinned versions make later behavior reproducible. Every layer starts from the same package set.
- Wait for the terminal prompt to return.
- Confirm that the Explorer shows a node_modules folder.
Did the dependency installation fail?
Confirm that the integrated terminal belongs to the incident-triage-board workspace. Confirm that package.json has been saved.
A network restriction can also interrupt package downloads. Help me troubleshoot npm install.
- Create .gitignore inside incident-triage-board using the New File... control in the Explorer.
- Protect generated files plus the future local database configuration by pasting this code into .gitignore:
node_modules
dist
server/config.js
.DS_Store
Why ignore these paths?
The dependency folder plus production build can be recreated from project files. The local server configuration later holds your Atlas connection string.
Keeping these paths out of source control prevents generated output plus local configuration from becoming repository history.
- Save .gitignore.
- Confirm that the Explorer lists .gitignore beside package.json.
- Create index.html inside incident-triage-board using the New File... control in the Explorer.
- Define the browser entry document by pasting this code into index.html:
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Incident Triage Board</title></head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
</html>
How does the page reach React?
- The root element gives React a browser element to control.
- The module script loads src/main.jsx as the client entry point.
- The document title identifies the app in the Safari tab.
- Save index.html.
- Confirm that the Explorer lists index.html.
Does the browser entry look incomplete?
Confirm that the file is named index.html in lowercase. Check that the script path ends with /src/main.jsx.
Help me compare my browser entry file.
- Create vite.config.js inside incident-triage-board using the New File... control in the Explorer.
- Set the React plugin plus development server behavior by pasting this code into vite.config.js:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 5173, strictPort: true, proxy: { "/api": "http://localhost:4000" } }
});
What does the Vite configuration control?
- The React plugin transforms the JSX used by the client.
- Port 5173 gives the browser a predictable local address.
- The strict port setting stops startup if that port is already occupied.
- The proxy sends matching /api requests to http://localhost:4000 once the Express server exists.
- Save vite.config.js.
- Confirm that the Explorer lists vite.config.js.
Does Vite reject the configuration?
Check that both import paths match the installed package names. Confirm that the server object closes before the final parenthesis.
Help me inspect my Vite configuration.
- Create a src folder inside incident-triage-board using the New Folder control in the Explorer.
- Confirm that the Explorer shows src under incident-triage-board.
- Create App.jsx inside the new src folder using the New File... control.
- Add the starter component by pasting this code into src/App.jsx:
export default function App() {
return <main><h1>Incident Triage setup ready.</h1></main>;
}
What does the starter component do?
The App component returns the first visible interface for the project. Its heading gives you a concrete signal that React rendered successfully.
- Save src/App.jsx.
- Confirm that App.jsx appears inside src.
Does App.jsx show a syntax warning?
Check the function braces plus the JSX closing tags. Confirm that the exported function is named App.
Help me inspect the starter component.
- Create main.jsx inside the src folder using the New File... control.
- Connect React to the browser document by pasting this code into src/main.jsx:
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
createRoot(document.getElementById("root")).render(<App />);
How does React mount the app?
- The client import provides createRoot for mounting React in the browser.
- The component import makes App available to the entry file.
- The stylesheet import asks Vite to include the local CSS.
- The final line renders App into the root element from index.html.
- Save src/main.jsx.
- Confirm that main.jsx appears inside src.
Does main.jsx show an import warning?
Confirm that App.jsx uses an uppercase A. Check that the stylesheet path begins with ./.
Help me inspect the React entry file.
- Create styles.css inside the src folder using the New File... control.
- Add the starter page reset by pasting this code into src/styles.css:
body { margin: 0; font-family: system-ui, sans-serif; }
What does the starter style change?
The rule removes the browser's default body margin. It also uses the operating system's interface font for the starter screen.
- Save src/styles.css.
- Confirm that styles.css appears inside src.
✔️ Awesome, I've got everything!
Your workspace contains the pinned manifest plus the browser entry plus the Vite configuration plus the three starter source files. Make sure every open editor tab has been saved.
ⓧ I'd like to double check the full code
These are the complete project files at this point.
node_modules
dist
server/config.js
.DS_Store
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Incident Triage Board</title></head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
</html>
{
"name": "incident-triage-board",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"client": "vite",
"server": "node server/index.js",
"build": "vite build"
},
"dependencies": {
"express": "5.3.0",
"mongoose": "9.11.4",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.2",
"vite": "8.3.5"
}
}
export default function App() {
return <main><h1>Incident Triage setup ready.</h1></main>;
}
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
createRoot(document.getElementById("root")).render(<App />);
body { margin: 0; font-family: system-ui, sans-serif; }
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 5173, strictPort: true, proxy: { "/api": "http://localhost:4000" } }
});
Run the starter client
The development server now has every file it needs to follow the browser entry into React. Starting the client also proves that the pinned packages can load together.
Before you start the client, do you expect Safari to show the React heading or the raw HTML document?
- Switch back to the integrated Terminal from earlier.
- Start the Vite client through the package script by running this command:
npm run client
What does this command start?
npm reads the client script from package.json. That script starts the local Vite development server.
Vite reads vite.config.js before serving the app on port 5173. The terminal remains occupied while the server runs.
You should see Vite report a local address using port 5173. Keep this terminal process running while you check the page.
- Open macOS search by pressing Cmd+Space.
- Type Safari into the search field.
- Press Enter to open Safari.
- Click the Safari address field.
- Enter http://localhost:5173.
- Press Enter to load the client.
You should see Incident Triage setup ready. as the page heading. You now have the local client path working from Safari through Vite into React.
Does the starter page fail to load?
- Confirm that the integrated terminal still shows the running Vite process.
- If port 5173 is occupied, return to the process already using that port.
- Stop an unneeded Vite process by pressing Ctrl+C in its terminal.
- Compare the filenames plus import paths with the complete-code tab if Safari shows a blank page.
Help me troubleshoot the starter client.
Interview checkpoint
- Node.js is the runtime that executes JavaScript outside the browser.
- npm is the package manager that installs dependencies plus runs project scripts.
- Vite is the development plus build tool that serves the client during local work.
- React owns the presentation layer rendered into the browser document.
- Express provides the HTTP application layer that you will start in a later step.
Your pinned MERN workspace is running on a predictable local port. Next up, you will replace the setup heading with a visible incident board plus client-owned mock data.
Build the Visible Incident Board
Your Vite client already proves the local workspace runs. Now the setup screen becomes the incident board that operators interact with.
A mock board gives you a visible product before network requests or database behavior enter the lifecycle. React owns the records in memory during this step.
In this step, get ready to:
- Render two mock incidents from React state.
- Control the incident form with React state.
- Create local incidents through the form.
- Style the board with incident cards and badges.
Build the state-owned board
A controlled form stores each input value in component state. The submitted incident joins the existing array through an immutable update.
- Select src/App.jsx in the VS Code Explorer sidebar.
- Replace the current setup component with the board by pasting this code:
import { useState } from "react";
const INITIAL_INCIDENTS = [
{ id: "mock-1", externalId: "OPS-101", title: "Checkout latency", severity: "high", status: "open" },
{ id: "mock-2", externalId: "OPS-102", title: "Search timeout", severity: "medium", status: "open" }
];
export default function App() {
const [incidents, setIncidents] = useState(INITIAL_INCIDENTS);
const [form, setForm] = useState({ externalId: "", title: "", severity: "medium" });
function updateForm(event) { const { name, value } = event.target; setForm((current) => ({ ...current, [name]: value })); }
function createIncident(event) { event.preventDefault(); setIncidents((current) => [...current, { ...form, id: crypto.randomUUID(), status: "open" }]); setForm({ externalId: "", title: "", severity: "medium" }); }
return <main className="shell"><header className="hero"><p className="eyebrow">MERN request lifecycle lab</p><h1>Incident Triage Board</h1></header><section className="panel"><h2>Create incident</h2><form className="incident-form" onSubmit={createIncident}><label>External ID<input name="externalId" value={form.externalId} onChange={updateForm} required /></label><label>Title<input name="title" value={form.title} onChange={updateForm} required /></label><label>Severity<input name="severity" value={form.severity} onChange={updateForm} required /></label><button>Create incident</button></form></section><section className="panel"><h2>Incidents</h2><div className="incident-grid">{incidents.map((incident) => <article className="incident-card" key={incident.id}><div className="badge-row"><span className={`badge severity-${incident.severity}`}>{incident.severity}</span><span className="badge status">{incident.status}</span></div><h3>{incident.title}</h3><p className="incident-id">{incident.externalId}</p></article>)}</div></section></main>;
}
What does this code do?
- The INITIAL_INCIDENTS array gives the board two records to render immediately.
- The first useState call stores the incident collection in memory.
- The second useState call stores the current form values.
- The updateForm() function copies each typed value into its matching form field.
- The createIncident() function appends a new object without mutating the existing array.
- The rendered list maps every incident to a card with severity and status badges.
- Save src/App.jsx.
- Return to Safari.
- Refresh http://localhost:5173.
You should see the incident form above cards for OPS-101 and OPS-102.
Seeing a blank page?
- Compare the closing JSX tags in src/App.jsx with the code above.
- Confirm the final closing brace remains beneath the component output.
- Check that useState is imported at the top of the file.
Help me diagnose why my React incident board renders a blank page.
Test the local create flow
The browser currently owns every incident. Submitting the form changes only the in-memory array held by the component.
- Enter OPS-103 in the External ID field.
- Enter Login queue delay in the Title field.
- Replace the severity value with critical.
Before you submit, predict how the card count and form values will change.
- Click Create incident.
You should see a third card for OPS-103 immediately.
The form should return to empty text fields with medium restored as the severity.
Before you refresh, consider whether the third card will remain.
- Refresh Safari.
You should see the board return to the two mock incidents. The refresh creates fresh component state from INITIAL_INCIDENTS.
Why does the new incident disappear?
React state belongs to the current browser session. A page refresh rebuilds the component from its initial values.
This makes state a responsive presentation boundary. It does not provide persistence or protect concurrent writes.
Style the triage board
The JSX already assigns classes to the layout and badges. CSS now turns that structure into a compact operator dashboard.
- Select src/styles.css in the VS Code Explorer sidebar.
- Replace the current body rule with the complete board styles by pasting this code:
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #e8edf7; background: #0b1020; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #17254a, #0b1020 52%); }
button, input, select { font: inherit; }
.shell { width: min(1100px, calc(100% - 32px)); margin: auto; padding: 48px 0 72px; }
.hero { margin-bottom: 28px; }
.hero h1, .panel h2 { margin: 4px 0 8px; }
.eyebrow { margin: 0; color: #8fb5ff; font-size: .78rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
.panel { margin-top: 20px; padding: 24px; border: 1px solid #2c3b64; border-radius: 18px; background: #101931; }
.incident-form { display: grid; grid-template-columns: 1fr 2fr 1fr auto; gap: 14px; align-items: end; }
label { display: grid; gap: 7px; color: #b9c6df; font-size: .85rem; font-weight: 700; }
input, select { width: 100%; border: 1px solid #41527f; border-radius: 10px; padding: 10px 12px; color: #f7f9ff; background: #101a34; }
button { border: 0; border-radius: 10px; padding: 11px 16px; background: #8fb5ff; font-weight: 800; cursor: pointer; }
.toolbar, .badge-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.incident-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin-top: 18px; }
.incident-card { border: 1px solid #30416c; border-radius: 14px; padding: 18px; background: #101a34; }
.incident-id, time { color: #9aa9c5; }
.badge { border-radius: 999px; padding: 5px 9px; font-size: .72rem; font-weight: 800; text-transform: uppercase; }
.severity-low { background: #245a45; }.severity-medium { background: #665521; }.severity-high { background: #81451f; }.severity-critical { background: #7b2738; }.status { background: #28375c; }
@media (max-width: 760px) { .incident-form { grid-template-columns: 1fr; }.toolbar { align-items: stretch; flex-direction: column; } }
What do these styles control?
- The page rules create the dark background and centered content shell.
- The form grid keeps related fields aligned on wide screens.
- The incident grid creates responsive card columns.
- The badge classes give each severity a distinct color.
- The media query stacks the form on narrower screens.
- Save src/styles.css.
- Return to Safari.
- Refresh the incident board.
You should see a dark dashboard with bordered panels. Each incident should appear on its own card with separate severity and status badges.
Still seeing an unstyled page?
- Confirm src/styles.css is saved.
- Check that src/main.jsx still imports ./styles.css.
- Compare .incident-card with the matching class in src/App.jsx.
Help me find why the incident board CSS is not appearing in Safari.
✔️ Awesome, I've got everything!
Your incident board is running with two mock records. Double check that you saved src/App.jsx and src/styles.css.
ⓧ I'd like to double check the full code
Compare your project with the complete files for this step:
node_modules
dist
server/config.js
.DS_Store
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Incident Triage Board</title></head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
</html>
{
"name": "incident-triage-board",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"client": "vite",
"server": "node server/index.js",
"build": "vite build"
},
"dependencies": {
"express": "5.3.0",
"mongoose": "9.11.4",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.2",
"vite": "8.3.5"
}
}
import { useState } from "react";
const INITIAL_INCIDENTS = [
{ id: "mock-1", externalId: "OPS-101", title: "Checkout latency", severity: "high", status: "open" },
{ id: "mock-2", externalId: "OPS-102", title: "Search timeout", severity: "medium", status: "open" }
];
export default function App() {
const [incidents, setIncidents] = useState(INITIAL_INCIDENTS);
const [form, setForm] = useState({ externalId: "", title: "", severity: "medium" });
function updateForm(event) { const { name, value } = event.target; setForm((current) => ({ ...current, [name]: value })); }
function createIncident(event) { event.preventDefault(); setIncidents((current) => [...current, { ...form, id: crypto.randomUUID(), status: "open" }]); setForm({ externalId: "", title: "", severity: "medium" }); }
return <main className="shell"><header className="hero"><p className="eyebrow">MERN request lifecycle lab</p><h1>Incident Triage Board</h1></header><section className="panel"><h2>Create incident</h2><form className="incident-form" onSubmit={createIncident}><label>External ID<input name="externalId" value={form.externalId} onChange={updateForm} required /></label><label>Title<input name="title" value={form.title} onChange={updateForm} required /></label><label>Severity<input name="severity" value={form.severity} onChange={updateForm} required /></label><button>Create incident</button></form></section><section className="panel"><h2>Incidents</h2><div className="incident-grid">{incidents.map((incident) => <article className="incident-card" key={incident.id}><div className="badge-row"><span className={`badge severity-${incident.severity}`}>{incident.severity}</span><span className="badge status">{incident.status}</span></div><h3>{incident.title}</h3><p className="incident-id">{incident.externalId}</p></article>)}</div></section></main>;
}
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
createRoot(document.getElementById("root")).render(<App />);
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #e8edf7; background: #0b1020; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at top, #17254a, #0b1020 52%); }
button, input, select { font: inherit; }
.shell { width: min(1100px, calc(100% - 32px)); margin: auto; padding: 48px 0 72px; }
.hero { margin-bottom: 28px; }
.hero h1, .panel h2 { margin: 4px 0 8px; }
.eyebrow { margin: 0; color: #8fb5ff; font-size: .78rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; }
.panel { margin-top: 20px; padding: 24px; border: 1px solid #2c3b64; border-radius: 18px; background: #101931; }
.incident-form { display: grid; grid-template-columns: 1fr 2fr 1fr auto; gap: 14px; align-items: end; }
label { display: grid; gap: 7px; color: #b9c6df; font-size: .85rem; font-weight: 700; }
input, select { width: 100%; border: 1px solid #41527f; border-radius: 10px; padding: 10px 12px; color: #f7f9ff; background: #101a34; }
button { border: 0; border-radius: 10px; padding: 11px 16px; background: #8fb5ff; font-weight: 800; cursor: pointer; }
.toolbar, .badge-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.incident-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin-top: 18px; }
.incident-card { border: 1px solid #30416c; border-radius: 14px; padding: 18px; background: #101a34; }
.incident-id, time { color: #9aa9c5; }
.badge { border-radius: 999px; padding: 5px 9px; font-size: .72rem; font-weight: 800; text-transform: uppercase; }
.severity-low { background: #245a45; }.severity-medium { background: #665521; }.severity-high { background: #81451f; }.severity-critical { background: #7b2738; }.status { background: #28375c; }
@media (max-width: 760px) { .incident-form { grid-template-columns: 1fr; }.toolbar { align-items: stretch; flex-direction: column; } }
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 5173, strictPort: true, proxy: { "/api": "http://localhost:4000" } }
});
Before the final check, predict what the board will show after a fresh page load.
- Switch back to the Terminal panel where npm run client remains active.
- Confirm the Vite process is still running.
- Return to Safari.
- Refresh http://localhost:5173.
You should see two styled incident cards beneath the create form.
- Enter OPS-103 in the External ID field.
- Enter Login queue delay in the Title field.
- Enter critical in the Severity field.
Before you submit, predict where the new card will appear.
- Click Create incident.
You should see a third card immediately. Its badges should show critical and open.
That is your first complete interaction loop: controlled input becomes client-owned state and renders as a visible incident card.
Your browser now demonstrates the full local interaction loop. Next up, the incident data moves beyond the current page session.
Wire the Full Request Path
Your React board now creates incidents instantly. A refresh still erases every locally created record.
This step routes each request through Vite, Express, Mongoose, and MongoDB Atlas. You will test whether a free-form browser field creates a trustworthy data boundary.
In this step, get ready to:
- Store your Atlas connection string in an ignored local configuration file.
- Build a layered API that persists incidents in Atlas.
- Replace the local React flow with API requests through the existing Vite proxy.
Store the Atlas connection safely
The server needs an Atlas connection string before it can persist incidents. This value contains database credentials, so it belongs only in the ignored server/config.js file.
- Return to MongoDB Atlas in your browser.
- Open Clusters.
- Select Connect for your deployment.
- Select Drivers as the connection method.
- Copy the value under Use this connection string in your application.
- Complete the copied connection string with your Atlas database user credentials.
Keep the Connection Private
Your completed connection string can grant access to your database. Keep it off screenshots, chat messages, and source control.
Atlas also requires your client IP on the project access list. Your deployment must have a database user before the server can connect.
- Switch back to the Visual Studio Code window from the previous step.
- Create a server folder inside incident-triage-board using the Explorer sidebar.
- Create config.example.js inside the new server folder using the New File control.
- Add the safe configuration example to server/config.example.js by pasting this code:
export const MONGODB_URI = "your-mongodb-connection-string";
What Does This Example Do?
The example documents the required MONGODB_URI export without exposing a working credential. Someone can inspect this file to understand the configuration shape safely.
- Save server/config.example.js.
- Confirm that config.example.js appears inside the server folder in the Explorer sidebar.
- Create config.js inside the same server folder using the New File control.
- Add the local configuration export to server/config.js by pasting this code:
export const MONGODB_URI = "your-mongodb-connection-string";
Why Use a Separate Local File?
The server imports MONGODB_URI from this local file at startup. The existing .gitignore entry prevents the completed file from entering source control.
- Replace your-mongodb-connection-string in server/config.js with the completed connection string from Atlas.
- Save server/config.js.
- Return to .gitignore in the Explorer sidebar.
- Confirm that server/config.js remains listed in .gitignore.
Build the layered API
A layered API gives each part of the request path one job. The model defines persisted fields, the service owns the database operation, and the route translates an HTTP request into that service call.
Why a Modular Monolith?
A modular monolith keeps the route, service, model, and startup boundaries visible inside one server process. It demonstrates backend separation without adding deployment work from multiple services.
- Create a models folder inside server using the Explorer sidebar.
- Create Incident.js inside server/models using the New File control.
- Define the initial persistence model in server/models/Incident.js by pasting this code:
import mongoose from "mongoose";
const incidentSchema = new mongoose.Schema({
externalId: String,
title: String,
severity: String,
status: { type: String, default: "open" },
createdAt: { type: Date, default: Date.now }
});
export const Incident = mongoose.model("Incident", incidentSchema);
What Does the Model Do?
- The schema records the external identifier, title, severity, workflow status, and creation time for each incident.
- The status field defaults to open when a new document is saved.
- The plain String severity field deliberately has no domain validation in this first API version.
- Save server/models/Incident.js.
- Confirm that Incident.js appears inside server/models in the Explorer sidebar.
- Create a services folder inside server using the Explorer sidebar.
- Create incidentService.js inside server/services using the New File control.
- Add the initial database operations to server/services/incidentService.js by pasting this code:
import { Incident } from "../models/Incident.js";
export async function createIncident(input) {
return new Incident({ externalId: input.externalId, title: input.title, severity: input.severity, status: "open" }).save();
}
export async function listIncidents() {
return Incident.find().sort({ createdAt: -1 });
}
What Does the Service Do?
- The createIncident() service converts request input into an Incident document before saving it.
- The listIncidents() service returns the newest records first.
- The service currently trusts the incoming strings. This gives you a real boundary to test before hardening it.
- Save server/services/incidentService.js.
- Confirm that incidentService.js appears inside server/services in the Explorer sidebar.
- Create a routes folder inside server using the Explorer sidebar.
- Create incidents.js inside server/routes using the New File control.
- Register the read and create endpoints in server/routes/incidents.js by pasting this code:
import { createIncident, listIncidents } from "../services/incidentService.js";
export function registerIncidentRoutes(app) {
app.get("/api/incidents", async (request, response) => response.json({ data: await listIncidents() }));
app.post("/api/incidents", async (request, response) => response.status(201).json({ data: await createIncident(request.body) }));
}
What Do the Routes Do?
- The GET /api/incidents route asks the service for persisted incidents before returning a JSON response.
- The POST /api/incidents route passes the parsed request body to the service before responding with status 201.
- Express forwards rejected promises from these asynchronous route handlers into its error flow.
- Save server/routes/incidents.js.
- Confirm that incidents.js appears inside server/routes in the Explorer sidebar.
- Create index.js directly inside the server folder using the New File control.
- Add the server startup flow to server/index.js by pasting this code:
import express from "express";
import mongoose from "mongoose";
import { MONGODB_URI } from "./config.js";
import { registerIncidentRoutes } from "./routes/incidents.js";
const app = express();
const PORT = 4000;
app.use(express.json());
app.get("/api/health", (request, response) => response.json({ status: "ok" }));
registerIncidentRoutes(app);
async function start() {
await mongoose.connect(MONGODB_URI);
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
}
start().catch((error) => { console.error("Failed to start server", error); process.exitCode = 1; });
How Does Startup Work?
- The express.json() middleware parses incoming JSON bodies before the incident routes receive them.
- The health route provides a database-connected server check without creating an incident.
- The start() function waits for Mongoose to connect before Express listens on port 4000.
- Save server/index.js.
- Create a second Terminal window from the Terminal window that is already running Vite.
- Start the API from the incident-triage-board folder by running this command:
npm run server
What Does This Command Start?
The server script runs server/index.js with Node.js. The process connects to Atlas before it begins listening for requests.
You should see API listening on http://localhost:4000 in the second Terminal window. That message proves the database connection completed before the HTTP server started.
Server Did Not Start?
- Confirm that the completed connection string is inside server/config.js.
- Confirm that your current IP is allowed by the Atlas project access list.
- Confirm that the database user in your connection string still has access to the Atlas deployment.
Ask for help with the connection details while keeping the connection string private: Why is my Express server failing before it starts listening on port 4000?
The health endpoint gives you a small checkpoint before the client begins using the API.
- Open a new Safari tab.
- Visit http://localhost:4000/api/health to test the running API.
You should see a response containing {"status":"ok"}. Your Express process can now receive requests after connecting to Atlas.
Connect React to the API
The client now needs one helper for decoding responses and another for loading incidents. The component will call them through the existing /api proxy.
- Return to src/App.jsx in the Visual Studio Code Explorer sidebar.
- Replace everything from the first import through the line before updateForm() with this API-backed state setup:
import { useEffect, useState } from "react";
const EMPTY_FORM = { externalId: "", title: "", severity: "medium" };
async function readJson(response) {
const payload = await response.json();
if (!response.ok) throw new Error(payload.error?.message ?? "Request failed");
return payload;
}
async function fetchIncidents(status) {
const query = status === "all" ? "" : `?status=${encodeURIComponent(status)}`;
return readJson(await fetch(`/api/incidents${query}`));
}
export default function App() {
const [incidents, setIncidents] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let ignore = false;
setLoading(true);
setError("");
fetchIncidents("all").then((payload) => { if (!ignore) setIncidents(payload.data); }).catch((requestError) => { if (!ignore) setError(requestError.message); }).finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; };
}, []);
How Does the Initial Read Work?
- The readJson() helper returns successful response data. It converts a failed response into a JavaScript error.
- The fetchIncidents() helper sends the browser request through the existing Vite proxy.
- The effect loads incidents after the component mounts. Its cleanup flag prevents an obsolete response from updating state after cleanup.
- Save src/App.jsx.
- Switch back to the existing client tab in Safari.
- Refresh http://localhost:5173 to load incidents from Atlas.
The mock records OPS-101 and OPS-102 should be gone. The board now reflects the records returned by the API.
Mock Cards Still Showing?
- Confirm that you replaced the INITIAL_INCIDENTS declaration with EMPTY_FORM.
- Confirm that the API Terminal still shows a running process.
- Confirm that the client remains available on port 5173.
Ask for help tracing the first request: Why is my React board still showing local mock incidents instead of the GET response?
- Find the existing updateForm() and createIncident() functions in src/App.jsx.
- Replace both functions with this API-backed submit flow:
function updateForm(event) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
}
async function createIncident(event) {
event.preventDefault();
setSaving(true);
setError("");
try {
await readJson(await fetch("/api/incidents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form)
}));
setForm(EMPTY_FORM);
setIncidents((await fetchIncidents("all")).data);
} catch (requestError) {
setError(requestError.message);
} finally {
setSaving(false);
}
}
How Does the Create Request Work?
- The submit handler serializes the controlled form into a JSON request body.
- The POST /api/incidents request waits for the server response before clearing the form.
- A fresh read replaces the client list with the current persisted records after the save succeeds.
- The finally block restores the button state after success or failure.
- Find the existing return statement at the bottom of src/App.jsx.
- Replace that return statement with this loading, saving, and error-aware interface:
return (
<main className="shell">
<header className="hero"><p className="eyebrow">MERN request lifecycle lab</p><h1>Incident Triage Board</h1><p>Follow each incident from React state to a database guarantee.</p></header>
<section className="panel">
<h2>Create incident</h2>
<form className="incident-form" onSubmit={createIncident}><label>External ID<input name="externalId" value={form.externalId} onChange={updateForm} placeholder="OPS-202" required /></label><label>Title<input name="title" value={form.title} onChange={updateForm} placeholder="Checkout latency" required /></label><label>Severity<input name="severity" value={form.severity} onChange={updateForm} required /></label><button disabled={saving}>{saving ? "Saving..." : "Create incident"}</button></form>
{error && <p className="error" role="alert">{error}</p>}
</section>
<section className="panel">
<h2>Incidents</h2>
{loading ? <p>Loading incidents...</p> : incidents.length === 0 ? <p>No incidents match this filter.</p> : <div className="incident-grid">{incidents.map((incident) => <article className="incident-card" key={incident._id}><div className="badge-row"><span className={`badge severity-${incident.severity}`}>{incident.severity}</span><span className="badge status">{incident.status}</span></div><h3>{incident.title}</h3><p className="incident-id">{incident.externalId}</p><time>{new Date(incident.createdAt).toLocaleString()}</time></article>)}</div>}
</section>
</main>
);
}
What Changes in the Interface?
- The create button displays Saving... while the request is active.
- The incident panel displays a loading message while the first read is active.
- The error paragraph surfaces a failed request beside the form.
- Each card now uses its MongoDB document identifier as the React key.
- Save src/App.jsx.
- Switch back to http://localhost:5173 in Safari.
- Refresh the page to load the API-backed interface.
You should briefly see Loading incidents... before the persisted queue renders. The form should still use a free-text severity field.
Client Request Failing?
- Confirm that Vite still runs in the first Terminal window.
- Confirm that Express still runs in the second Terminal window.
- Confirm that vite.config.js still proxies /api requests to port 4000.
Ask for help tracing the proxy request: Why does my React fetch request fail even though the Express health endpoint works?
Before you submit, predict whether the server will preserve or reject a severity value it has never seen.
- Enter OPS-URGENT in the External ID field.
- Enter Database failover in the Title field.
- Enter urgent in the Severity field.
- Click Create incident.
You will see a new incident card with an urgent severity badge. The API accepted the free-form string and persisted it.
- Refresh Safari to test whether the new incident survives a complete client reload.
The OPS-URGENT card should return after the refresh. That visible shortfall proves the server still trusts unvalidated client input.
Trace the Complete Request
The browser sends the request to Vite. The proxy forwards it to Express without requiring separate browser-facing origins.
Express parses the body before the route calls the service. Mongoose passes the database operation to the MongoDB driver before Atlas stores or reads the document.
While the database operation is pending, Node.js can continue serving other work through its event loop. The resolved data travels back through the same layers as a serialized response.
✔️ Awesome, I've got everything!
Your API is running in a second Terminal window. Your board now reads and creates persisted incidents through the Vite proxy.
Confirm that OPS-URGENT still appears after a Safari refresh.
ⓧ I'd like to double check the full code
Compare server/config.example.js with this safe example:
export const MONGODB_URI = "your-mongodb-connection-string";
Configuration Example Check
This committed example must retain the placeholder. It must not contain your working connection string.
Your ignored server/config.js has the same export shape. Keep your completed connection string in place when comparing it:
export const MONGODB_URI = "your-mongodb-connection-string";
Local Configuration Check
The displayed placeholder represents the completed Atlas connection string in your local ignored file. Never replace your working value with the placeholder after the server connects.
Compare server/models/Incident.js with this complete file:
import mongoose from "mongoose";
const incidentSchema = new mongoose.Schema({
externalId: String,
title: String,
severity: String,
status: { type: String, default: "open" },
createdAt: { type: Date, default: Date.now }
});
export const Incident = mongoose.model("Incident", incidentSchema);
Model File Check
The model should keep severity as a plain string for this deliberate first version. It should also default new incidents to the open status.
Compare server/services/incidentService.js with this complete file:
import { Incident } from "../models/Incident.js";
export async function createIncident(input) {
return new Incident({ externalId: input.externalId, title: input.title, severity: input.severity, status: "open" }).save();
}
export async function listIncidents() {
return Incident.find().sort({ createdAt: -1 });
}
Service File Check
The service should save the request values directly. Its read operation should return the newest records first.
Compare server/routes/incidents.js with this complete file:
import { createIncident, listIncidents } from "../services/incidentService.js";
export function registerIncidentRoutes(app) {
app.get("/api/incidents", async (request, response) => response.json({ data: await listIncidents() }));
app.post("/api/incidents", async (request, response) => response.status(201).json({ data: await createIncident(request.body) }));
}
Route File Check
The route module should register one read endpoint and one create endpoint. Both endpoints should delegate their database work to the service.
Compare server/index.js with this complete file:
import express from "express";
import mongoose from "mongoose";
import { MONGODB_URI } from "./config.js";
import { registerIncidentRoutes } from "./routes/incidents.js";
const app = express();
const PORT = 4000;
app.use(express.json());
app.get("/api/health", (request, response) => response.json({ status: "ok" }));
registerIncidentRoutes(app);
async function start() {
await mongoose.connect(MONGODB_URI);
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
}
start().catch((error) => { console.error("Failed to start server", error); process.exitCode = 1; });
Startup File Check
JSON parsing should appear before route registration. The server should begin listening only after the Mongoose connection resolves.
Compare src/App.jsx with this complete API-backed client:
import { useEffect, useState } from "react";
const EMPTY_FORM = { externalId: "", title: "", severity: "medium" };
async function readJson(response) {
const payload = await response.json();
if (!response.ok) throw new Error(payload.error?.message ?? "Request failed");
return payload;
}
async function fetchIncidents(status) {
const query = status === "all" ? "" : `?status=${encodeURIComponent(status)}`;
return readJson(await fetch(`/api/incidents${query}`));
}
export default function App() {
const [incidents, setIncidents] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let ignore = false;
setLoading(true);
setError("");
fetchIncidents("all").then((payload) => { if (!ignore) setIncidents(payload.data); }).catch((requestError) => { if (!ignore) setError(requestError.message); }).finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; };
}, []);
function updateForm(event) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
}
async function createIncident(event) {
event.preventDefault();
setSaving(true);
setError("");
try {
await readJson(await fetch("/api/incidents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form)
}));
setForm(EMPTY_FORM);
setIncidents((await fetchIncidents("all")).data);
} catch (requestError) {
setError(requestError.message);
} finally {
setSaving(false);
}
}
return (
<main className="shell">
<header className="hero"><p className="eyebrow">MERN request lifecycle lab</p><h1>Incident Triage Board</h1><p>Follow each incident from React state to a database guarantee.</p></header>
<section className="panel">
<h2>Create incident</h2>
<form className="incident-form" onSubmit={createIncident}><label>External ID<input name="externalId" value={form.externalId} onChange={updateForm} placeholder="OPS-202" required /></label><label>Title<input name="title" value={form.title} onChange={updateForm} placeholder="Checkout latency" required /></label><label>Severity<input name="severity" value={form.severity} onChange={updateForm} required /></label><button disabled={saving}>{saving ? "Saving..." : "Create incident"}</button></form>
{error && <p className="error" role="alert">{error}</p>}
</section>
<section className="panel">
<h2>Incidents</h2>
{loading ? <p>Loading incidents...</p> : incidents.length === 0 ? <p>No incidents match this filter.</p> : <div className="incident-grid">{incidents.map((incident) => <article className="incident-card" key={incident._id}><div className="badge-row"><span className={`badge severity-${incident.severity}`}>{incident.severity}</span><span className="badge status">{incident.status}</span></div><h3>{incident.title}</h3><p className="incident-id">{incident.externalId}</p><time>{new Date(incident.createdAt).toLocaleString()}</time></article>)}</div>}
</section>
</main>
);
}
Client File Check
The client should load incidents after mounting. It should create incidents through the API while keeping severity as a free-text input.
That is the complete MERN request path working through its success case and its trust failure. Next, you will reject invalid severity values and translate backend failures into one consistent error contract.
Enforce Contracts and Translate Failures
Your React board now reaches Express. Incidents persist in MongoDB Atlas, including the invalid urgent severity.
Now you will replace that trust with explicit contracts. Bad input must fail consistently before it becomes another stored incident.
In this step, get ready to:
- Move incident validation into the service layer.
- Enforce unique external IDs with a database index.
- Translate failures into consistent API error responses.
Move domain validation into the service
A service layer owns the rules for a use case. This keeps domain decisions independent from routes or browser controls.
- Switch back to server/routes/incidents.js in Visual Studio Code.
- Locate the current route registration code shown below:
import { createIncident, listIncidents } from "../services/incidentService.js";
export function registerIncidentRoutes(app) {
app.get("/api/incidents", async (request, response) => response.json({ data: await listIncidents() }));
app.post("/api/incidents", async (request, response) => response.status(201).json({ data: await createIncident(request.body) }));
}
What are you changing?
The current GET route asks the service for every incident. The route does not pass the requested status into the service.
- Replace the current route registration code with this version:
import { createIncident, listIncidents } from "../services/incidentService.js";
export function registerIncidentRoutes(app) {
app.get("/api/incidents", async (request, response) => response.json({ data: await listIncidents({ status: request.query.status }) }));
app.post("/api/incidents", async (request, response) => response.status(201).json({ data: await createIncident(request.body) }));
}
Why pass the status into the service?
The route now extracts one HTTP input from the request. The service decides whether that input belongs to the allowed domain vocabulary.
- Save server/routes/incidents.js.
- Switch back to the second Terminal from earlier.
- Stop the running server by pressing Ctrl+C.
Before you restart the server, consider whether the board will still load when the old service receives the new route argument.
- Restart the API by running this command:
npm run server
What does this command do?
The npm script starts server/index.js with Node.js. The running process loads the updated route module.
- Refresh the board in Safari.
Your existing incident cards still load. The old service ignores the extra argument, which makes this route change backward compatible.
Did the board stop loading?
- Confirm the second Terminal shows the API listening on http://localhost:4000.
- Check that the GET route passes an object containing status.
- Check that both route handlers remain inside registerIncidentRoutes().
Ask for help checking the route update if the board still cannot load.
The service now needs a project-owned error type plus allow-lists for accepted values. These rules protect every caller of the service, including callers outside the React form.
- Switch back to server/services/incidentService.js.
- Replace its contents with this validation foundation:
import { Incident } from "../models/Incident.js";
const ALLOWED_SEVERITIES = new Set(["low", "medium", "high", "critical"]);
const ALLOWED_STATUSES = new Set(["open", "investigating", "resolved"]);
export class DomainValidationError extends Error {
constructor(message, status = 400, code = "INVALID_INCIDENT") {
super(message);
this.name = "DomainValidationError";
this.status = status;
this.code = code;
}
}
function requiredText(value, fieldName) {
if (typeof value !== "string" || value.trim() === "") throw new DomainValidationError(`${fieldName} is required.`);
return value.trim();
}
What does this validation foundation do?
- The allow-lists define the severity values and status values accepted by the domain.
- The DomainValidationError class carries an API-safe status plus a stable error code.
- The requiredText() helper rejects missing strings before trimming valid input.
- Add the service operations directly below requiredText() by pasting this code:
export async function createIncident(input) {
const source = input ?? {};
const externalId = requiredText(source.externalId, "externalId");
const title = requiredText(source.title, "title");
if (!ALLOWED_SEVERITIES.has(source.severity)) throw new DomainValidationError("severity must be low, medium, high, or critical.");
return new Incident({ externalId, title, severity: source.severity, status: "open" }).save();
}
export async function listIncidents({ status }) {
if (status && !ALLOWED_STATUSES.has(status)) throw new DomainValidationError("Unknown status filter.");
return Incident.find(status ? { status } : {}).sort({ createdAt: -1 });
}
How do the service operations protect the boundary?
- The createIncident() operation validates required text before constructing a document.
- The severity allow-list rejects values such as urgent.
- The listIncidents() operation permits only known status filters.
- Save server/services/incidentService.js.
- Switch back to the second Terminal.
- Stop the running server by pressing Ctrl+C.
- Load the new service code by running this command:
npm run server
What changes after the restart?
The restarted process loads the service-owned validation rules. Express catches rejected async route handlers, but the API does not yet have your centralized JSON error formatter.
- Switch back to the incident form in Safari.
- Enter OPS-VALIDATION in the External ID field.
- Enter Invalid severity check in the Title field.
- Enter urgent in the Severity field.
Before you submit this incident, consider whether rejection alone is enough to give React a useful message.
- Click Create incident.
The incident is rejected, so it does not join the board. The browser may show a low-level response parsing failure because the server has not translated the domain error into the expected JSON shape.
Why is this intermediate failure useful?
The service boundary now protects stored data. The rough browser message proves that validation and HTTP error presentation are separate responsibilities.
Did the invalid incident appear anyway?
- Confirm createIncident() checks ALLOWED_SEVERITIES before constructing the incident.
- Confirm the second Terminal was restarted after you saved the service file.
- Confirm the browser submitted the lowercase value urgent.
Ask for help tracing why the service still accepts the invalid severity.
Centralize errors and enforce uniqueness
A centralized error handler gives every failure the same JSON envelope. A unique Index gives the database final authority over external IDs.
Why use a unique index?
A lookup before insertion leaves a race between checking and writing. A unique index closes that gap inside the database, where concurrent writes are decided.
- In the Visual Studio Code Explorer sidebar, right-click the server folder.
- Select New Folder.
- Enter middleware as the folder name.
- Right-click the new server/middleware folder.
- Select New File.
- Enter errorHandler.js as the file name.
- Add the centralized error handler by pasting this code:
export function errorHandler(error, request, response, next) {
if (response.headersSent) return next(error);
if (error.code === 11000) return response.status(409).json({ error: { code: "DUPLICATE_INCIDENT", message: "An incident with that external ID already exists." } });
if (error.name === "DomainValidationError") return response.status(error.status).json({ error: { code: error.code, message: error.message } });
console.error(error);
return response.status(500).json({ error: { code: "INTERNAL_ERROR", message: "The server could not complete the request." } });
}
How does the error contract work?
- A duplicate-key failure with code 11000 becomes an HTTP conflict with the stable code DUPLICATE_INCIDENT.
- A DomainValidationError uses its own status plus its own message.
- An unknown failure becomes a generic server response while its original details remain in the server console.
- Save server/middleware/errorHandler.js.
- Confirm errorHandler.js appears inside the server/middleware folder in the Explorer sidebar.
Is the middleware file missing?
- Check that middleware is inside server.
- Check that the filename uses the exact capitalization errorHandler.js.
Ask for help fixing the middleware path if the file appears elsewhere.
The startup module must register error middleware after every route. It also creates the unique index through the native collection exposed by Mongoose.
- Switch back to server/index.js.
- Replace its contents with this startup code:
import express from "express";
import mongoose from "mongoose";
import { MONGODB_URI } from "./config.js";
import { Incident } from "./models/Incident.js";
import { registerIncidentRoutes } from "./routes/incidents.js";
import { errorHandler } from "./middleware/errorHandler.js";
const app = express();
const PORT = 4000;
app.use(express.json());
app.get("/api/health", (request, response) => response.json({ status: "ok" }));
registerIncidentRoutes(app);
app.use(errorHandler);
async function start() {
await mongoose.connect(MONGODB_URI);
await Incident.collection.createIndex({ externalId: 1 }, { unique: true });
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
}
start().catch((error) => { console.error("Failed to start server", error); process.exitCode = 1; });
Why does middleware order matter?
Express reaches middleware in registration order. Placing errorHandler after the routes lets it receive errors from every incident handler.
Startup creates the unique externalId index before the server begins accepting requests. Duplicate writes now fail at the database boundary.
- Save server/index.js.
- Switch back to the second Terminal.
- Stop the running server by pressing Ctrl+C.
Before you restart the server, consider whether Atlas can create a unique index if duplicate external IDs already exist.
- Restart the API by running this command:
npm run server
What happens during startup?
The process connects to Atlas before creating the unique index. It starts listening only after both operations succeed.
You should see the API listening on http://localhost:4000.
Did index creation stop the server?
- Check the terminal output for a failure mentioning duplicate existing values.
- Remove older test incidents that reuse the same external ID from the Atlas collection.
- Run the server script again after each remaining external ID is unique.
Ask for help identifying duplicate incident records if startup still fails.
- Switch back to the incident form in Safari.
- Enter OPS-VALIDATION in the External ID field.
- Enter Invalid severity check in the Title field.
- Enter urgent in the Severity field.
Before you submit the same invalid value again, consider how the new error contract changes the message React receives.
- Click Create incident.
You should see severity must be low, medium, high, or critical. beside the form. The incident does not appear on the board.
- Enter OPS-202 in the External ID field.
- Enter Checkout latency in the Title field.
- Enter medium in the Severity field.
- Click Create incident.
The new OPS-202 card appears on the board. That successful write now reserves the external ID through the unique index.
- Enter OPS-202 in the External ID field again.
- Enter Duplicate checkout incident in the Title field.
- Enter high in the Severity field.
Before you send the duplicate, consider which layer now has final authority over that external ID.
- Click Create incident.
You should see An incident with that external ID already exists. beside the form. Only one OPS-202 card remains.
That is the contract repaired. Bad input now fails at the service boundary while duplicate data fails at the database boundary.
Did the duplicate incident get saved?
- Confirm startup calls createIndex() with the unique option.
- Confirm the server was restarted after you changed server/index.js.
- Confirm both submissions use the exact external ID OPS-202.
Ask for help tracing the duplicate write if two cards still appear.
Replace the free-form severity control
A severity selector improves usability by presenting valid choices. Server validation remains authoritative because callers can bypass the browser interface.
- Switch back to src/App.jsx.
- Locate the EMPTY_FORM declaration near the top of the file.
- Replace that declaration with this default form state:
const EMPTY_FORM = { externalId: "", title: "", severity: "medium" };
Why default to medium?
The initial value must match one of the selector options. Resetting the form now returns severity to a valid visible choice.
- Locate the severity field inside the incident creation form.
- Replace the complete severity label with this selector:
<label>Severity<select name="severity" value={form.severity} onChange={updateForm}><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option></select></label>
What changes in the browser?
The selector offers the same four values accepted by the service. The existing updateForm() handler still records the selected value.
- Save src/App.jsx.
- Refresh the board in Safari.
You should see a severity selector containing Low, Medium, High, and Critical. The free-form severity field is gone.
- Enter OPS-202 in the External ID field.
- Enter Duplicate verification in the Title field.
- Select Medium from the Severity field.
Before you submit this final check, consider whether changing the browser control affects the database guarantee.
- Click Create incident.
You should still see An incident with that external ID already exists. beside the form. The select improves input guidance without replacing server or database enforcement.
Do you still see a text field?
- Confirm the severity field uses a select element.
- Confirm each option has one of the four allowed lowercase values.
- Confirm src/App.jsx is saved.
Ask for help comparing the severity control if the browser still accepts free text.
✔️ Awesome, I've got everything!
Your service validation, unique index, centralized error handler, and severity selector are now in place. Save every changed file before moving on.
ⓧ I'd like to double check the full code
The following references show the complete backend files changed in this step.
import { Incident } from "../models/Incident.js";
const ALLOWED_SEVERITIES = new Set(["low", "medium", "high", "critical"]);
const ALLOWED_STATUSES = new Set(["open", "investigating", "resolved"]);
export class DomainValidationError extends Error {
constructor(message, status = 400, code = "INVALID_INCIDENT") {
super(message);
this.name = "DomainValidationError";
this.status = status;
this.code = code;
}
}
function requiredText(value, fieldName) {
if (typeof value !== "string" || value.trim() === "") throw new DomainValidationError(`${fieldName} is required.`);
return value.trim();
}
export async function createIncident(input) {
const source = input ?? {};
const externalId = requiredText(source.externalId, "externalId");
const title = requiredText(source.title, "title");
if (!ALLOWED_SEVERITIES.has(source.severity)) throw new DomainValidationError("severity must be low, medium, high, or critical.");
return new Incident({ externalId, title, severity: source.severity, status: "open" }).save();
}
export async function listIncidents({ status }) {
if (status && !ALLOWED_STATUSES.has(status)) throw new DomainValidationError("Unknown status filter.");
return Incident.find(status ? { status } : {}).sort({ createdAt: -1 });
}
This complete service file owns incident validation plus the allowed status vocabulary.
import { createIncident, listIncidents } from "../services/incidentService.js";
export function registerIncidentRoutes(app) {
app.get("/api/incidents", async (request, response) => response.json({ data: await listIncidents({ status: request.query.status }) }));
app.post("/api/incidents", async (request, response) => response.status(201).json({ data: await createIncident(request.body) }));
}
This route file translates HTTP input before delegating domain decisions to the service.
export function errorHandler(error, request, response, next) {
if (response.headersSent) return next(error);
if (error.code === 11000) return response.status(409).json({ error: { code: "DUPLICATE_INCIDENT", message: "An incident with that external ID already exists." } });
if (error.name === "DomainValidationError") return response.status(error.status).json({ error: { code: error.code, message: error.message } });
console.error(error);
return response.status(500).json({ error: { code: "INTERNAL_ERROR", message: "The server could not complete the request." } });
}
This middleware translates known failures while hiding unexpected server details from API clients.
import express from "express";
import mongoose from "mongoose";
import { MONGODB_URI } from "./config.js";
import { Incident } from "./models/Incident.js";
import { registerIncidentRoutes } from "./routes/incidents.js";
import { errorHandler } from "./middleware/errorHandler.js";
const app = express();
const PORT = 4000;
app.use(express.json());
app.get("/api/health", (request, response) => response.json({ status: "ok" }));
registerIncidentRoutes(app);
app.use(errorHandler);
async function start() {
await mongoose.connect(MONGODB_URI);
await Incident.collection.createIndex({ externalId: 1 }, { unique: true });
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
}
start().catch((error) => { console.error("Failed to start server", error); process.exitCode = 1; });
This startup file creates the unique index before listening for requests. It registers error middleware after the routes.
The React changes in this step should match these two references.
const EMPTY_FORM = { externalId: "", title: "", severity: "medium" };
The form state now starts with a severity accepted by the service.
<label>Severity<select name="severity" value={form.severity} onChange={updateForm}><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option></select></label>
The final severity field presents the four allowed choices while preserving the existing controlled-input handler.
Your write path now defends its contracts at every boundary. Next up, you will align the read path with an indexed status filter.
Align Queries with Indexes and Filters
Your write path now rejects invalid incidents. It also protects each external ID with a unique database guarantee.
Correct writes still need an efficient read path. This step aligns a status filter with a MongoDB index so the dashboard can request the records it needs in a predictable order.
In this step, get ready to:
- Read incidents through the native collection cursor.
- Create a compound index that matches the filter and sort pattern.
- Add status filtering with protection against stale responses.
Align the read query with its filter
Every Mongoose model exposes its native collection. This escape hatch gives the service direct access to the database cursor used for an index-aware query.
Your existing allow-list still decides whether a status is valid. The database receives either one approved status or an empty filter.
- In server/services/incidentService.js, find the final return statement inside listIncidents().
- Replace that return statement with the cursor-based query below:
const cursor = Incident.collection.find(status ? { status } : {}).sort({ createdAt: -1 });
const incidents = [];
for await (const incident of cursor) incidents.push(incident);
return incidents;
What does this query do?
- The Incident.collection property accesses the native MongoDB collection.
- The filter object contains only a status that passed the existing allow-list.
- The sort() call requests the newest createdAt values first.
- The for await loop consumes each cursor result without blocking the request flow.
- Save server/services/incidentService.js.
- Stop the running server by pressing Ctrl+C in its Terminal window.
- Restart the API by running this command:
npm run server
What does this command do?
The command runs the server script from package.json. That script starts server/index.js.
You should see API listening on http://localhost:4000. This confirms the updated service loaded successfully.
- Return to Safari.
- Enter http://localhost:5173/api/incidents?status=open in the address bar.
You should see a JSON response containing only incidents whose status is open. This proves the allow-listed filter reaches the database query.
Is the filtered request failing?
- Confirm the client process from earlier is still running on port 5173.
- Confirm the API Terminal still shows the listening message.
- Check that the replacement remains inside listIncidents().
Help me troubleshoot the filtered incidents request.
✔️ Awesome, I've got everything!
Your service now consumes a native MongoDB cursor. Keep server/services/incidentService.js saved.
ⓧ I'd like to double check the full code
Compare your service with the complete file below.
import { Incident } from "../models/Incident.js";
const ALLOWED_SEVERITIES = new Set(["low", "medium", "high", "critical"]);
const ALLOWED_STATUSES = new Set(["open", "investigating", "resolved"]);
export class DomainValidationError extends Error {
constructor(message, status = 400, code = "INVALID_INCIDENT") {
super(message);
this.name = "DomainValidationError";
this.status = status;
this.code = code;
}
}
function requiredText(value, fieldName) {
if (typeof value !== "string" || value.trim() === "") throw new DomainValidationError(`${fieldName} is required.`);
return value.trim();
}
export async function createIncident(input) {
const source = input ?? {};
const externalId = requiredText(source.externalId, "externalId");
const title = requiredText(source.title, "title");
if (!ALLOWED_SEVERITIES.has(source.severity)) throw new DomainValidationError("severity must be low, medium, high, or critical.");
return new Incident({ externalId, title, severity: source.severity, status: "open" }).save();
}
export async function listIncidents({ status }) {
if (status && !ALLOWED_STATUSES.has(status)) throw new DomainValidationError("Unknown status filter.");
const cursor = Incident.collection.find(status ? { status } : {}).sort({ createdAt: -1 });
const incidents = [];
for await (const incident of cursor) incidents.push(incident);
return incidents;
}
What should match?
The full file keeps the existing validation boundary. Only the read implementation changes to a native cursor.
Create the query-aligned index
A compound index stores more than one indexed field in a defined order. The first field supports the status filter. The second field supports the newest-first ordering within that status.
- In server/index.js, find the existing externalId index inside start().
- Add this compound index on the next line:
await Incident.collection.createIndex({ status: 1, createdAt: -1 });
Why this field order?
The ascending status field matches the equality filter. The descending createdAt field matches the newest-first sort.
This order mirrors the query shape used by listIncidents(). The database can use one index for both parts of that read path.
- Save server/index.js.
- Stop the API by pressing Ctrl+C in its Terminal window.
Before you restart the API, do you expect it to begin listening before or after the index operation completes?
- Restart the API by running this command:
npm run server
What does this restart prove?
The start() function awaits each index operation before calling app.listen(). A listening message confirms both index operations completed.
You should see API listening on http://localhost:4000 again. The compound index now backs the filter-then-sort query pattern.
Does the server stop before listening?
- Check that the new index line is inside the asynchronous start() function.
- Check that Incident.collection.createIndex matches the existing index call.
- Review the first error printed in the API Terminal for the failed startup operation.
Help me diagnose the index startup failure.
✔️ Awesome, I've got everything!
Your startup path now creates the unique index plus the compound read index.
ⓧ I'd like to double check the full code
Compare your startup module with the complete file below.
import express from "express";
import mongoose from "mongoose";
import { MONGODB_URI } from "./config.js";
import { Incident } from "./models/Incident.js";
import { registerIncidentRoutes } from "./routes/incidents.js";
import { errorHandler } from "./middleware/errorHandler.js";
const app = express();
const PORT = 4000;
app.use(express.json());
app.get("/api/health", (request, response) => response.json({ status: "ok" }));
registerIncidentRoutes(app);
app.use(errorHandler);
async function start() {
await mongoose.connect(MONGODB_URI);
await Incident.collection.createIndex({ externalId: 1 }, { unique: true });
await Incident.collection.createIndex({ status: 1, createdAt: -1 });
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
}
start().catch((error) => { console.error("Failed to start server", error); process.exitCode = 1; });
What should match?
The compound index sits after the unique index. Both operations finish before the API begins listening.
Refetch incidents when the filter changes
The React interface needs its own status state so each selection can trigger a new request. An effect cleanup flag prevents an older response from replacing a newer filtered result.
This matters when requests finish in a different order from the one in which they started. The latest status selection remains authoritative in the browser.
- In src/App.jsx, find the existing fetchIncidents() helper.
- Replace that helper with the status-aware version below:
async function fetchIncidents(status) {
const query = status === "all" ? "" : `?status=${encodeURIComponent(status)}`;
return readJson(await fetch(`/api/incidents${query}`));
}
How is the request URL built?
- The all option sends no query string.
- Every specific status becomes a status query parameter.
- The encodeURIComponent() call safely encodes the selected value.
- Save src/App.jsx.
- Refresh Safari.
You should see the current incident cards load again. This confirms the helper still supports the unfiltered all request.
Do the incidents fail to load?
- Check that query is appended inside the request template string.
- Check that the request path still begins with /api/incidents.
Help me fix the status-aware fetch helper.
- Inside App(), replace the current loading state range plus its data-loading effect with the code below:
const [status, setStatus] = useState("all");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let ignore = false;
setLoading(true);
setError("");
fetchIncidents(status)
.then((payload) => { if (!ignore) setIncidents(payload.data); })
.catch((requestError) => { if (!ignore) setError(requestError.message); })
.finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; };
}, [status]);
How does the effect avoid stale results?
- The status state records the active filter.
- The dependency array reruns the effect whenever that filter changes.
- The cleanup function changes ignore to true for the obsolete request.
- Each result checks ignore before updating browser state.
- Save src/App.jsx.
- Refresh Safari.
You should see the incident cards return after the loading message. This confirms the effect completed its initial all request.
Is the page stuck loading?
- Check that every state update inside the promise chain tests ignore.
- Check that status appears in the effect dependency array.
- Check that the cleanup function sits inside the effect.
Help me troubleshoot the filter effect cleanup.
- In src/App.jsx, replace the current createIncident() function with the version below:
async function createIncident(event) {
event.preventDefault();
setSaving(true);
setError("");
try {
await readJson(await fetch("/api/incidents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form)
}));
setForm(EMPTY_FORM);
setStatus("all");
setIncidents((await fetchIncidents("all")).data);
} catch (requestError) {
setError(requestError.message);
} finally {
setSaving(false);
}
}
Why reset the filter after creation?
Every new incident starts with the open status. Resetting the filter to all makes the created record visible after the request succeeds.
The explicit refetch returns the persisted database state. The new card therefore reflects the server response path.
- Save src/App.jsx.
- Refresh Safari.
You should see the form plus the existing incident cards. The updated creation path is ready for the filter control.
Does creation stop working?
- Check that setStatus("all") appears after the successful POST request.
- Check that the final refetch passes all to fetchIncidents().
Help me fix the updated incident creation flow.
- Inside the incidents panel in src/App.jsx, replace its heading area with the toolbar below:
<div className="toolbar"><div><p className="eyebrow">Persisted queue</p><h2>Incidents</h2></div><label>Status<select value={status} onChange={(event) => setStatus(event.target.value)}><option value="all">All</option><option value="open">Open</option><option value="investigating">Investigating</option><option value="resolved">Resolved</option></select></label></div>
How does the filter control connect?
The select reads its value from status. Each change calls setStatus() with the selected option.
That state change reruns the effect. The next request contains only the approved filter value.
- Save src/App.jsx.
Before you choose a filter, which cards do you expect the Open option to keep?
- Return to Safari.
- Select Open from the Status field.
You should see your open incident cards. The browser has refetched the list through the indexed status query.
Before changing the filter again, do you expect any existing incident to have reached the resolved state?
- Select Resolved from the Status field.
You should see No incidents match this filter. The empty result proves the selected status reached the API.
Does the list ignore the selected status?
- Check that the select uses status as its value.
- Check that its change handler calls setStatus().
- Check that the effect depends on status.
Help me trace why the status filter is not updating the incident list.
The live filter proves the development request path works. A production build checks that Vite can compile the completed client.
Before you build, do you expect the completed React client to compile successfully?
- Build the production client by running this command in the project Terminal:
npm run build
What does this build command do?
The command runs the build script from package.json. Vite compiles the client into the ignored dist folder.
You should see Vite complete the production build. The terminal output should identify the generated files inside dist.
Does the production build fail?
- Read the first file path named in the build output.
- Check that the corresponding code matches the completed file in the double-check tab below.
- Confirm both client dependencies from the workspace setup remain installed.
Help me diagnose the failed Vite production build.
✔️ Awesome, I've got everything!
Your completed client filters persisted incidents. The production build also compiles successfully.
ⓧ I'd like to double check the full code
Compare your component with the complete main-project file below.
import { useEffect, useState } from "react";
const EMPTY_FORM = { externalId: "", title: "", severity: "medium" };
async function readJson(response) {
const payload = await response.json();
if (!response.ok) throw new Error(payload.error?.message ?? "Request failed");
return payload;
}
async function fetchIncidents(status) {
const query = status === "all" ? "" : `?status=${encodeURIComponent(status)}`;
return readJson(await fetch(`/api/incidents${query}`));
}
export default function App() {
const [incidents, setIncidents] = useState([]);
const [form, setForm] = useState(EMPTY_FORM);
const [status, setStatus] = useState("all");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let ignore = false;
setLoading(true);
setError("");
fetchIncidents(status)
.then((payload) => { if (!ignore) setIncidents(payload.data); })
.catch((requestError) => { if (!ignore) setError(requestError.message); })
.finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; };
}, [status]);
function updateForm(event) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
}
async function createIncident(event) {
event.preventDefault();
setSaving(true);
setError("");
try {
await readJson(await fetch("/api/incidents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form)
}));
setForm(EMPTY_FORM);
setStatus("all");
setIncidents((await fetchIncidents("all")).data);
} catch (requestError) {
setError(requestError.message);
} finally {
setSaving(false);
}
}
return <main className="shell"><header className="hero"><p className="eyebrow">MERN request lifecycle lab</p><h1>Incident Triage Board</h1><p>Follow each incident from React state to a database guarantee.</p></header><section className="panel"><h2>Create incident</h2><form className="incident-form" onSubmit={createIncident}><label>External ID<input name="externalId" value={form.externalId} onChange={updateForm} placeholder="OPS-202" required /></label><label>Title<input name="title" value={form.title} onChange={updateForm} placeholder="Checkout latency" required /></label><label>Severity<select name="severity" value={form.severity} onChange={updateForm}><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option></select></label><button disabled={saving}>{saving ? "Saving..." : "Create incident"}</button></form>{error && <p className="error" role="alert">{error}</p>}</section><section className="panel"><div className="toolbar"><div><p className="eyebrow">Persisted queue</p><h2>Incidents</h2></div><label>Status<select value={status} onChange={(event) => setStatus(event.target.value)}><option value="all">All</option><option value="open">Open</option><option value="investigating">Investigating</option><option value="resolved">Resolved</option></select></label></div>{loading ? <p>Loading incidents...</p> : incidents.length === 0 ? <p>No incidents match this filter.</p> : <div className="incident-grid">{incidents.map((incident) => <article className="incident-card" key={incident._id}><div className="badge-row"><span className={`badge severity-${incident.severity}`}>{incident.severity}</span><span className="badge status">{incident.status}</span></div><h3>{incident.title}</h3><p className="incident-id">{incident.externalId}</p><time>{new Date(incident.createdAt).toLocaleString()}</time></article>)}</div>}</section></main>;
}
What should match?
The component owns the selected status. Its effect cleans up obsolete requests before their results can update the list.
Interview checkpoint
- Each index can reduce work for aligned reads. Every additional index also adds storage plus write maintenance.
- An allow-list keeps arbitrary request query fields away from the database filter.
- Cursor pagination scales through a stable indexed position. Large offsets require the database to skip increasing numbers of records.
You now have the full main project running with a filtered read path backed by a matching database index.
Secret mission
Enforce One-Way Status Transitions
Your incident board currently stores workflow status without controlling how that status changes. In this mission, you will enforce the legal path from open to investigating to resolved across the service, API, and React client.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your project resources, pause the local processes for a later return, or delete the project-specific data and files. A paid MongoDB Atlas deployment continues its normal billing independently of this project.
Cost warning
Atlas Free is listed at $0/hour. If server/config.js points to a paid deployment, its normal charges continue after you stop Vite or Node.js.
Deleting the project collection does not delete the deployment. Review that deployment separately if you no longer need it.
Resources you used:
- Local incident-triage-board folder containing the application source, installed dependencies in node_modules, the local server/config.js file, and the dist production build.
- MongoDB Atlas incidents collection containing the demo records plus the { externalId: 1 } and { status: 1, createdAt: -1 } indexes.
Keep everything running
No project data needs deletion. Choose this option if you plan to keep rehearsing the request lifecycle or extending the incident board.
- Press Ctrl+C in the terminal where Vite serves the client.
- Press Ctrl+C in the terminal where Node.js runs the API.
- Retain the incident-triage-board folder for future interview practice.
- Leave the incidents collection in place with its records and indexes.
Pause - I'll come back to this later
Shut down both local processes to free memory. Your files and Atlas data remain available for your next session.
- Press Ctrl+C in the terminal where Vite serves the client.
- Press Ctrl+C in the terminal where Node.js runs the API.
- Retain the incident-triage-board folder with its installed dependencies and production build.
- Retain the incidents collection so your saved incidents remain available.
Delete - I don't want to use this again
Remove the project-specific resources while leaving your existing Atlas deployment intact. Deletion permanently removes the incident records and local source files.
- Press Ctrl+C in the terminal where Vite serves the client.
- Press Ctrl+C in the terminal where Node.js runs the API.
Deleting the collection permanently removes its demo records plus both project indexes. Your other Atlas data remains untouched.
- Open Clusters in MongoDB Atlas.
- Select the deployment you used for the incident board.
- Open the collection browser for that deployment.
- Select the database that contains incidents.
- Select the incidents collection.
- Use the collection deletion control.
- Confirm the deletion with the collection name if Atlas requests it.
- Refresh the collection browser to verify that incidents is absent.
Your demo records and both project indexes are now gone. The rest of your Atlas deployment remains available.
Removing the local folder is permanent. The commands target only incident-triage-board from its parent folder.
- Remove the local project from the server terminal by running these commands:
cd ..
rm -rf incident-triage-board
ls
What Do These Commands Do?
- The first line moves the terminal from the project folder into its parent folder.
- The second line removes the named project folder and everything inside it.
- The final line lists the remaining folders so you can verify the deletion.
You should no longer see incident-triage-board in the terminal output. That completes the local cleanup.
Still See the Project Folder?
- Move to the folder that contains incident-triage-board.
- Run the deletion command again.
Help me locate and remove the local project folder.
Nice Work!
Nice Work!
You did it! Your incident triage board now persists incidents across a complete MERN request path. Its API enforces the data guarantees that keep the board trustworthy.
You've learned how to:
- Build a React incident board backed by a layered Express API. Persist each accepted incident in MongoDB Atlas. Explain how Node.js can serve other requests while database I/O is pending.
- Protect the Mongoose model boundary with service-layer validation. Close duplicate races with a unique database index. Return failures through a consistent JSON error contract.
- Filter the dashboard through an allow-listed status query. Align that query with a compound index. Explain the index's read and write trade-off.
- Secret Mission: Enforced one-way status transitions in the service layer. Each incident now advances from open to investigating to resolved. Resolved cards hide the advance action.
Ready to quiz yourself?