Fix a Blocking Next.js Dashboard
Build a server-first incident dashboard with streaming and error recovery.
Introduction
30 Second Summary
A dashboard can feel broken when a click changes the address but leaves the screen frozen. Without visible feedback, even a short wait makes people wonder whether the click worked.
In this project, you will diagnose and fix a blocking incident-triage dashboard built with the Next.js App Router. The finished dashboard keeps ready incident content usable while delayed reporter information appears later.
What You'll Build
Opening an incident shows the fast summary first while the delayed reporter panel fills in independently.
By the end of this project, you'll have:
- A filterable incident queue displays three production incidents from Server Components. One narrow Client Component handles the status controls.
- A dynamic incident route such as /incidents/INC-102 displays an immediate loading skeleton. Granular streaming then replaces only the reporter skeleton.
- A recoverable error boundary exposes accessible controls for retrying an invalid incident or returning safely to the queue.
- Secret Mission: Add a second slow timeline panel that streams independently from reporter details.
Are there any prerequisites?
This project assumes you are comfortable building React apps with the Next.js App Router. Everything runs locally, so you do not need a paid service or cloud account.
Before We Start
This first checkpoint commits you to an interview-ready incident-triage demo where Server Components render the incident queue plus route data. A narrow Client Component boundary handles the status filter.
Set Up the Manual Next.js Workspace
An interview-ready rendering demo needs a workspace where every file has a clear purpose. Generated boilerplate can hide the boundaries you want to explain.
You will build a minimal Next.js workspace by hand in Visual Studio Code. This keeps the App Router structure visible from the start.
In this step, get ready to:
- Verify the Windows runtime meets the project requirement.
- Create the minimal application files.
- Install the dependencies and run the dashboard locally.
Verify the Windows runtime
Next.js 16.3.8 requires Node.js 20.9.0 or newer. You also need the npm command for installing the project packages.
- Press the Windows key to open Windows search.
- Type Visual Studio Code and press Enter.
Visual Studio Code opens with its editor and navigation panels ready.
- Select Terminal from the top menu.
- Select New Terminal.
The integrated terminal opens at the bottom of Visual Studio Code.
- Check the installed Node.js and npm versions by running these commands:
node --version
npm --version
What do these checks show?
- The first command prints the installed Node.js version.
- The second command confirms that npm is available.
Compare the Node.js result with the required minimum. Choose the tab that matches what you see.
✔️ I see version 20.9.0 or higher
Your runtime is ready. Node.js meets the minimum requirement while npm reports its installed version.
ⓧ I see an older version
The installed Node.js version is below the project requirement. Update it before creating the workspace.
- Visit the official Node.js download page.
- Download the Windows installer for v24.21.0 LTS.
- Complete the installation with the default options.
- Close Visual Studio Code after the installation finishes.
- Reopen Visual Studio Code through Windows search.
- Repeat both version checks in a new integrated terminal.
Still seeing the older version?
Restart Windows so the new runtime path reaches Visual Studio Code. If the older version remains, help me identify which Node.js installation Windows is using.
ⓧ Command not found
The terminal cannot access the required runtime commands. Install the supported Node.js release before continuing.
- Visit the official Node.js download page.
- Download the Windows installer for v24.21.0 LTS.
- Complete the installation with the default options.
- Close Visual Studio Code after the installation finishes.
- Reopen Visual Studio Code through Windows search.
- Repeat both version checks in a new integrated terminal.
Commands still unavailable?
Restart Windows so the installer can refresh the runtime path. If the commands remain unavailable, help me troubleshoot the Node.js installation.
Create the minimal project files
A manual workspace exposes the relationship between package scripts, route content, shared layout, and global styling. Each file directly supports the dashboard you will build.
Why Set Up Next.js Manually?
A generated scaffold includes files and packages that do not teach this project's rendering model. The manual setup keeps your attention on the server-first application structure.
- Select File from the Visual Studio Code menu.
- Select Open Folder.
The Windows folder picker opens so you can choose a stable location for the project.
- Select your Desktop in the folder picker.
- Create a folder named incident-triage with the new folder control.
- Open the new incident-triage folder as the workspace.
You will see incident-triage at the top of the Explorer sidebar.
- Create package.json inside incident-triage with the Explorer new file control.
The new package file appears directly beneath the workspace folder.
- Define the package scripts and dependency versions by pasting this code into package.json:
{
"name": "incident-triage",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.3.8",
"react": "19.3.0",
"react-dom": "19.3.0"
}
}
What Does This File Configure?
- The scripts define the development, build, and production start commands.
- The dependencies pin Next.js to 16.3.8.
- The dependencies pin React and React DOM to 19.3.0.
- Save package.json.
- Confirm Visual Studio Code shows no red syntax underline in the saved JSON.
Package File Showing an Error?
Check that every property name uses double quotes. If the error remains, help me find the JSON syntax problem.
- Create an app folder inside incident-triage with the Explorer new folder control.
The app folder now appears beneath package.json in the Explorer sidebar.
- Create layout.js inside the app folder with the Explorer new file control.
The new app/layout.js file will provide the shared document shell for every route.
- Create the shared layout by pasting this code into app/layout.js:
import './globals.css'
export const metadata = {
title: 'Incident Triage',
description: 'A server-first incident dashboard for exploring Next.js rendering.',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header className="site-header">
<p className="brand">Incident Triage</p>
<p className="header-note">Server-first rendering lab</p>
</header>
{children}
</body>
</html>
)
}
What Does This Layout Provide?
- The metadata gives the dashboard a title and description.
- The root layout defines the document language and shared body.
- The header remains visible around the route content passed through children.
- The stylesheet import applies one theme across the application.
- Save app/layout.js.
- Confirm Visual Studio Code shows no red syntax underline in the layout.
Layout File Showing a Syntax Error?
Check that the metadata object closes before RootLayout begins. If the error remains, help me inspect the layout structure.
- Create page.js inside the app folder with the Explorer new file control.
The new app/page.js file represents the home route.
- Create the starter home page by pasting this code into app/page.js:
export default function Home() {
return (
<main>
<h1>Incident Triage</h1>
</main>
)
}
What Does This Page Render?
The Home component defines the content for the home route. Its heading gives you a visible result when the development server starts.
- Save app/page.js.
- Confirm Visual Studio Code shows no red syntax underline in the page.
Page File Showing a Syntax Error?
Check the closing tags for main and h1. If the error remains, help me debug the starter page.
- Create globals.css inside the app folder with the Explorer new file control.
The app/globals.css file holds the complete CSS foundation for the dashboard.
- Add the color variables and global element rules by pasting this code into app/globals.css:
:root {
color-scheme: dark;
--background: #07111f;
--surface: #111f33;
--surface-light: #172b45;
--border: #29415f;
--text: #f5f7fb;
--muted: #a8b6c9;
--accent: #68d5ff;
--danger: #ff8d8d;
--warning: #ffd166;
--success: #7be0a3;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--background);
color: var(--text);
font-family: Arial, Helvetica, sans-serif;
}
button,
a {
font: inherit;
}
What Do These Global Rules Control?
- The custom properties keep the dashboard colors consistent.
- The body rules create the dark canvas and shared typography.
- The sizing rule makes component dimensions predictable.
- Save app/globals.css.
- Confirm Visual Studio Code shows no red syntax underline in these rules.
Global Rules Showing an Error?
Check that every custom property starts with two hyphens. If the error remains, help me inspect the global theme rules.
- Add the link focus styles and shared header rules below the existing CSS:
a {
color: var(--accent);
}
a:focus-visible,
button:focus-visible {
outline: 3px solid var(--warning);
outline-offset: 3px;
}
.site-header {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1rem clamp(1rem, 5vw, 4rem);
border-bottom: 1px solid var(--border);
background: #09182a;
}
.brand,
.header-note,
.eyebrow,
.summary,
.meta,
.status-line {
margin: 0;
}
Why Add Focus and Header Styles?
The focus outline makes keyboard navigation visible. The header rules create a shared shell for every route.
- Save app/globals.css.
- Confirm Visual Studio Code shows no red syntax underline in the new selectors.
Header Selectors Showing an Error?
Check the commas between grouped selectors. If the error remains, help me debug the focus and header styles.
- Add the header text and main content sizing below the existing CSS:
.brand {
font-weight: 800;
}
.header-note,
.summary,
.meta,
.status-line {
color: var(--muted);
}
main {
width: min(920px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0;
}
h1 {
margin: 0.35rem 0 0.75rem;
font-size: clamp(2rem, 6vw, 4rem);
line-height: 1;
}
What Does This Sizing Establish?
The shared content width keeps the dashboard readable on wide screens. The responsive title size protects the layout on narrow screens.
- Save app/globals.css.
- Confirm Visual Studio Code shows no red syntax underline in the sizing rules.
Content Sizing Showing an Error?
Check the nested parentheses inside the width declaration. If the error remains, help me inspect the responsive sizing.
- Add the section heading and filter layout rules below the existing CSS:
h2,
h3 {
margin-top: 0;
}
.eyebrow {
color: var(--accent);
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 2rem 0 1rem;
padding: 0;
border: 0;
}
.filters legend {
width: 100%;
margin-bottom: 0.5rem;
font-weight: 700;
}
What Will These Filter Rules Support?
The filter controls can wrap on narrow screens. The legend occupies a full row so the group keeps a clear label.
- Save app/globals.css.
- Confirm Visual Studio Code shows no red syntax underline in the filter layout.
Filter Layout Showing an Error?
Check that .filters legend contains a space between its selectors. If the error remains, help me debug the filter layout.
- Add the interactive controls and incident list layout below the existing CSS:
.filter-button,
.retry-button {
padding: 0.7rem 1rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.filter-button[aria-pressed='true'],
.retry-button:hover {
border-color: var(--accent);
background: var(--surface-light);
}
.incident-list {
display: grid;
gap: 1rem;
margin: 1rem 0;
padding: 0;
list-style: none;
}
How Do These Controls Show State?
The pressed attribute highlights the selected filter. The queue keeps list semantics while removing default list markers.
- Save app/globals.css.
- Confirm Visual Studio Code shows no red syntax underline in the control rules.
Control Selectors Showing an Error?
Check the brackets around the pressed attribute selector. If the error remains, help me correct the interactive control styles.
- Add the shared card and alignment rules below the existing CSS:
.incident-card,
.panel,
.route-skeleton,
.error-card {
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--surface);
}
.card-top,
.detail-meta,
.error-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.incident-link {
display: inline-block;
margin-top: 1rem;
font-weight: 800;
}
Why Share These Card Styles?
Queue cards, detail panels, loading surfaces, and errors use one visual treatment. The alignment rules let metadata and actions wrap on smaller screens.
- Save app/globals.css.
- Confirm Visual Studio Code shows no red syntax underline in the shared card rules.
Shared Card Rules Showing an Error?
Check the commas between each grouped class selector. If the error remains, help me inspect the shared card styles.
- Add the badge and detail grid rules below the existing CSS:
.badge {
display: inline-flex;
align-items: center;
min-height: 1.8rem;
padding: 0.25rem 0.65rem;
border: 1px solid currentColor;
border-radius: 999px;
color: var(--warning);
font-size: 0.8rem;
font-weight: 800;
}
.badge[data-status='Mitigated'] {
color: var(--success);
}
.detail-grid {
display: grid;
gap: 1rem;
margin-top: 1.5rem;
}
What Do the Badges Communicate?
Badges create a consistent shape for priority and status. The status attribute gives mitigated incidents a distinct success color.
- Save app/globals.css.
- Confirm Visual Studio Code shows no red syntax underline in the badge rules.
Badge Selector Showing an Error?
Check the brackets around the data-status selector. If the error remains, help me debug the badge styles.
- Add the panel spacing and fallback surface rules below the existing CSS:
.panel p:last-child {
margin-bottom: 0;
}
.skeleton {
min-height: 150px;
overflow: hidden;
background: linear-gradient(100deg, var(--surface) 20%, var(--surface-light) 50%, var(--surface) 80%);
background-size: 200% 100%;
animation: pulse 1.2s infinite linear;
}
.route-skeleton {
min-height: 280px;
}
.error-card {
border-color: var(--danger);
}
.error-actions {
margin-top: 1rem;
}
How Do These Fallback Surfaces Help?
The skeleton reserves space while route content loads. The error card uses a distinct border so recovery controls remain easy to find.
- Save app/globals.css.
- Confirm Visual Studio Code shows no red syntax underline in the fallback rules.
Skeleton Gradient Showing an Error?
Check the commas between the gradient color stops. If the error remains, help me repair the fallback styles.
- Finish the stylesheet with the animation and reduced-motion rules:
@keyframes pulse {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
Why Include Reduced Motion?
The animation makes loading surfaces visibly active. The media query removes that movement when the operating system requests reduced motion.
- Save app/globals.css.
- Confirm Visual Studio Code shows no red syntax underline at the end of the stylesheet.
Animation Rules Showing an Error?
Check the nested braces in the keyframes and media query. If the error remains, help me debug the animation rules.
Your four source files now define the complete starting workspace. Use the tabs to compare every saved file before installing the packages.
✔️ Awesome, I've got everything!
Your source files are ready. Confirm that package.json, app/layout.js, app/page.js, and app/globals.css are saved.
ⓧ I'd like to double check the full code
Compare each saved file with its complete version below.
{
"name": "incident-triage",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.3.8",
"react": "19.3.0",
"react-dom": "19.3.0"
}
}
What Should This File Contain?
This reference contains the exact package scripts and dependency versions required by the workspace.
import './globals.css'
export const metadata = {
title: 'Incident Triage',
description: 'A server-first incident dashboard for exploring Next.js rendering.',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header className="site-header">
<p className="brand">Incident Triage</p>
<p className="header-note">Server-first rendering lab</p>
</header>
{children}
</body>
</html>
)
}
What Should This Layout Contain?
This reference contains the exact metadata, document shell, shared header, and route content position.
export default function Home() {
return (
<main>
<h1>Incident Triage</h1>
</main>
)
}
What Should This Page Contain?
This reference contains the complete starter home route and its visible heading.
:root {
color-scheme: dark;
--background: #07111f;
--surface: #111f33;
--surface-light: #172b45;
--border: #29415f;
--text: #f5f7fb;
--muted: #a8b6c9;
--accent: #68d5ff;
--danger: #ff8d8d;
--warning: #ffd166;
--success: #7be0a3;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--background);
color: var(--text);
font-family: Arial, Helvetica, sans-serif;
}
button,
a {
font: inherit;
}
a {
color: var(--accent);
}
a:focus-visible,
button:focus-visible {
outline: 3px solid var(--warning);
outline-offset: 3px;
}
.site-header {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1rem clamp(1rem, 5vw, 4rem);
border-bottom: 1px solid var(--border);
background: #09182a;
}
.brand,
.header-note,
.eyebrow,
.summary,
.meta,
.status-line {
margin: 0;
}
.brand {
font-weight: 800;
}
.header-note,
.summary,
.meta,
.status-line {
color: var(--muted);
}
main {
width: min(920px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0;
}
h1 {
margin: 0.35rem 0 0.75rem;
font-size: clamp(2rem, 6vw, 4rem);
line-height: 1;
}
h2,
h3 {
margin-top: 0;
}
.eyebrow {
color: var(--accent);
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 2rem 0 1rem;
padding: 0;
border: 0;
}
.filters legend {
width: 100%;
margin-bottom: 0.5rem;
font-weight: 700;
}
.filter-button,
.retry-button {
padding: 0.7rem 1rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.filter-button[aria-pressed='true'],
.retry-button:hover {
border-color: var(--accent);
background: var(--surface-light);
}
.incident-list {
display: grid;
gap: 1rem;
margin: 1rem 0;
padding: 0;
list-style: none;
}
.incident-card,
.panel,
.route-skeleton,
.error-card {
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--surface);
}
.card-top,
.detail-meta,
.error-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.incident-link {
display: inline-block;
margin-top: 1rem;
font-weight: 800;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 1.8rem;
padding: 0.25rem 0.65rem;
border: 1px solid currentColor;
border-radius: 999px;
color: var(--warning);
font-size: 0.8rem;
font-weight: 800;
}
.badge[data-status='Mitigated'] {
color: var(--success);
}
.detail-grid {
display: grid;
gap: 1rem;
margin-top: 1.5rem;
}
.panel p:last-child {
margin-bottom: 0;
}
.skeleton {
min-height: 150px;
overflow: hidden;
background: linear-gradient(100deg, var(--surface) 20%, var(--surface-light) 50%, var(--surface) 80%);
background-size: 200% 100%;
animation: pulse 1.2s infinite linear;
}
.route-skeleton {
min-height: 280px;
}
.error-card {
border-color: var(--danger);
}
.error-actions {
margin-top: 1rem;
}
@keyframes pulse {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
What Should This Stylesheet Contain?
This reference contains the complete dashboard theme, shared components, loading surfaces, error styles, and reduced-motion handling.
Install and run locally
The source files describe the application. npm now needs to install the exact framework packages listed in package.json.
The first installation downloads the dependencies, so it can take a short while. The terminal returning to its prompt confirms that the installation process has finished.
- Return to the integrated terminal in Visual Studio Code.
- Install the project dependencies by running:
npm install
What Does This Command Do?
npm reads the dependencies from package.json and installs them inside incident-triage. The project can now use its pinned framework versions.
- Wait for the terminal to return to its prompt.
- Confirm the Explorer sidebar now lists node_modules and package-lock.json.
Dependency Installation Failed?
Confirm the terminal belongs to the open incident-triage workspace. If the installation still fails, help me diagnose the npm output.
Before you start the development server, what heading do you expect the current app/page.js route to show?
- Start the Next.js development server by running:
npm run dev
What Does This Command Start?
npm runs the development script from package.json. The terminal remains occupied while Next.js watches the project files.
Keep this terminal running throughout the project. Future server-rendering errors will appear here.
- Open your web browser through Windows search or the taskbar.
- Enter http://localhost:3000 in the address bar.
- Load the local dashboard.
You will see the dark dashboard shell with its shared header. The main route displays Incident Triage as the page heading.
That first page is live. Your manually configured workspace can now serve routes from the App Router.
Local Dashboard Not Loading?
- Confirm the development server is still running in the Visual Studio Code terminal.
- Check the terminal for a file path or syntax error.
- Compare the four source files with the double-check tab above.
If the page still does not load, help me debug the local development server.
Your local dashboard is running from a workspace you can explain file by file. Next, you will replace the starter heading with a server-rendered incident queue.
Build the Server-Rendered Incident Queue
Your Next.js workspace is live in Visual Studio Code. The next job is to replace its placeholder page with incident data that feels like a real operations queue.
In the App Router, pages are Server Components by default. The queue can render useful UI from server-side data before any browser filter logic exists.
In this step, get ready to:
- Create the local incident data module.
- Render the incident queue from a Server Component.
- Verify three accessible incident cards in the browser.
Create the local data source
Keeping the data in lib/incidents.js gives the server-rendered pages one source for incident records. The reporter lookup includes a deliberate delay that later makes blocking behavior visible.
- Select the incident-triage folder in the file sidebar.
- Click the new folder icon at the top of the file sidebar.
- Type lib into the folder name field.
- Press Enter to create the folder.
- Select the new lib folder.
- Click the new file icon at the top of the file sidebar.
- Type incidents.js into the file name field.
- Press Enter to create the file.
You should see lib/incidents.js nested beneath the incident-triage folder.
- Build the local data module by pasting this code into lib/incidents.js:
const incidents = [
{ id: 'INC-101', title: 'Checkout latency spike', priority: 'P1', status: 'Open', summary: 'Payment confirmation is taking longer than the service objective.' },
{ id: 'INC-102', title: 'Search index delay', priority: 'P2', status: 'Mitigated', summary: 'New catalog items appear in search after an unexpected delay.' },
{ id: 'INC-103', title: 'Notification retries rising', priority: 'P2', status: 'Open', summary: 'The email worker is retrying more messages than usual.' },
]
const reporters = {
'INC-101': { name: 'Maya Chen', team: 'Payments Reliability', note: 'First detected during checkout synthetic monitoring.' },
'INC-102': { name: 'Jon Bell', team: 'Search Platform', note: 'Mitigation reduced indexing lag while the root cause is reviewed.' },
'INC-103': { name: 'Amina Yusuf', team: 'Messaging', note: 'Retry volume increased after a downstream provider slowdown.' },
}
export function getIncidents() { return incidents }
export function getIncident(id) { return incidents.find((incident) => incident.id === id) }
export async function getReporter(id) {
await new Promise((resolve) => setTimeout(resolve, 1800))
return reporters[id]
}
What does this data module do?
- The incidents array stores the three records that appear in the queue.
- The reporters object stores reporter details under each incident ID.
- The getIncidents() function returns the full queue to the home page.
- The getIncident(id) function finds one record for a future detail route.
- The getReporter(id) function waits for 1800 milliseconds before returning reporter information. That delay creates the slow server work you diagnose later.
- Save lib/incidents.js by pressing Ctrl+S.
The unsaved indicator on the incidents.js tab should disappear.
File in the wrong place?
- Confirm the file path is lib/incidents.js inside incident-triage.
- Compare every closing bracket in the data objects if Visual Studio Code marks the file with a syntax problem.
Help me check my incident data module.
✔️ Awesome, I've got everything!
Great. Double check that lib/incidents.js is saved before moving on.
ⓧ I'd like to double check the full code
Compare your complete lib/incidents.js file with this version:
const incidents = [
{ id: 'INC-101', title: 'Checkout latency spike', priority: 'P1', status: 'Open', summary: 'Payment confirmation is taking longer than the service objective.' },
{ id: 'INC-102', title: 'Search index delay', priority: 'P2', status: 'Mitigated', summary: 'New catalog items appear in search after an unexpected delay.' },
{ id: 'INC-103', title: 'Notification retries rising', priority: 'P2', status: 'Open', summary: 'The email worker is retrying more messages than usual.' },
]
const reporters = {
'INC-101': { name: 'Maya Chen', team: 'Payments Reliability', note: 'First detected during checkout synthetic monitoring.' },
'INC-102': { name: 'Jon Bell', team: 'Search Platform', note: 'Mitigation reduced indexing lag while the root cause is reviewed.' },
'INC-103': { name: 'Amina Yusuf', team: 'Messaging', note: 'Retry volume increased after a downstream provider slowdown.' },
}
export function getIncidents() { return incidents }
export function getIncident(id) { return incidents.find((incident) => incident.id === id) }
export async function getReporter(id) {
await new Promise((resolve) => setTimeout(resolve, 1800))
return reporters[id]
}
Render the incident queue on the server
The existing app/page.js only renders the project title. Replacing it turns the home page into a queue backed by the local data module.
- Select app/page.js in the file sidebar.
- Replace the contents of app/page.js with this code:
import Link from 'next/link'
import { getIncidents } from '../lib/incidents'
export default function Home() {
const incidents = getIncidents()
return (
<main>
<p className="eyebrow">Operations queue</p>
<h1>Production incidents</h1>
<p className="summary">Filter the queue, open a detail route, and watch slow server content stream independently.</p>
<section aria-labelledby="queue-heading">
<h2 id="queue-heading">Incident queue</h2>
<ul className="incident-list">
{incidents.map((incident) => (
<li className="incident-card" key={incident.id}>
<article>
<div className="card-top"><span className="badge">{incident.priority}</span><span className="badge" data-status={incident.status}>{incident.status}</span></div>
<h3>{incident.title}</h3><p className="meta">{incident.id}</p><p>{incident.summary}</p>
<Link className="incident-link" href={`/incidents/${incident.id}`}>Open {incident.id}</Link>
</article>
</li>
))}
</ul>
</section>
</main>
)
}
How does the queue render?
- The getIncidents() call reads the local records while the page renders on the server.
- The Home component maps each record into one incident card.
- The <ul> element identifies the queue as a list.
- Each <article> element groups the content for one incident.
- Each Link displays the incident ID in its visible label. Visitors can identify the destination before opening it.
- Save app/page.js by pressing Ctrl+S.
Before you refresh the page, how many incident cards do you expect the server-rendered queue to show?
- Return to the browser tab showing http://localhost:3000.
- Refresh the page.
You'll see Production incidents above three cards. Every card displays its incident ID.
Each priority appears inside a badge. Each card also displays its status and summary.
You have your first rendering win: the queue now comes from server-side data.
Still seeing the placeholder page?
- Confirm you replaced app/page.js instead of app/layout.js.
- Confirm the lib folder sits beside the app folder inside incident-triage.
- Check the running development server output for a file path that points to the failing import.
Help me debug why the incident queue is not rendering.
✔️ Awesome, I've got everything!
Great. Double check that app/page.js is saved before the final accessibility check.
ⓧ I'd like to double check the full code
Compare your complete app/page.js file with this version:
import Link from 'next/link'
import { getIncidents } from '../lib/incidents'
export default function Home() {
const incidents = getIncidents()
return (
<main>
<p className="eyebrow">Operations queue</p>
<h1>Production incidents</h1>
<p className="summary">Filter the queue, open a detail route, and watch slow server content stream independently.</p>
<section aria-labelledby="queue-heading">
<h2 id="queue-heading">Incident queue</h2>
<ul className="incident-list">
{incidents.map((incident) => (
<li className="incident-card" key={incident.id}>
<article>
<div className="card-top"><span className="badge">{incident.priority}</span><span className="badge" data-status={incident.status}>{incident.status}</span></div>
<h3>{incident.title}</h3><p className="meta">{incident.id}</p><p>{incident.summary}</p>
<Link className="incident-link" href={`/incidents/${incident.id}`}>Open {incident.id}</Link>
</article>
</li>
))}
</ul>
</section>
</main>
)
}
Verify the queue structure
Accessible semantic HTML gives browsers a clear outline of the page. It also gives assistive technology meaningful boundaries for the queue and each incident.
Before you check the finished queue, do you expect every visible link label to identify the incident it opens?
- Refresh http://localhost:3000.
- Confirm Production incidents is the only page-level heading.
- Confirm the introductory paragraph appears below the page heading.
- Count the incident cards in the queue.
- Check every card for an incident ID.
- Check every card for a priority and status.
- Check every card for a summary.
- Press Tab repeatedly until focus has moved through all three incident links.
You'll see three incident cards with visible IDs and priorities. Their statuses and summaries are visible too.
Keyboard focus reaches Open INC-101, Open INC-102, and Open INC-103 in queue order. Each visible label names its destination.
Your server-rendered queue now presents three incidents with a clear accessible structure. Next, you'll isolate the filter inside a Client Component. You'll also add dynamic incident details.
Add a Client Boundary and Dynamic Details
Your Server Component now renders all three incidents from local data. The queue is useful before any browser state arrives.
Filtering needs browser state. A narrow Client Component keeps that state away from the page-level server boundary.
The dynamic route includes delayed reporter data. You will use that route to expose the cost of waiting for every Next.js server result before rendering.
In this step, get ready to:
- Isolate queue filtering inside a Client Component.
- Create a dynamic incident route with incident-specific metadata.
- Experience how delayed reporter data blocks the first detail page.
Isolate the interactive filter
A Client Component can use React state for interactions in the browser. The page can remain server-rendered while it passes the incident array across the boundary.
- Create app/components/incident-board.js from the Visual Studio Code file sidebar by pasting this code:
'use client'
import { useState } from 'react'
import Link from 'next/link'
const filters = ['All', 'Open', 'Mitigated']
export default function IncidentBoard({ incidents }) {
const [status, setStatus] = useState('All')
const visibleIncidents = status === 'All' ? incidents : incidents.filter((incident) => incident.status === status)
return (
<section aria-labelledby="queue-heading">
<h2 id="queue-heading">Incident queue</h2>
<fieldset className="filters"><legend>Filter by status</legend>{filters.map((filter) => <button className="filter-button" type="button" aria-pressed={status === filter} onClick={() => setStatus(filter)} key={filter}>{filter}</button>)}</fieldset>
<p className="status-line" aria-live="polite">Showing {visibleIncidents.length} incident{visibleIncidents.length === 1 ? '' : 's'}</p>
<ul className="incident-list">{visibleIncidents.map((incident) => <li className="incident-card" key={incident.id}><article><div className="card-top"><span className="badge">{incident.priority}</span><span className="badge" data-status={incident.status}>{incident.status}</span></div><h3>{incident.title}</h3><p className="meta">{incident.id}</p><p>{incident.summary}</p><Link className="incident-link" href={`/incidents/${incident.id}`}>Open {incident.id}</Link></article></li>)}</ul>
</section>
)
}
What does this code do?
- 'use client' establishes the browser boundary for this module.
- useState('All') stores the selected incident status.
- visibleIncidents derives the cards that match the active filter.
- aria-pressed exposes each button's selected state to assistive technology.
- aria-live announces the updated incident count after a filter changes.
- Save app/components/incident-board.js.
You should see incident-board.js inside app/components in the file sidebar.
- Select app/page.js in the file sidebar.
- Replace its contents with this code:
import IncidentBoard from './components/incident-board'
import { getIncidents } from '../lib/incidents'
export default function Home() {
const incidents = getIncidents()
return (
<main>
<p className="eyebrow">Operations queue</p>
<h1>Production incidents</h1>
<p className="summary">Filter the queue, open a detail route, and watch slow server content stream independently.</p>
<IncidentBoard incidents={incidents} />
</main>
)
}
Why keep the page on the server?
- Home remains a Server Component because the file has no client directive.
- getIncidents() still reads the local incident data on the server.
- IncidentBoard receives a serializable array across the narrow client boundary.
- Save app/page.js.
- Refresh http://localhost:3000 in your browser.
You should see three filter buttons above the incident cards. You should also see Showing 3 incidents above the queue.
Great work. Your queue now has browser state without moving its data lookup into the client.
Filters missing from the queue?
- Confirm that 'use client' is the first line of incident-board.js.
- Check that app/page.js imports IncidentBoard from ./components/incident-board.
- Compare the component name in the import with the name used in <IncidentBoard incidents={incidents} />.
Help me debug my incident filter.
✔️ Awesome, I've got everything!
Your server page now passes its incidents into the saved client boundary.
ⓧ I'd like to double check the full code
- Compare your complete app/components/incident-board.js file with this reference:
'use client'
import { useState } from 'react'
import Link from 'next/link'
const filters = ['All', 'Open', 'Mitigated']
export default function IncidentBoard({ incidents }) {
const [status, setStatus] = useState('All')
const visibleIncidents = status === 'All' ? incidents : incidents.filter((incident) => incident.status === status)
return (
<section aria-labelledby="queue-heading">
<h2 id="queue-heading">Incident queue</h2>
<fieldset className="filters"><legend>Filter by status</legend>{filters.map((filter) => <button className="filter-button" type="button" aria-pressed={status === filter} onClick={() => setStatus(filter)} key={filter}>{filter}</button>)}</fieldset>
<p className="status-line" aria-live="polite">Showing {visibleIncidents.length} incident{visibleIncidents.length === 1 ? '' : 's'}</p>
<ul className="incident-list">{visibleIncidents.map((incident) => <li className="incident-card" key={incident.id}><article><div className="card-top"><span className="badge">{incident.priority}</span><span className="badge" data-status={incident.status}>{incident.status}</span></div><h3>{incident.title}</h3><p className="meta">{incident.id}</p><p>{incident.summary}</p><Link className="incident-link" href={`/incidents/${incident.id}`}>Open {incident.id}</Link></article></li>)}</ul>
</section>
)
}
- Compare your complete app/page.js file with this reference:
import IncidentBoard from './components/incident-board'
import { getIncidents } from '../lib/incidents'
export default function Home() {
const incidents = getIncidents()
return (
<main>
<p className="eyebrow">Operations queue</p>
<h1>Production incidents</h1>
<p className="summary">Filter the queue, open a detail route, and watch slow server content stream independently.</p>
<IncidentBoard incidents={incidents} />
</main>
)
}
Add the dynamic incident route
A folder named [id] captures the incident identifier from the URL. The route uses that value to generate metadata for each incident.
- Create app/incidents/[id]/page.js from the Visual Studio Code file sidebar by pasting this code:
import Link from 'next/link'
import { getIncident, getReporter } from '../../../lib/incidents'
export async function generateMetadata({ params }) {
const { id } = await params
const incident = getIncident(id)
return { title: incident ? `${incident.id}: ${incident.title}` : 'Incident unavailable', description: incident ? incident.summary : 'The requested incident could not be loaded.' }
}
How does the route metadata work?
- await params resolves the promised route parameters before reading the captured incident ID.
- getIncident(id) finds the matching record in the local data source.
- generateMetadata() returns an incident-specific title when the record exists.
- description uses the incident summary for the page description.
- Save app/incidents/[id]/page.js.
You should see page.js nested beneath app/incidents/[id] in the file sidebar.
- Add the detail page component below generateMetadata() in app/incidents/[id]/page.js by pasting this code:
export default async function IncidentPage({ params }) {
const { id } = await params
const incident = getIncident(id)
if (!incident) throw new Error('Incident not found')
const reporter = await getReporter(id)
return (
<main>
<Link href="/">Back to queue</Link><p className="eyebrow">{incident.id}</p><h1>{incident.title}</h1>
<div className="detail-meta"><span className="badge">{incident.priority}</span><span className="badge" data-status={incident.status}>{incident.status}</span></div><p className="summary">{incident.summary}</p>
<section className="panel"><h2>Reporter details</h2><p>{reporter.name}</p><p className="meta">{reporter.team}</p><p>{reporter.note}</p></section>
</main>
)
}
What does the detail page do?
- await params reads the incident ID captured by the dynamic segment.
- getIncident(id) loads the fast incident record.
- throw new Error('Incident not found') stops the route when the requested record is missing.
- getReporter(id) loads the delayed reporter record for the detail panel.
- Link gives the learner a route back to the incident queue.
- Save app/incidents/[id]/page.js.
✔️ Awesome, I've got everything!
Your dynamic route now contains generated metadata plus the first detail-page implementation.
ⓧ I'd like to double check the full code
- Compare your complete app/incidents/[id]/page.js file with this reference:
import Link from 'next/link'
import { getIncident, getReporter } from '../../../lib/incidents'
export async function generateMetadata({ params }) {
const { id } = await params
const incident = getIncident(id)
return { title: incident ? `${incident.id}: ${incident.title}` : 'Incident unavailable', description: incident ? incident.summary : 'The requested incident could not be loaded.' }
}
export default async function IncidentPage({ params }) {
const { id } = await params
const incident = getIncident(id)
if (!incident) throw new Error('Incident not found')
const reporter = await getReporter(id)
return (
<main>
<Link href="/">Back to queue</Link><p className="eyebrow">{incident.id}</p><h1>{incident.title}</h1>
<div className="detail-meta"><span className="badge">{incident.priority}</span><span className="badge" data-status={incident.status}>{incident.status}</span></div><p className="summary">{incident.summary}</p>
<section className="panel"><h2>Reporter details</h2><p>{reporter.name}</p><p className="meta">{reporter.team}</p><p>{reporter.note}</p></section>
</main>
)
}
Experience the blocked navigation
The queue now has interactive filtering plus incident-specific links. Testing both behaviors shows where the browser boundary helps and where delayed server work still affects navigation.
- Switch back to the browser tab running http://localhost:3000.
- Click the Open filter button.
You should see two cards. The live status line should read Showing 2 incidents.
- Click the Mitigated filter button.
You should see only the INC-102 card. The live status line should read Showing 1 incident.
Before you open the incident, do you think its heading will appear immediately while the reporter information loads?
- Click the Open INC-102 link.
The URL changes to /incidents/INC-102 immediately. The detail view stays absent until the delayed reporter lookup finishes.
After about 1.8 seconds you should see Search index delay with its priority and status. The reporter panel should show Jon Bell from Search Platform.
That pause is the intended shortfall. Your fast incident fields are ready before the reporter lookup resolves, yet the page withholds all of them.
Why did the route feel frozen?
IncidentPage awaits getReporter(id) before returning its JSX. The browser receives no detail UI during that wait.
One slow lookup therefore holds back the incident heading, priority, status, summary, and reporter panel. The next rendering change has a clear problem to solve.
Detail route not loading?
- Check that the route file is located at app/incidents/[id]/page.js.
- Confirm that the incident link uses /incidents/${incident.id} inside its href value.
- Check the running development server for a file path or syntax problem.
Help me debug my dynamic incident route.
You have diagnosed the blocking route through direct observation. Next, you will give that navigation immediate visual feedback while the reporter lookup continues.
Turn Blocking Navigation into a Loading State
The incident queue now filters through a narrow Client Component. The dynamic detail route also revealed its problem because the Server Component leaves navigation looking frozen while reporter data loads.
A route-level loading file lets Next.js acknowledge the navigation immediately. The existing reporter wait stays unchanged so you can focus on the effect of the loading boundary.
In this step, get ready to:
- Add an accessible route-level loading skeleton.
- Compare the loading sequence across multiple incident routes.
- Identify which ready incident details the broad fallback still hides.
Add the route-level fallback
A loading.js file defines the fallback for its route segment. Next.js displays it while the existing page.js waits for reporter data.
- Switch back to Visual Studio Code from earlier.
- Expand the app folder in the file sidebar.
- Expand the incidents folder.
- Expand the [id] folder.
- Select the new file icon beside [id].
- Type loading.js in the filename field.
- Press Enter to create the file.
You'll see loading.js beside page.js inside app/incidents/[id].
- Build the accessible route fallback by pasting this code into loading.js:
export default function Loading() {
return (
<main>
<div className="route-skeleton skeleton" aria-busy="true" aria-live="polite">
<p className="eyebrow">Loading incident</p>
<h1>Preparing incident details...</h1>
</div>
</main>
)
}
What does this code do?
- The Loading component gives the dynamic incident segment a temporary interface.
- The route-skeleton class reserves space for the incoming detail page.
- The skeleton class applies the existing animated loading style.
- The accessibility attributes announce that this region is busy without interrupting the learner's current task.
- The status text explains that incident details are being prepared.
- Save loading.js by pressing Ctrl+S.
The detail page still takes a moment because the reporter lookup remains delayed. The new fallback makes that pause visible.
Before you open another incident, predict what appears immediately after you select its link.
- Switch back to the browser from earlier.
- Return to the queue by selecting Back to queue.
- Open the first incident by selecting Open INC-101.
You'll immediately see the shared header above a loading skeleton containing Loading incident and Preparing incident details....
The complete incident detail page replaces the fallback after reporter data resolves. That is the silent wait solved.
Fallback not appearing?
- Confirm loading.js sits inside app/incidents/[id] beside page.js.
- Confirm the development server from earlier is still running in its terminal.
- Confirm you saved loading.js before selecting an incident link.
Help me find why my route-level loading fallback does not appear.
✔️ Awesome, I've got everything!
The fallback file is complete. Confirm loading.js is saved before you continue.
ⓧ I'd like to double check the full code
Compare your loading.js with the complete file below:
export default function Loading() {
return (
<main>
<div className="route-skeleton skeleton" aria-busy="true" aria-live="polite">
<p className="eyebrow">Loading incident</p>
<h1>Preparing incident details...</h1>
</div>
</main>
)
}
Compare the loading experience
The loading file belongs to the dynamic incident segment. Every incident route now shares the same fallback while the root layout stays visible.
- Wait for the INC-101 detail page to replace the fallback.
- Return to the queue by selecting Back to queue.
- Open the second incident by selecting Open INC-102.
You'll see the shared site header remain visible while the route-level skeleton fills the content area. The complete INC-102 detail page replaces it after the wait.
- Return to the queue by selecting Back to queue.
- Open the third incident by selecting Open INC-103.
You'll see the same fallback sequence for INC-103. This proves the loading file protects every route handled by the dynamic segment.
Identify the remaining limitation
The route-level fallback covers the entire dynamic page. The direct getReporter(id) wait still blocks every incident field behind it.
- Wait for the INC-103 detail page to finish loading.
- Return to the queue by selecting Back to queue.
Before you open the next incident, predict whether any incident-specific content appears beside the skeleton.
- Open the first incident again by selecting Open INC-101.
You'll see the route skeleton immediately. It hides all incident-specific details until the reporter lookup finishes.
The complete detail page then replaces the skeleton. The route responds quickly even though its ready content still cannot appear independently.
What limitation did you find?
The incident record is available before the reporter lookup completes. The broad route fallback cannot reveal that ready content separately.
The dynamic route now acknowledges navigation immediately while preserving its deliberate delay. Next, you'll keep the ready incident details visible while the reporter panel loads separately.
Stream Slow Data and Recover from Errors
Your Next.js App Router route now provides immediate loading feedback. The shared shell stays visible while the incident details wait.
The route-level skeleton still hides incident information that is already available. Only the reporter lookup needs the full delay.
You will move that lookup into an async Server Component behind a Suspense boundary. You will also add an error boundary that gives invalid routes a recovery path.
In this step, get ready to:
- Move the delayed reporter lookup into an async child component.
- Stream reporter details through a granular Suspense boundary.
- Add recoverable error UI for invalid incident routes.
Move slow work into an async child
The page currently waits for reporter data before returning any incident UI. Moving that lookup into ReporterCard gives the slow work its own component boundary.
- Return to Visual Studio Code.
- Select app/incidents/[id]/page.js in the Explorer sidebar.
- Add the async reporter component after the existing IncidentPage function by pasting this code:
async function ReporterCard({ incidentId }) {
const reporter = await getReporter(incidentId)
return (
<section className="panel" aria-labelledby="reporter-heading">
<h2 id="reporter-heading">Reporter details</h2>
<p>{reporter.name}</p>
<p className="meta">{reporter.team}</p>
<p>{reporter.note}</p>
</section>
)
}
What does this component do?
- The incidentId prop identifies which reporter record the component needs.
- The awaited getReporter(incidentId) call keeps the deliberate delay inside the child component.
- The returned section preserves the accessible reporter heading from the blocking page.
- Select the complete IncidentPage function in the same file.
- Replace the selected function with this version:
export default async function IncidentPage({ params }) {
const { id } = await params
const incident = getIncident(id)
if (!incident) {
throw new Error('Incident not found')
}
return (
<main>
<Link href="/">Back to queue</Link>
<p className="eyebrow">{incident.id}</p>
<h1>{incident.title}</h1>
<div className="detail-meta">
<span className="badge">{incident.priority}</span>
<span className="badge" data-status={incident.status}>
{incident.status}
</span>
</div>
<p className="summary">{incident.summary}</p>
<div className="detail-grid">
<ReporterCard incidentId={id} />
</div>
</main>
)
}
What changed in the page?
- The page resolves the incident before it returns the fast title and summary.
- The ReporterCard child now owns the delayed reporter lookup.
- The child still has no local Suspense boundary. Its delay continues to reach the route-level fallback.
- Save app/incidents/[id]/page.js.
Before you navigate, what do you expect the route to reveal while the child waits? Make your prediction now.
- Visit http://localhost:3000 in your browser.
- Select Open INC-102.
You should still see the route-level skeleton until the reporter lookup finishes. The incident heading remains hidden during that wait.
Why does the route still block?
An async child creates a place for the slow work to live. A nearby Suspense boundary creates the reveal point that lets the surrounding page stream first.
Reporter details not loading?
Confirm that ReporterCard receives id through the incidentId prop. Check that the component awaits getReporter(incidentId).
Compare the closing braces around IncidentPage with the snippet above if the development server reports a syntax problem.
Help me diagnose the async reporter component.
Add a granular Suspense boundary
The reporter needs a fallback that is narrower than the whole route. A matching skeleton reserves space while the fast incident content remains usable.
- Add ReporterSkeleton after ReporterCard in app/incidents/[id]/page.js by pasting this code:
function ReporterSkeleton() {
return (
<section className="panel skeleton" aria-busy="true" aria-live="polite">
<h2>Reporter details</h2>
<p>Loading reporter information...</p>
</section>
)
}
What does the reporter skeleton provide?
- The shared panel skeleton classes reserve the reporter panel's space.
- The aria-busy attribute identifies the section as unfinished.
- The aria-live attribute makes the loading status available to assistive technology.
- Replace the import group at the top of app/incidents/[id]/page.js with these imports:
import Link from 'next/link'
import { Suspense } from 'react'
import { getIncident, getReporter } from '../../../lib/incidents'
Why import Suspense here?
The named React import makes the boundary available in this Server Component. The page can then define exactly where delayed content pauses.
- Save app/incidents/[id]/page.js.
- Return to the incident queue in your browser.
- Open a different incident.
The route-level skeleton should still own the wait. This confirms that a fallback only becomes active when it is attached to a boundary.
- Find the current reporter render inside detail-grid.
<ReporterCard incidentId={id} />
What is the current render point?
The page renders ReporterCard directly. Its suspended work therefore reaches the broader route boundary.
- Replace the current reporter render with this local boundary:
<Suspense fallback={<ReporterSkeleton />}>
<ReporterCard incidentId={id} />
</Suspense>
How does this boundary change rendering?
- The fallback prop displays ReporterSkeleton while the child suspends.
- The page can stream its available incident content without waiting for ReporterCard.
- The finished reporter panel replaces only its matching skeleton.
- Save app/incidents/[id]/page.js.
Before you test the boundary, which incident content should become usable first? Make your prediction now.
- Return to the incident queue in your browser.
- Select Open INC-101.
You should see the incident heading and summary while the reporter skeleton remains visible. The reporter details replace that skeleton after the delayed lookup resolves.
You have turned one blocking route into a progressive page. Fast incident information is now available before the slow panel finishes.
Still seeing only the route skeleton?
Confirm that the delayed getReporter(incidentId) call appears only inside ReporterCard. A remaining page-level await keeps the entire route blocked.
Check that ReporterCard sits between the opening and closing Suspense tags.
Help me find why the route still blocks.
✔️ Awesome, I've got everything!
Great. Confirm that app/incidents/[id]/page.js is saved before you add error recovery.
ⓧ I'd like to double check the full code
Compare your complete app/incidents/[id]/page.js file with this reference:
import Link from 'next/link'
import { Suspense } from 'react'
import { getIncident, getReporter } from '../../../lib/incidents'
export async function generateMetadata({ params }) {
const { id } = await params
const incident = getIncident(id)
return {
title: incident ? `${incident.id}: ${incident.title}` : 'Incident unavailable',
description: incident ? incident.summary : 'The requested incident could not be loaded.',
}
}
export default async function IncidentPage({ params }) {
const { id } = await params
const incident = getIncident(id)
if (!incident) {
throw new Error('Incident not found')
}
return (
<main>
<Link href="/">Back to queue</Link>
<p className="eyebrow">{incident.id}</p>
<h1>{incident.title}</h1>
<div className="detail-meta">
<span className="badge">{incident.priority}</span>
<span className="badge" data-status={incident.status}>
{incident.status}
</span>
</div>
<p className="summary">{incident.summary}</p>
<div className="detail-grid">
<Suspense fallback={<ReporterSkeleton />}>
<ReporterCard incidentId={id} />
</Suspense>
</div>
</main>
)
}
async function ReporterCard({ incidentId }) {
const reporter = await getReporter(incidentId)
return (
<section className="panel" aria-labelledby="reporter-heading">
<h2 id="reporter-heading">Reporter details</h2>
<p>{reporter.name}</p>
<p className="meta">{reporter.team}</p>
<p>{reporter.note}</p>
</section>
)
}
function ReporterSkeleton() {
return (
<section className="panel skeleton" aria-busy="true" aria-live="polite">
<h2>Reporter details</h2>
<p>Loading reporter information...</p>
</section>
)
}
What should match?
The page returns fast incident content before the reporter lookup resolves. The reporter component sits inside its own Suspense boundary with a matching fallback.
Add recoverable route error UI
The route already throws when an incident cannot be found. An error.js file catches that failure for this dynamic segment.
The error UI needs browser interaction for retry behavior. That requirement makes it a narrow Client Component.
- Click the new file icon beside the app/incidents/[id] folder in the Explorer sidebar.
- Name the file error.js.
You should see error.js listed beside loading.js and page.js.
- Add the recoverable error interface by pasting this code into error.js:
'use client'
import Link from 'next/link'
export default function Error({ retry }) {
return (
<main>
<section className="error-card" role="alert">
<p className="eyebrow">Route error</p>
<h1>We could not load this incident</h1>
<p>The incident may not exist, or its server-rendered content failed.</p>
<div className="error-actions">
<button className="retry-button" type="button" onClick={() => retry()}>
Try again
</button>
<Link href="/">Back to queue</Link>
</div>
</section>
</main>
)
}
How does error recovery work?
- The 'use client' directive creates the client boundary required by the route error interface.
- The alert role identifies the failed route as important information.
- The retry() callback attempts to render the protected route again.
- The queue link gives the learner a safe exit when the requested incident remains invalid.
- Save app/incidents/[id]/error.js.
Before you load an unknown incident, what should replace the failed route? Make your prediction now.
- Visit http://localhost:3000/incidents/broken in your browser.
You should see the route error card with Try again and Back to queue.
✔️ The route error appears
Good. The dynamic segment now catches its invalid incident error and presents recovery controls.
ⓧ I'd like to double check the full code
Compare your complete app/incidents/[id]/error.js file with this reference:
'use client'
import Link from 'next/link'
export default function Error({ retry }) {
return (
<main>
<section className="error-card" role="alert">
<p className="eyebrow">Route error</p>
<h1>We could not load this incident</h1>
<p>The incident may not exist, or its server-rendered content failed.</p>
<div className="error-actions">
<button className="retry-button" type="button" onClick={() => retry()}>
Try again
</button>
<Link href="/">Back to queue</Link>
</div>
</section>
</main>
)
}
What should match?
The file begins with the client directive. Its retry button calls the stable callback while its link returns to the queue.
The error route works when entered directly. Adding a deliberate queue link turns it into a repeatable demonstration.
- Select app/components/incident-board.js in the Explorer sidebar.
- Find the closing </ul> below the incident cards.
- Add the error-demo link immediately below that closing tag by pasting this code:
<p>
Error demo: <Link href="/incidents/broken">open an invalid incident</Link>.
</p>
What does this link test?
The link requests an incident ID that does not exist in the local data. That request triggers the page's existing invalid-route error.
- Save app/components/incident-board.js.
✔️ Awesome, I've got everything!
Great. Confirm that the queue now includes the invalid-incident demo link.
ⓧ I'd like to double check the full code
Compare your complete app/components/incident-board.js file with this reference:
'use client'
import { useState } from 'react'
import Link from 'next/link'
const filters = ['All', 'Open', 'Mitigated']
export default function IncidentBoard({ incidents }) {
const [status, setStatus] = useState('All')
const visibleIncidents =
status === 'All'
? incidents
: incidents.filter((incident) => incident.status === status)
return (
<section aria-labelledby="queue-heading">
<h2 id="queue-heading">Incident queue</h2>
<fieldset className="filters">
<legend>Filter by status</legend>
{filters.map((filter) => (
<button
className="filter-button"
type="button"
aria-pressed={status === filter}
onClick={() => setStatus(filter)}
key={filter}
>
{filter}
</button>
))}
</fieldset>
<p className="status-line" aria-live="polite">
Showing {visibleIncidents.length} incident{visibleIncidents.length === 1 ? '' : 's'}
</p>
<ul className="incident-list">
{visibleIncidents.map((incident) => (
<li className="incident-card" key={incident.id}>
<article>
<div className="card-top">
<span className="badge">{incident.priority}</span>
<span className="badge" data-status={incident.status}>
{incident.status}
</span>
</div>
<h3>{incident.title}</h3>
<p className="meta">{incident.id}</p>
<p>{incident.summary}</p>
<Link className="incident-link" href={`/incidents/${incident.id}`}>
Open {incident.id}
</Link>
</article>
</li>
))}
</ul>
<p>
Error demo: <Link href="/incidents/broken">open an invalid incident</Link>.
</p>
</section>
)
}
What should match?
The existing filter and incident cards remain unchanged. The invalid-incident link appears after the queue list.
Before you use the new link, predict how each recovery control should behave. Keep that prediction in mind for the final check.
- Select Back to queue in the error card.
- Select open an invalid incident below the queue.
You should see the error card again. Its controls should read Try again and Back to queue.
- Select Try again.
The route retries its render. The same error card returns because broken still does not match an incident.
- Select Back to queue.
You should return to the incident queue with all three incident cards available. That completes the recovery path.
You now have an interview-ready rendering demo. It shows fast server content streaming around slow work while invalid routes remain recoverable.
Error interface not appearing?
Confirm that error.js is inside the same app/incidents/[id] folder as page.js.
Check that the file begins with 'use client'. Confirm that the component receives a prop named retry.
Help me debug the route error boundary.
Secret mission
Add a Parallel Streaming Timeline
Add a second slow server-rendered panel to the incident detail route. Watch its timeline resolve before reporter details to prove that sibling Suspense boundaries reveal content independently.
Clean Up Your Resources
Clean Up Your Resources
Your Next.js dashboard runs entirely on your Windows computer. There are no cloud resources or ongoing costs.
Resources you used:
- The running local Next.js development server.
- The local incident-triage folder containing your source files plus installed project dependencies.
Keep everything running
You can leave everything as it is. Choose this while you are actively testing the dashboard.
- Your dashboard remains available at http://localhost:3000 while the development server runs.
- Your incident-triage folder keeps the complete incident queue plus both streaming panels.
- Your installed project dependencies remain inside node_modules/.
Pause - I'll come back to this later
Stop the running development server to free up your terminal. Your project files remain ready for another session.
- Switch back to the Visual Studio Code terminal from earlier.
- Press Ctrl+C to stop the development server.
- Confirm that the command prompt returns in the terminal.
- Leave the incident-triage folder in its current location.
Your terminal is free again. Every source file remains ready for your next practice session.
Delete - I don't want to use this again
Deleting the folder permanently removes this local project. It affects only the incident-triage workspace.
- Switch back to the Visual Studio Code terminal from earlier.
- Press Ctrl+C to stop the development server.
- Confirm that the command prompt returns in the terminal.
- Close the Visual Studio Code window containing incident-triage.
- Use Windows search to locate the existing incident-triage folder.
- Open the folder's containing location in File Explorer.
- Select the incident-triage folder.
- Press Shift+Delete to permanently remove the selected folder.
You have cleared the complete local workspace. No cloud cleanup remains.
Nice Work!
Nice Work!
You did it! You built a Next.js incident-triage dashboard that turns the App Router rendering model into a visible interview demo.
What you learned:
- Rendered the incident queue with Server Components. Kept interactive status filtering inside a narrow Client Component.
- Experienced a blocking dynamic route before adding immediate route-level feedback. Compared that loading state with granular Suspense streaming.
- Added generated metadata for incident routes. Built accessible controls with clear state announcements. Created recoverable error UI with retry and queue actions.
- Secret Mission: extended the detail route with a second slow timeline panel. Its sibling Suspense boundary lets the timeline stream independently from reporter details.
Ready to quiz yourself?