React Components & Layout

Website Rebuild with Next.js

Chapter 4 · React Components & Layout

This chapter builds the real layout Chapter 3's own database-backed route renders through — a persistent site shell, a breadcrumb trail, and, like every sibling course before it, a way to render stored HTML content as real markup rather than escaped text.

Nested Layouts: A Genuine Routing Primitive

// app/layout.tsx export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <header><h1>My Learning Blog</h1></header> {children} </body> </html> ); }
Not a manually-included partial — a first-class routing feature
Every sibling framework's own "layout" is a template a page explicitly extends or includes (Django's {% extends %}, Rails' own layout convention, Astro's imported <Layout> component). Next.js's app/layout.tsx is different in kind: it's wired directly into the routing tree itself — every route under app/ automatically renders inside it, with no per-page import or extends statement needed anywhere. Nested folders can each add their own layout.tsx, wrapping progressively, which is genuinely useful for a site with real hierarchical structure like this one — though this course doesn't need that depth of nesting to work correctly.

The Breadcrumb

// components/PageShell.tsx import { prisma } from '@/lib/prisma'; import type { Page } from '@prisma/client'; async function getBreadcrumb(pageId: number): Promise<Page[]> { const crumbs: Page[] = []; let currentId: number | null = pageId; while (currentId) { const current = await prisma.page.findUnique({ where: { id: currentId } }); if (!current) break; crumbs.unshift(current); currentId = current.parentId; } return crumbs; } export default async function PageShell({ page }: { page: Page }) { const breadcrumb = await getBreadcrumb(page.id); return ( <> <nav> {breadcrumb.map((crumb) => ( <a key={crumb.id} href={`/${crumb.fullPath}`}>{crumb.title}</a> ))} </nav> <h1>{page.title}</h1> <div dangerouslySetInnerHTML={{ __html: page.body ?? '' }} /> </> ); }
The same N+1 pattern, planted deliberately a seventh time
getBreadcrumb walks current.parentId one row at a time inside a while loop — one database query per ancestor level, exactly the pattern every one of this course's own five already-complete siblings built into its own breadcrumb code on purpose. It's left as written here, to be caught and fixed in Chapter 6, once this course's own relational-query tooling is actually introduced.

Wiring It Into the Route

// app/[...path]/page.tsx — updated from Chapter 3 import { prisma } from '@/lib/prisma'; import { notFound } from 'next/navigation'; import PageShell from '@/components/PageShell'; export default async function CatchAllPage({ params }: PageProps<'/[...path]'>) { const { path } = await params; const fullPath = path.join('/'); const page = await prisma.page.findUnique({ where: { fullPath } }); if (!page) { notFound(); } return <PageShell page={page} />; }

dangerouslySetInnerHTML: React's Own Answer

The same job, six different syntaxes
dangerouslySetInnerHTML={{ __html: page.body }} sets an element's HTML content directly, bypassing React's own default auto-escaping — the exact same job every sibling framework does in its own way.
FrameworkMechanism
Next.js (React)<div dangerouslySetInnerHTML={{ __html: page.body }} /> — a prop, deliberately named to signal danger
Django{{ page.body|safe }} — a template filter
Laravel{!! $page->body !!} — a Blade output-tag variant
Rails<%== @page.body %> — an ERB output-tag variant
Astro<div set:html={page.body} /> — a template directive on the element
Express (EJS)<%- page.body %> — an EJS output-tag variant, same family as Rails'
Only for content the admin controls
dangerouslySetInnerHTML is only safe for content this project's own admin interface writes — never for rendering arbitrary user input directly, since it bypasses the exact escaping that protects against injected markup. React's own naming choice — the only one of the six that puts a warning directly in the API's own name — is a small, genuine design signal the other five don't share.

Hands-On Exercises

Exercise 1

Build PageShell.tsx rendering a page's title and body via dangerouslySetInnerHTML, and confirm real stored HTML (e.g. a <strong> tag inside body) renders as actual formatted HTML, not escaped text.

📄 View solution
Exercise 2

Build getBreadcrumb() and confirm it produces the correct, correctly-ordered ancestor chain for a real page stored at least three levels deep.

📄 View solution
Exercise 3

Explain, in your own words, why app/layout.tsx doesn't need to be imported or extended by app/[...path]/page.tsx the way Django's {% extends %} or Astro's imported <Layout> component both require.

📄 View solution

Chapter 4 Quick Reference

  • app/layout.tsx — a real routing primitive; every route beneath it renders inside it automatically, no import needed
  • getBreadcrumb — deliberately inefficient (one query per ancestor level); left unfixed until Chapter 6
  • dangerouslySetInnerHTML — React's own answer to rendering trusted stored HTML; only ever safe for admin-written content
  • Next chapter: Styling — Dark Theme