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- 1
In the Next.js App Router, which file defines the UI for a route segment?
- 2
Which file in a route segment wraps its children and persists across navigations?
- 3
By default, components in the App Router are:
- 4
Which directive must appear at the very top of a file to mark it as a Client Component?
- 5
How do you create a dynamic route segment for a blog post slug in the App Router?
- 6
In a Server Component page with a dynamic route
[id], how do you access the dynamic parameter in Next.js 15+? - 7
Which Next.js component should you use for client-side navigation between routes?
- 8
How do you create an API route handler at
/api/helloin the App Router? - 9
Which
fetchoption causes Next.js to revalidate cached data every 60 seconds? - 10
How do you define static metadata (page title, description) for a route in the App Router?
- 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
What does wrapping a folder name in parentheses — e.g.
(marketing)— do in the App Router? - 13
What triggers a Next.js route to be rendered dynamically on every request rather than statically at build time?
- 14
How do you pre-render a set of dynamic route paths at build time in the App Router?
- 15
What is the purpose of the
<Suspense>boundary when used around a slow async Server Component? - 16
In the Edge Runtime used by Next.js Middleware, which of the following is NOT available?
Coding exercises
8 tasksCreate an About Page
EasyCreate a new page at the /about route that renders a heading About Us and a short paragraph. Use the App Router file conventions.
// 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
// 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>
);
}Shared Layout with Navigation
EasyCreate 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
// 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>
);
}Dynamic Blog Post Page
MediumCreate 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
// 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>
);
}Interactive Counter with Client Component
MediumCreate 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
// 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>
);
}JSON API Route Handler
HardCreate 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
// 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 });
}Contact Form with Server Action and useActionState
MediumBuild 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.
// 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
// 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>
);
}Streaming Dashboard with Suspense
HardCreate 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
// 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>
);
}Auth Middleware Guard
HardWrite 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
// 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*"],
};