learning-website-nextjs1-6 Exercise 2: Layouts, Nested Layouts and the Navigation Components
============================================================================================
In the Next.js App Router a LAYOUT wraps the pages below it and is not drawn again when the visitor moves between them. That
suits a site frame. Two layouts are used:
- the ROOT layout (app/layout.tsx): the page frame, the bar of sites and the site's menu, which are the same on every page;
- a NESTED layout (app/[...path]/layout.tsx) for the content pages: it makes the breadcrumb. A layout is not redrawn when the
visitor moves, but it IS handed the address's segments, so it can work out the breadcrumb from them.
The components live in the shared UI package (the CSS is a CSS Module, as Chapter 5 decided: only the site's own components use
these classes):
Save as packages/ui/src/Navigation.tsx:
import Link from "next/link";
import type { Crumb, GlobalBarItem, Listing } from "@lw/navigation";
import type { Page } from "@lw/content";
import styles from "./Nav.module.css";
import { SiteMenu } from "./SiteMenu";
/** Layer 1: every site. The links go to OTHER hosts, so they are plain links, not client-side navigation. */
export function GlobalBar({ items }: { items: readonly GlobalBarItem[] }) {
return (
);
}
export { SiteMenu };
/** Layer 3: where you are. */
export function Breadcrumbs({ crumbs }: { crumbs: readonly Crumb[] }) {
if (crumbs.length <= 1) return null;
return (
);
}
/** Previous and next chapter of the same course. */
export function PageNav({ previous, next, href }: { previous: Page | null; next: Page | null; href: (page: Page) => string }) {
if (!previous && !next) return null;
return (
);
}
/** What is inside a folder. */
export function FolderListing({ listing, href }: { listing: Listing; href: (page: Page) => string }) {
return (
<>
{listing.folders.length > 0 ? (
<>
Folders
{listing.folders.map((folder) => (
{folder.label}{folder.count} pages
))}
>
) : null}
{listing.pages.length > 0 ? (
<>
{listing.courseName ? "Chapters" : "Pages"}
{listing.pages.map((page) =>
{page.title}
)}
>
) : null}
>
);
}
Save as packages/ui/src/SiteMenu.tsx:
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { MenuItem } from "@lw/navigation";
import styles from "./Nav.module.css";
/**
* Layer 2: this site's subjects. It is a client component only because the layout is not drawn again when the visitor moves
* between pages, so something has to ask the browser where we are now (usePathname) to mark the current subject.
*/
export function SiteMenu({ items, label }: { items: readonly MenuItem[]; label: string }) {
const pathname = usePathname();
if (items.length === 0) return null;
return (
);
}
Save as packages/ui/src/SiteLayout.tsx:
import type { ReactNode } from "react";
import type { GlobalBarItem, MenuItem } from "@lw/navigation";
import { SITES, type SiteName } from "@lw/sites";
import { GlobalBar, SiteMenu } from "./Navigation";
import styles from "./SiteLayout.module.css";
/** The frame of every page: a skip link, the bar of sites, the site's name and subjects, the page, and a footer. */
export function SiteLayout({ site, bar, menu, children }: {
site: SiteName; bar: readonly GlobalBarItem[]; menu: readonly MenuItem[]; children: ReactNode;
}) {
return (
<>
Skip to the content{SITES[site].title}{children}
>
);
}
Save as packages/ui/src/Nav.module.css:
/* The navigation's look. Only the site's own components use these classes, so they are CSS Module classes (renamed). */
.globalBar {
display: flex; gap: 1.1rem; padding: .4rem 1.25rem; overflow-x: auto; white-space: nowrap;
background: var(--bg); border-bottom: 1px solid var(--border); font-size: .8rem;
}
.globalBar a { color: var(--muted); text-decoration: none; }
.globalBar a:hover { color: var(--text); }
.globalBar a[aria-current="true"] { color: var(--accent); font-weight: 700; }
.menu { display: flex; flex-wrap: wrap; gap: .25rem 1rem; font-size: .88rem; }
.menu a { color: var(--text); text-decoration: none; padding: .15rem 0; border-bottom: 2px solid transparent; }
.menu a:hover, .menu a[aria-current="true"] { border-bottom-color: var(--accent); }
.breadcrumb ol { list-style: none; display: flex; flex-wrap: wrap; gap: .25rem; margin: 0 0 1rem; padding: 0; font-size: .82rem; color: var(--muted); }
.breadcrumb li + li::before { content: "\203A"; margin-right: .25rem; }
.breadcrumb a { color: var(--muted); }
.pageNav { display: flex; justify-content: space-between; gap: 1rem; margin-top: 2.5rem; border-top: 1px solid var(--border); padding-top: 1rem; }
.pageNav a { text-decoration: none; padding: .6rem .9rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); max-width: 48%; }
.pageNav a span { display: block; font-size: .7rem; text-transform: uppercase; letter-spacing: .08em; color: var(--accent); }
.next { margin-left: auto; text-align: right; }
.listingHeading { font-size: 1rem; color: var(--accent); text-transform: uppercase; letter-spacing: .08em; margin: 1.5rem 0 .5rem; }
.listing { padding-left: 1.4rem; }
.listing li { margin: .3rem 0; }
.count { color: var(--muted); font-size: .8rem; margin-left: .4rem; }
@media print {
.globalBar, .breadcrumb, .pageNav { display: none; }
}
Two choices worth explaining:
1. Links inside a site use Next's Link (a click loads only what is new, no page reload). The bar of sites uses plain links,
because they go to OTHER hosts.
2. The menu is a CLIENT component only because of one question: which subject is the visitor in now? The root layout is not
drawn again when the visitor moves, so something has to ask the browser (usePathname). Everything else here is a server
component and sends no script. The menu's list of items arrives as plain data.
The apps' own files (the same for the web development app, with its own site name; only languages has the content routes so far):
Save as apps/languages/lib/site.ts:
import { SiteContent } from "@lw/content";
import { NavIndex } from "@lw/navigation";
/** The content folder. There is no default: a build that quietly reads the wrong folder is worse than one that stops. */
export function contentRoot(): string {
const root = process.env["LW_CONTENT_ROOT"];
if (!root) throw new Error("Set LW_CONTENT_ROOT to the content folder before building.");
return root;
}
export const content = new SiteContent(contentRoot(), "languages");
export const nav = new NavIndex("languages", content.all());
Save as apps/languages/app/layout.tsx:
import type { ReactNode } from "react";
import "@lw/ui/styles.css";
import { globalBar } from "@lw/navigation";
import { environment } from "@lw/sites";
import { SiteLayout, htmlProps } from "@lw/ui";
import { HydrationMark } from "../components/HydrationMark";
import { nav } from "../lib/site";
export const metadata = { title: "languages | Philip's Learning Notes" };
export default function RootLayout({ children }: { children: ReactNode }) {
return (
{children}
);
}
Save as apps/languages/app/[...path]/layout.tsx:
import type { ReactNode } from "react";
import { Breadcrumbs } from "@lw/ui";
import { content, nav } from "../../lib/site";
/**
* A NESTED layout: it wraps every page and folder under the root layout. A layout is not drawn again when the visitor
* moves between pages that share it, but it is given the address's segments, so the breadcrumb can be made from them.
*/
export default async function ContentLayout({ children, params }: { children: ReactNode; params: Promise<{ path: string[] }> }) {
const { path } = await params;
const page = content.pageFor(path);
const folder = path.join("/");
const crumbs = page
? nav.breadcrumbs(page.path.includes("/") ? page.path.slice(0, page.path.lastIndexOf("/")) : "", page.title)
: nav.breadcrumbs(decodeURIComponent(folder));
return (
<>
{children}
>
);
}
Save as apps/languages/app/[...path]/page.tsx:
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { decodeSegment, pageHref, urlSegments } from "@lw/content";
import { folderLabel } from "@lw/navigation";
import { FolderListing, PageNav } from "@lw/ui";
import { Fragment } from "../../components/Fragment";
import { content, nav } from "../../lib/site";
// every page and folder of the site is made when the site is built; any other address is a 404
export const dynamicParams = false;
export function generateStaticParams() {
const addresses = new Set();
for (const page of content.all()) addresses.add(urlSegments(page).join("/"));
for (const folder of nav.folders()) addresses.add(folder); // a page of the same name wins (see ContentPage)
return [...addresses].map((address) => ({ path: address.split("/") }));
}
export async function generateMetadata({ params }: { params: Promise<{ path: string[] }> }): Promise {
const { path } = await params;
const page = content.pageFor(path);
if (page) return { title: `${page.title} | Philip's Learning Notes`, description: page.summary || undefined };
const folder = path.map(decodeSegment).join("/");
const listing = nav.listing(folder);
return listing ? { title: `${listing.courseName ?? folderLabel(folder.split("/").at(-1) ?? "")} | Philip's Learning Notes` } : {};
}
export default async function ContentPage({ params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const page = content.pageFor(path);
if (page) {
const html = content.fragmentFor(page);
const { previous, next } = nav.neighbours(page);
return (
<>
{/* a page with its own
shows it; every other page gets one made from its title */}
{/
>
);
}
Save as packages/content/src/site.ts:
import type { SiteName } from "@lw/sites";
import { prepareFragment } from "./fragment.ts";
import { loadPages, readContentFile } from "./scan.ts";
import type { Page } from "./types.ts";
/** Next may hand over percent-encoded segments for non-ASCII addresses; a segment that cannot be decoded is kept as it is. */
function decodeSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
/** The URL segments of a page: hungary/x/y.html is ["hungary", "x", "y"]. */
export function urlSegments(page: Page): string[] {
return page.path.slice(0, -".html".length).split("/");
}
/**
* One site's content, read once per build process. Any file that cannot be read stops the build: a site that is
* missing a page without saying so is worse than one that does not build.
*/
export class SiteContent {
readonly site: SiteName;
private readonly root: string;
private cache: Map | undefined;
constructor(root: string, site: SiteName) {
this.root = root;
this.site = site;
}
/** Every page of the site, by its URL ("hungary/x/y"). */
pages(): Map {
if (this.cache === undefined) {
const { pages, errors } = loadPages(this.root, { site: this.site });
if (errors.length > 0) {
throw new Error(`${errors.length} content files could not be read, for example ${errors[0]?.path}: ${errors[0]?.message}`);
}
this.cache = new Map(pages.map((page) => [urlSegments(page).join("/"), page]));
}
return this.cache;
}
all(): Page[] {
return [...this.pages().values()];
}
pageFor(segments: readonly string[]): Page | undefined {
return this.pages().get(segments.map(decodeSegment).join("/"));
}
/** The HTML to show for a page. Fragments are the site's OWN files: never pass visitor-supplied text through here. */
fragmentFor(page: Page): string {
return prepareFragment(readContentFile(this.root, page.path), page.path, page.site);
}
}
/** The address of a page on its site: /hungary/x/y (no trailing slash; whether to add one is a decision for Chapter 9). */
export function pageHref(page: Page): string {
return "/" + urlSegments(page).join("/");
}
export { decodeSegment };
One route, two kinds of address: a page, or a FOLDER (a listing of what is inside). generateStaticParams lists both, a page wins if
a page and a folder have the same address, and an address that is neither is a 404. The folder listing page is the site's index:
nothing was written to make it.
Built on the real content:
npm run build -w @lw/languages
Generating static pages using 8 workers (601/601) in 3.0s real 13.2 s
(561 pages, plus the folder listings, the home page, the 404 page and a few of Next's own)
and fetched from "next start":
global bar eight links, "Languages" marked current (aria-current="true"), the others to their own ports
site menu France, Germany, Hungary (marked current), Japan, Culture
breadcrumb Languages > Hungary > Hungarian Basic Conversation 3 > Work & Careers: Applying for a Job & Interviews
prev / next chapter 4 and chapter 6 of the course
/hungary a listing: Hungarian Alphabet 1 (20), Hungarian Basic 1 (12), Hungarian Basic 2 (12), Hungarian Basic 3 (12), ...
web development app its menu: Web Development, Web Platforms, Web Servers, Sidebar
KNOWN GAP: the web development app shows its menu, but it has no content routes yet, so those menu links (and its Sidebar link)
lead to a 404 there. They will work when that site's pages are built; only the languages site is complete.
WHY THIS WORKS AS AN ANSWER
---------------------------
The layers match what layouts are for: what is the same everywhere goes in the root layout, what depends on the address goes in a
nested layout, and the one thing that needs the browser is the one client component.