CPCODELAB
Practice

Practice Next.js

You’ve read the lessons — now prove it. Take the quiz for instant feedback, then work the coding exercises. Try each one yourself before you reveal the solution.

Quick quiz

16 questions
Score: 0 / 16
0/16 answered
  1. 1

    In the Next.js App Router, which file defines the UI for a route segment?

  2. 2

    Which file in a route segment wraps its children and persists across navigations?

  3. 3

    By default, components in the App Router are:

  4. 4

    Which directive must appear at the very top of a file to mark it as a Client Component?

  5. 5

    How do you create a dynamic route segment for a blog post slug in the App Router?

  6. 6

    In a Server Component page with a dynamic route [id], how do you access the dynamic parameter in Next.js 15+?

  7. 7

    Which Next.js component should you use for client-side navigation between routes?

  8. 8

    How do you create an API route handler at /api/hello in the App Router?

  9. 9

    Which fetch option causes Next.js to revalidate cached data every 60 seconds?

  10. 10

    How do you define static metadata (page title, description) for a route in the App Router?

  11. 11

    Which hook do you use in a Client Component to access the current action's pending state and wire up a Server Action?

  12. 12

    What does wrapping a folder name in parentheses — e.g. (marketing) — do in the App Router?

  13. 13

    What triggers a Next.js route to be rendered dynamically on every request rather than statically at build time?

  14. 14

    How do you pre-render a set of dynamic route paths at build time in the App Router?

  15. 15

    What is the purpose of the <Suspense> boundary when used around a slow async Server Component?

  16. 16

    In the Edge Runtime used by Next.js Middleware, which of the following is NOT available?

🛠️

Coding exercises

8 tasks
1

Create an About Page

Easy

Create a new page at the /about route that renders a heading About Us and a short paragraph. Use the App Router file conventions.

Starter
tsx
// src/app/about/page.tsx
// TODO: create and export a default React component
💡 Show hint

Create the folder src/app/about/ and add a page.tsx file that exports a default function component.

✅ Show solution
tsx
// src/app/about/page.tsx
export default function AboutPage() {
  return (
    <main>
      <h1>About Us</h1>
      <p>We are building great things with Next.js and the App Router.</p>
    </main>
  );
}
2

Shared Layout with Navigation

Easy

Create a root layout (src/app/layout.tsx) that renders an <html> and <body> tag, includes a <nav> with <Link> components pointing to / (Home) and /about (About), and renders {children} inside <main>.

💡 Show hint

Import Link from next/link. The root layout must include <html> and <body> tags and accept a children prop.

✅ Show solution
tsx
// src/app/layout.tsx
import Link from "next/link";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <nav>
          <Link href="/">Home</Link>
          <Link href="/about">About</Link>
        </nav>
        <main>{children}</main>
      </body>
    </html>
  );
}
3

Dynamic Blog Post Page

Medium

Create a dynamic route at /blog/[slug] that: 1. Accepts a slug URL parameter. 2. Fetches post data from https://jsonplaceholder.typicode.com/posts/1 in a Server Component (ignore the slug for now and always fetch post 1). 3. Renders the post title and body. 4. Exports a metadata object with title: "Blog Post".

💡 Show hint

In Next.js 15, params is a Promise — use await params before reading params.slug. Fetch inside the async server component directly; no useEffect needed.

✅ Show solution
tsx
// src/app/blog/[slug]/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Blog Post",
};

type Post = { id: number; title: string; body: string };

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const res = await fetch("https://jsonplaceholder.typicode.com/posts/1", {
    next: { revalidate: 60 },
  });
  const post: Post = await res.json();

  return (
    <article>
      <p>Slug: <code>{slug}</code></p>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}
4

Interactive Counter with Client Component

Medium

Create a Counter component at src/components/Counter.tsx that: 1. Is a Client Component. 2. Uses useState to track a count starting at 0. 3. Renders the current count and two buttons: Increment and Decrement. Then import and render <Counter /> inside src/app/page.tsx (a Server Component).

💡 Show hint

Add "use client" as the very first line of Counter.tsx. Server Components can import and render Client Components — not the other way around.

✅ Show solution
tsx
// src/components/Counter.tsx
"use client";

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c - 1)}>Decrement</button>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

// src/app/page.tsx
import Counter from "../components/Counter";

export default function HomePage() {
  return (
    <main>
      <h1>Home</h1>
      <Counter />
    </main>
  );
}
5

JSON API Route Handler

Hard

Create a Route Handler at src/app/api/posts/route.ts that: 1. Handles GET requests: fetches all posts from https://jsonplaceholder.typicode.com/posts, returns them as JSON with a Cache-Control header of s-maxage=30. 2. Handles POST requests: reads a JSON body { title, body, userId }, forwards it to https://jsonplaceholder.typicode.com/posts via POST, and returns the created post with status 201. 3. Returns a 405 JSON error for any other method.

💡 Show hint

Use named exports GET, POST from route.ts. Access the request body with await request.json(). Use NextResponse.json(data, { status, headers }) to build responses.

✅ Show solution
tsx
// src/app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";

const UPSTREAM = "https://jsonplaceholder.typicode.com/posts";

export async function GET() {
  const res = await fetch(UPSTREAM, { next: { revalidate: 30 } });
  const posts = await res.json();
  return NextResponse.json(posts, {
    headers: { "Cache-Control": "s-maxage=30" },
  });
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const res = await fetch(UPSTREAM, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const created = await res.json();
  return NextResponse.json(created, { status: 201 });
}
6

Contact Form with Server Action and useActionState

Medium

Build a contact form using a Server Action and useActionState. 1. In src/lib/actions/contact.ts, create a "use server" action sendMessage(prevState, formData) that validates name and message fields and returns { error } or { success: true }. 2. In src/components/ContactForm.tsx (a Client Component), use useActionState to wire the action to a <form>. Show a pending state on the submit button, an error message on failure, and a success message on completion.

Starter
tsx
// src/lib/actions/contact.ts
"use server";

export type MessageState = { error?: string; success?: boolean };

export async function sendMessage(
  prevState: MessageState,
  formData: FormData
): Promise<MessageState> {
  // TODO: validate and return state
}

// src/components/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { sendMessage, type MessageState } from "@/lib/actions/contact";

// TODO: wire form with useActionState
💡 Show hint

The signature for useActionState is useActionState(action, initialState) and returns [state, formAction, isPending]. Pass formAction to the <form action={...}> prop.

✅ Show solution
tsx
// src/lib/actions/contact.ts
"use server";

export type MessageState = { error?: string; success?: boolean };

export async function sendMessage(
  _prevState: MessageState,
  formData: FormData
): Promise<MessageState> {
  const name = formData.get("name") as string;
  const message = formData.get("message") as string;

  if (!name || !message) {
    return { error: "Both fields are required." };
  }

  // Simulate async work
  await new Promise((r) => setTimeout(r, 500));
  return { success: true };
}

// src/components/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { sendMessage, type MessageState } from "@/lib/actions/contact";

const initial: MessageState = {};

export default function ContactForm() {
  const [state, action, isPending] = useActionState(sendMessage, initial);

  return (
    <form action={action} className="flex flex-col gap-4">
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-600">Message sent!</p>}
      <input name="name" placeholder="Your name" required />
      <textarea name="message" placeholder="Your message" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Send Message"}
      </button>
    </form>
  );
}
7

Streaming Dashboard with Suspense

Hard

Create a dashboard page at src/app/dashboard/page.tsx that demonstrates streaming. 1. Create a slow async Server Component RevenueCard in src/components/RevenueCard.tsx that waits 2 seconds (simulating a slow DB call) then renders a <div> with the text Revenue: $42,000. 2. Create a RevenueSkeleton component in src/components/RevenueSkeleton.tsx that renders a grey placeholder <div>. 3. In the dashboard page, render a heading immediately and wrap <RevenueCard /> in <Suspense fallback={<RevenueSkeleton />}> so the skeleton is visible during the 2-second delay.

💡 Show hint

Make RevenueCard an async function that awaits a Promise that resolves after 2000 ms using new Promise(r => setTimeout(r, 2000)). The page itself is a Server Component — no "use client" needed.

✅ Show solution
tsx
// src/components/RevenueSkeleton.tsx
export default function RevenueSkeleton() {
  return (
    <div className="animate-pulse h-24 w-64 rounded-xl bg-gray-200" />
  );
}

// src/components/RevenueCard.tsx
export default async function RevenueCard() {
  await new Promise((r) => setTimeout(r, 2000));
  return (
    <div className="rounded-xl bg-white p-6 shadow">
      <p className="text-sm text-gray-500">Total Revenue</p>
      <p className="text-3xl font-bold">$42,000</p>
    </div>
  );
}

// src/app/dashboard/page.tsx
import { Suspense } from "react";
import RevenueCard from "@/components/RevenueCard";
import RevenueSkeleton from "@/components/RevenueSkeleton";

export default function DashboardPage() {
  return (
    <main className="p-8">
      <h1 className="mb-6 text-2xl font-bold">Dashboard</h1>
      <Suspense fallback={<RevenueSkeleton />}>
        <RevenueCard />
      </Suspense>
    </main>
  );
}
8

Auth Middleware Guard

Hard

Write Next.js middleware in src/middleware.ts that: 1. Protects all routes under /dashboard and /settings. 2. Checks for a cookie named session_token. 3. If the cookie is missing, redirects to /login?from=<original-pathname>. 4. Otherwise allows the request through. 5. Restricts the middleware to only run on /dashboard and /settings paths (use the config.matcher).

💡 Show hint

Use req.cookies.get("session_token") to read the cookie. Build the redirect URL with new URL("/login", req.url) and append the from param with searchParams.set. Export a config object with a matcher array to limit which paths trigger the middleware.

✅ Show solution
tsx
// src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(req: NextRequest) {
  const token = req.cookies.get("session_token")?.value;

  if (!token) {
    const loginUrl = new URL("/login", req.url);
    loginUrl.searchParams.set("from", req.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};