What You'll Build
You will build a Cricket Series Dashboard that combines every Module 2 concept into one working page: Server Components fetching data, parallel data fetching, a Server Action for a mutation, streaming with Suspense for a slow panel, and proper error and not-found handling. The dashboard shows series standings and a leading-scorer panel fetched on the server, a form that adds a note via a Server Action with revalidation, and a deliberately slow analytics panel that streams in behind a Suspense boundary while the rest of the page is usable immediately. By the end you will have a page that fetches securely on the server, mutates cleanly, streams gracefully, and fails safely — the complete server-and-client toolkit assembled into a realistic feature.
Prerequisites
- A working App Router project from Module 1, or a fresh one scaffolded with create-next-app using the App Router.
- Understanding of async Server Components and awaiting data directly during render.
- Familiarity with Server Actions, the 'use server' directive, and revalidatePath.
- Knowledge of Suspense boundaries and the loading.js convention for streaming.
- Awareness of error.js, not-found.js, and the notFound function for graceful failures.
Setup & Project Structure
Plan the dashboard route before coding. You will create a dashboard route with a page that fetches standings and top scorer in parallel, an actions file holding the add-note Server Action, a slow analytics component wrapped in Suspense, and error and not-found files for safety. Laying out these files first makes each concept's home obvious and shows how server fetching, mutation, streaming, and error handling coexist in a single segment.
# Target structure for the dashboard segment:
# app/
# dashboard/
# page.js \u2190 parallel fetch + form + Suspense
# actions.js \u2190 'use server' addNote action
# SlowAnalytics.js \u2190 deliberately slow, streamed component
# loading.js \u2190 route-level fallback skeleton
# error.js \u2190 client error boundary with reset
# not-found.js \u2190 friendly missing-data UI
# lib/
# series.js \u2190 mock data sources
# Start the dev server
npm run dev # http://localhost:3000/dashboardStep 1 — Foundation
Step 1 builds the data layer and the parallel-fetching page. You create mock data functions that simulate network latency, then a Server Component page that fires the standings and top-scorer fetches concurrently with Promise.all so the page does not waterfall. This establishes the secure server-side data foundation everything else builds on, and demonstrates the parallel-fetch habit that keeps data-dense pages fast.
// lib/series.js \u2014 mock data sources with simulated latency
export async function getStandings() {
await new Promise((r) => setTimeout(r, 300));
return [
{ id: 1, name: 'India', points: 18 },
{ id: 2, name: 'Australia', points: 14 },
{ id: 3, name: 'England', points: 10 },
];
}
export async function getTopScorer() {
await new Promise((r) => setTimeout(r, 300));
return { name: 'Rohit Sharma', runs: 421 };
}
// app/dashboard/page.js \u2014 parallel fetch in a Server Component
import { getStandings, getTopScorer } from '../../lib/series';
export default async function DashboardPage() {
const [standings, topScorer] = await Promise.all([getStandings(), getTopScorer()]);
return (
<section>
<h1>\ud83c\udfcf Series Dashboard</h1>
<p>Leading scorer: {topScorer.name} ({topScorer.runs} runs)</p>
<ol>{standings.map((t) => <li key={t.id}>{t.name}: {t.points} pts</li>)}</ol>
</section>
);
}Step 2 — Core Logic
Step 2 adds the mutation: a Server Action that records a coach's note and revalidates the dashboard so the new note appears. The action validates input, mutates the store, and calls revalidatePath. The page renders a form bound directly to the action, requiring no client fetch code. This is the write half of the dashboard, demonstrating how Server Actions turn a form submission into a secure server mutation with automatic UI refresh.
// app/dashboard/actions.js
'use server';
import { revalidatePath } from 'next/cache';
const notes = []; // imagine a real database
export async function addNote(formData) {
const text = String(formData.get('note') ?? '').trim();
if (text.length < 3) return { error: 'Note is too short' };
notes.push({ id: crypto.randomUUID(), text });
revalidatePath('/dashboard'); // refresh the page so the note shows
return { ok: true };
}
export function getNotes() { return notes; }
// In app/dashboard/page.js, render the form and existing notes:
// import { addNote, getNotes } from './actions';
// <form action={addNote}>
// <input name="note" placeholder="Coach's note" />
// <button type="submit">Save note</button>
// </form>
// <ul>{getNotes().map((n) => <li key={n.id}>{n.text}</li>)}</ul>Step 3 — Integration & Enhancement
Step 3 integrates streaming and safety. You add a deliberately slow analytics component wrapped in a Suspense boundary so the dashboard's fast content appears instantly while the analytics stream in, plus a loading.js skeleton, an error.js boundary with reset, and a not-found.js. This brings the read, write, streaming, and failure-handling pieces together so the dashboard is fast, mutable, and resilient all at once — the full Module 2 system operating as one feature.
// app/dashboard/SlowAnalytics.js \u2014 slow component to stream
export default async function SlowAnalytics() {
await new Promise((r) => setTimeout(r, 2000)); // simulate slow service
return <p>Net run rate leader: India (+1.42)</p>;
}
// app/dashboard/page.js \u2014 wrap the slow part in Suspense
import { Suspense } from 'react';
import SlowAnalytics from './SlowAnalytics';
// inside the returned JSX, after the standings:
// <Suspense fallback={<p>Loading analytics\u2026</p>}>
// <SlowAnalytics />
// </Suspense>
// app/dashboard/loading.js
export default function Loading() { return <p>Loading dashboard\u2026</p>; }
// app/dashboard/error.js
'use client';
export default function Error({ error, reset }) {
return (<div><p>Dashboard failed to load.</p><button onClick={() => reset()}>Retry</button></div>);
}
// app/dashboard/not-found.js
export default function NotFound() { return <p>No series data available.</p>; }Step 4 — Testing & Verification
Run the dashboard and verify each behaviour deliberately. Confirm the fast content paints before the analytics stream in, that adding a note refreshes the list without a reload, that a short note is rejected, and that the error boundary catches a thrown error. Verifying each path proves the concepts work together rather than just in isolation.
# With the dev server running, verify at /dashboard:
# 1. Page shows standings + top scorer immediately;
# 'Loading analytics\u2026' appears, then analytics stream in ~2s later.
# 2. Submit a valid note \u2192 it appears in the list, no full reload.
# 3. Submit a 1-character note \u2192 rejected with 'Note is too short'.
# 4. Temporarily throw in SlowAnalytics (throw new Error('boom'))
# \u2192 error.js renders with a working Retry button.
# 5. Remove the throw; confirm the dashboard recovers via reset() without reload.Warning: A common error is awaiting the two fetches on separate lines instead of with Promise.all, which silently turns parallel fetching into a slower waterfall. Another is forgetting revalidatePath in the action, which leaves the note list stale. Verify both: the page should load fast and the note should appear immediately after submit.
Extension Challenge: Add a second Suspense boundary around a separate slow component so two panels stream independently, give the analytics its own error.js by extracting it into a nested route segment, and add useTransition to a Client Component that calls addNote so the save button shows a pending state. This exercises granular streaming, nested error boundaries, and client-side action invocation together.
- Server Components fetch data directly and securely; firing independent fetches with Promise.all avoids slow waterfalls.
- A Server Action bound to a form mutates on the server and calls revalidatePath so the UI reflects the change without a reload.
- Wrapping a slow component in Suspense lets fast dashboard content paint immediately while the slow panel streams in.
- loading.js gives the whole route an automatic skeleton, while explicit Suspense controls streaming at finer granularity.
- error.js isolates runtime failures to the segment and offers a reset to recover, while not-found.js handles missing data.
- Validating action input and revalidating after mutation are the two habits that keep mutations secure and the UI consistent.