In the App Router a route is a folder inside app/: a page.tsx file makes it a URL, layout.tsx defines a shared shell that does not re-render on navigation, and a folder in square brackets ([slug]) creates a dynamic route. You navigate with <Link>: Next.js prefetches the page and swaps only the part that changed. The loading, error and not-found files define loading, error and not-found states, while metadata and generateMetadata set each page's title and description.
After this you will be able to
- build a site from pages and nested layouts with the App Router and explain what each file does
- create a dynamic
[slug]route withgenerateStaticParams,notFound()andgenerateMetadata - set up navigation with
<Link>, highlight the active item withusePathnameand navigate from code withuseRouter - add
loading,errorandnot-foundand check withcurlwhich HTTP status and title each page returns
Know beforehand
Verified with
Next.js 16.4React 19.3TypeScript 5.9Node.js 24 LTS (24.18)
Checked against docs: October 11, 2026
Contents
What Next.js adds on top of React
React handles components and state, but it does not decide how page URLs work, where HTML is rendered or how metadata reaches search engines. Next.js is a framework on top of React that takes care of this. The App Router is its current routing system: routes are folders in app/, and components run on the server by default (Server Components; the next lesson covers them in depth).
Page URLs
- React alone (SPA)
- A separate router library and manual setup
- Next.js App Router
- Folder = URL segment,
page.tsx= page
First HTML
- React alone (SPA)
- An empty
<div>; JavaScript builds the markup in the browser - Next.js App Router
- Ready HTML from the server, at build time or per request
Shared shell
- React alone (SPA)
- A wrapper component you mount yourself
- Next.js App Router
layout.tsx, which keeps state across navigations
Title and description
- React alone (SPA)
- A third-party library or manual
document.title - Next.js App Router
metadataandgenerateMetadata→ tags in<head>
Loading, error, 404
- React alone (SPA)
- Custom states in every component
- Next.js App Router
loading,error,not-foundfiles for a whole segment
This lesson was verified on Next.js 16.4, the current version at the time of writing. The project was created with create-next-app and the recommended defaults: TypeScript, ESLint, Tailwind CSS, App Router and Cache Components. The last one matters: a new project's next.config.ts already has cacheComponents: true and partialPrefetching: true, and some behavior below (such as the status code for an unknown URL) depends on them.
npx create-next-app@latest kite-studio --yes
cd kite-studio
npm run devThe practice project is a Kite Studio site with four kinds of pages: home, a service list, a service page at /services/<slug> and contacts. It has a shared header with navigation, a separate layout for the services section with a sidebar, metadata for every page and an honest 404 for a service that does not exist. Styles are omitted: the project keeps the template's app/globals.css with a few rules for the navigation.
Route = folder: page and layout
The App Router rule: folders define URL segments, and special files inside a folder define what is shown for that segment. A URL becomes public only when the folder contains page.tsx (or route.ts for an API). Everything else in app/ (components, utilities, styles) can live alongside: only what page returns is sent to the client.
app/layout.tsx
- URL
- —
- Role
- Root layout:
<html>,<body>, header, footer
app/page.tsx
- URL
/- Role
- Home
app/contacts/page.tsx
- URL
/contacts- Role
- Contacts
app/services/layout.tsx
- URL
- —
- Role
- Section layout: services sidebar
app/services/page.tsx
- URL
/services- Role
- Service list
app/services/[slug]/page.tsx
- URL
/services/websites, etc.- Role
- A single service page
app/nav-links.tsx, lib/services.ts
- URL
- —
- Role
- Regular modules, not routes
Start with the data. In a real project services would come from a CMS or a database; here it is an array, so the lesson stays about routing rather than data fetching:
export type Service = {
slug: string;
title: string;
summary: string;
details: string;
};
const services: Service[] = [
{
slug: "websites",
title: "Websites",
summary: "Landing pages and company sites.",
details: "Design, development and launch of a fast, accessible site.",
},
{
slug: "web-apps",
title: "Web apps",
summary: "Dashboards, portals and internal tools.",
details: "A product with accounts, roles and data that changes every day.",
},
{
slug: "mobile-apps",
title: "Mobile apps",
summary: "iOS and Android apps.",
details: "One codebase for both platforms and a release to the stores.",
},
];
export function getServices(): Service[] {
return services;
}
export function getService(slug: string): Service | undefined {
return services.find((service) => service.slug === slug);
}The root layout is required and must contain the <html> and <body> tags. It wraps every page of the site: the header, the footer and default metadata go here. You do not import the LayoutProps<"/"> type: Next.js generates it globally during next dev, next build or next typegen and infers the layout's props from the folder structure.
import type { Metadata } from "next";
import { NavLinks } from "./nav-links";
import "./globals.css";
export const metadata: Metadata = {
title: {
template: "%s | Kite Studio",
default: "Kite Studio",
},
description: "Websites, web apps and mobile apps.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<header className="site-header">
<NavLinks />
</header>
<main>{children}</main>
<footer className="site-footer">© Kite Studio</footer>
</body>
</html>
);
}A page is the default export of page.tsx. Home lives directly in app/, contacts in the contacts folder, and the folder itself becomes the /contacts URL:
import Link from "next/link";
export default function HomePage() {
return (
<section>
<h1>We build websites and apps</h1>
<p>Pick a service to see what is included.</p>
<Link href="/services">All services</Link>
</section>
);
}import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Contacts",
description: "How to reach Kite Studio.",
};
export default function ContactsPage() {
return (
<section>
<h1>Contacts</h1>
<p>
Email: <a href="mailto:hello@example.com">hello@example.com</a>
</p>
</section>
);
}metadata object sets the page title and descriptionNested layouts and navigation with Link
Layouts nest the same way folders do. app/services/layout.tsx wraps both /services and every /services/<slug>, and it sits inside the root layout. When you move between services, Next.js re-renders only the page: the header and the sidebar stay in place and keep their state.
- 01app/layout.tsxhtml, body, header with navigation — shared by the whole site
- 02app/services/layout.tsxservices sidebar — only for the /services section
- 03app/services/[slug]/page.tsxthe specific service — changes on navigation
import Link from "next/link";
import { getServices } from "@/lib/services";
export default function ServicesLayout({ children }: LayoutProps<"/services">) {
const services = getServices();
return (
<div className="services">
<aside>
<h2>Services</h2>
<ul>
{services.map((service) => (
<li key={service.slug}>
<Link href={`/services/${service.slug}`}>{service.title}</Link>
</li>
))}
</ul>
</aside>
<div>{children}</div>
</div>
);
}<Link> from next/link extends the regular <a>: the link stays a real link (it opens in a new tab, search engines see it), but on click Next.js does not reload the page and swaps only the segments that changed. <Link> also prefetches the route when the link enters the viewport, so by the time of the click the data is often already in the browser. Using a plain <a> between your own pages is a common mistake: every click becomes a full reload.
To highlight the active menu item you need the current URL. The usePathname hook reads it, and hooks only work in Client Components, so the navigation lives in its own file with the "use client" directive. The layout stays a Server Component: only the small links component becomes a client one.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
{ href: "/", label: "Home" },
{ href: "/services", label: "Services" },
{ href: "/contacts", label: "Contacts" },
];
export function NavLinks() {
const pathname = usePathname();
return (
<nav aria-label="Main">
{links.map(({ href, label }) => {
const isActive =
href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
aria-current={isActive ? "page" : undefined}
>
{label}
</Link>
);
})}
</nav>
);
}aria-current="page", which both styles and screen readers can seeThe dynamic [slug] route and params
You do not need a folder per service. A folder in square brackets is a dynamic segment: app/services/[slug]/page.tsx serves /services/websites, /services/web-apps and any other URL at that level. The segment value arrives in the params prop.
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getService, getServices } from "@/lib/services";
export function generateStaticParams() {
return getServices().map((service) => ({ slug: service.slug }));
}
export async function generateMetadata({
params,
}: PageProps<"/services/[slug]">): Promise<Metadata> {
const { slug } = await params;
const service = getService(slug);
if (!service) return { title: "Service not found" };
return { title: service.title, description: service.summary };
}
export default async function ServicePage({
params,
}: PageProps<"/services/[slug]">) {
const { slug } = await params;
const service = getService(slug);
if (!service) notFound();
return (
<article>
<h1>{service.title}</h1>
<p>{service.details}</p>
<Link href="/contacts">Discuss a project</Link>
</article>
);
}generateStaticParams
Returns the
slugvalues known in advance. Next.js builds HTML for them at build time. The function must always return an array, even an empty one.generateMetadata
Receives the same
paramsand returns the service title and description.<head>getsWebsites | Kite Studio: the root layout template inserts the name.notFound()
If there is no service with that slug, this function from
next/navigationstops rendering and shows the nearestnot-found.tsx. TypeScript knows execution does not continue after the call, soserviceis no longerundefinedbelow it.PageProps<"/services/[slug]">
A global helper type: from the route path it infers the shape of
params, herePromise<{ slug: string }>. No import needed.
The build output shows the result. The three known services are marked ○: ready HTML. The ◐ /services/[slug] line is the shell for URLs that were not in generateStaticParams: with Cache Components enabled Next.js prerenders a static shell for them and renders the content on the first request.
$ npm run build
▲ Next.js 16.4.0 (Turbopack)
✓ Running next.config.ts took 27ms
- Cache Components enabled
- Partial Prefetching enabled
Creating an optimized production build ...
✓ Compiled successfully in 532ms
Running TypeScript ...
Finished TypeScript in 1258ms ...
Collecting page data using 8 workers ...
✓ Generating static pages using 8 workers (10/10) in 949ms
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /contacts
├ ○ /services
└ /services/[slug]
├ ◐ /services/[slug]
├ ○ /services/websites
├ ○ /services/web-apps
└ ○ /services/mobile-apps
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentnext build output (Next.js 16.4, Windows 11), intermediate progress lines removedNavigating from code: useRouter
The Next.js docs recommend <Link> wherever the user clicks a link. useRouter is for navigation that follows another action: picking an option, a successful form submit, a key press. Import the hook from next/navigation (not next/router, which is the old Pages Router API); it only works in Client Components.
"use client";
import { useRouter } from "next/navigation";
type Option = { slug: string; title: string };
export function ServicePicker({ options }: { options: Option[] }) {
const router = useRouter();
return (
<label>
Go to service{" "}
<select
defaultValue=""
onChange={(event) => router.push(`/services/${event.target.value}`)}
>
<option value="" disabled>
Choose…
</option>
{options.map((option) => (
<option key={option.slug} value={option.slug}>
{option.title}
</option>
))}
</select>
</label>
);
}The server list page passes the client component only what it needs: an array of { slug, title }. Everything passed from a Server Component to a Client Component through props is serialized and sent to the browser, so extra fields (let alone secrets) do not belong there.
import type { Metadata } from "next";
import { getServices } from "@/lib/services";
import { ServicePicker } from "./service-picker";
export const metadata: Metadata = {
title: "Services",
};
export default function ServicesPage() {
const services = getServices();
return (
<section>
<h1>Services</h1>
<ServicePicker
options={services.map(({ slug, title }) => ({ slug, title }))}
/>
{services.map((service) => (
<article key={service.slug}>
<h2>{service.title}</h2>
<p>{service.summary}</p>
</article>
))}
</section>
);
}router.push(href)
- What it does
- Client-side navigation with a new browser history entry
router.replace(href)
- What it does
- Navigation without a new entry: Back returns to the page before the current one
router.back(), router.forward()
- What it does
- Step through browser history
router.refresh()
- What it does
- Re-request the current page's Server Components without losing client state
router.prefetch(href)
- What it does
- Prefetch a route the user is likely to visit
loading, error and not-found
These three files define states for a whole segment, with no conditional checks in every component. Next.js nests them in a strict order: layout → template → error → loading → not-found → page. Two practical consequences: an error in a page does not break the layout of its own segment, and error.tsx does not catch an error in the layout.tsx at the same level; you need an error.tsx one level up for that.
export default function Loading() {
return <p aria-busy="true">Loading…</p>;
}loading.tsx automatically wraps the page in <Suspense>: the header and menu stay interactive, and a placeholder shows immediately instead of the content. Our services are all built at build time, so you will hardly see it. It kicks in when a page renders per request, for example when it loads data from an API. The docs recommend adding loading.tsx to dynamic routes: the shell and the placeholder can then be prefetched.
"use client";
import { useEffect } from "react";
export default function ServicesError({
error,
retry,
}: {
error: Error & { digest?: string };
retry: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<section>
<h2>Could not load this section</h2>
<button type="button" onClick={() => retry()}>
Try again
</button>
</section>
);
}error.tsx catches unexpected exceptions while a segment renders and shows fallback UI instead of the crashed tree. The retry() function (stable since Next.js 16.3) re-fetches and re-renders the boundary's contents; the older reset() only clears the error state without re-fetching. We checked this on a temporary page that throws on click: "Could not load this section" appeared, the sidebar and header stayed in place, and Try again brought the page back. The boundary does not catch errors in event handlers or async code; handle those manually with try/catch and state.
import Link from "next/link";
export default function ServiceNotFound() {
return (
<section>
<h1>No such service</h1>
<Link href="/services">Back to all services</Link>
</section>
);
}There is a subtlety with not-found that we saw during testing. Before the response starts streaming, notFound() returns a 404 status. But for a URL that is not in generateStaticParams, Next.js with Cache Components first sends the prerendered shell, and the 200 status has already gone out. The not-found page is still shown, and Next.js adds <meta name="robots" content="noindex"> so search engines do not index it. Later requests to the same URL get a 404 from the cache:
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/services/abc
200
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/services/abc
404For search engines noindex is enough, but if the status matters (analytics, monitoring, client requirements), the docs suggest checking the slug before rendering starts, in Proxy. That is the proxy.ts file in the project root; before Next.js 16 it was called middleware.ts. The check here is fast, a lookup in an array; heavy database queries do not belong in Proxy.
import { NextResponse, type NextRequest } from "next/server";
import { getService } from "@/lib/services";
export function proxy(request: NextRequest) {
const slug = request.nextUrl.pathname.split("/")[2];
if (!getService(slug)) {
return NextResponse.rewrite(new URL("/404", request.url));
}
}
export const config = {
matcher: "/services/:slug",
};Page metadata and SEO
There are two ways to define metadata, and both work only in Server Components. A static metadata object fits when the title is known in advance (contacts, the service list). The generateMetadata function is for titles that depend on data (a service page). Next.js builds the <head> tags from them; the charset and viewport tags are always added.
title.template: "%s | Kite Studio"
- What it does
- A template for child segments:
Services→Services | Kite Studio
title.default
- What it does
- Title for pages without their own; required when
templateis set
title.absolute
- What it does
- A title that ignores the parent template
description
- What it does
- Inherited by child pages until they set their own
The template applies only to child segments. So the home page, which has no title of its own, gets title.default, just Kite Studio, and title.template in a page.tsx has no effect at all: a page has no child segments. The description is inherited: /services has none of its own and keeps the root layout's description. That is fine for a catalog, but pages meant to attract search traffic should set their own.
Let's check the built site without a browser: start the production server and look at the status and title of each URL.
$ npm run start &
$ for path in / /services /services/websites /contacts /services/unknown; do
curl -s -o page.html -w "%{http_code} $path " "http://localhost:3000$path"
grep -o '<title>[^<]*</title>' page.html | head -1
done
200 / <title>Kite Studio</title>
200 /services <title>Services | Kite Studio</title>
200 /services/websites <title>Websites | Kite Studio</title>
200 /contacts <title>Contacts | Kite Studio</title>
404 /services/unknown <title>404: This page could not be found.</title>npm run build: the title template works, and an unknown service returns 404 with proxy.tsCommon mistakes and exercises
- The root
app/layout.tsxdefines<html>and<body>, and<title>and<meta>come from the Metadata API, not by hand - Use
<Link>, not<a>, between your own pages; otherwise every click reloads the page paramsandsearchParamsare awaited (use()in a Client Component)usePathnameanduseRoutercome fromnext/navigationand live in files with"use client""use client"goes on small interactive components, not on a whole layouterror.tsxis a Client Component; an error in a layout is caught by the boundary one level up, in the root layout byglobal-error.tsx- Every indexable page has its own title;
title.defaultis set alongsidetitle.template - An unknown slug has been checked: its status and
noindexmatch expectations
Next on the track: Server Components and data fetching, meaning how to load data from an API or a database, cache it with Cache Components and change it with Server Actions. Styling this same project is covered in the Tailwind CSS lesson.
How this was verified5 · passed1 · partialchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.