Components

Chapter 3 — React Components & Layout | Next.js Course
Next.js Rebuild Course Chapter 3 of 10

React Components & Layout

Build the site's permanent shell — the navigation bar, breadcrumbs, and footer — as reusable Server Components. Learn JSX syntax, how props work in TypeScript, and the golden rule of when to reach for 'use client'.

🏁

Chapter milestone: osztromok.com has a real top navigation bar with subject links, a breadcrumb component, and a footer — all wired into the root layout.tsx so they appear on every page automatically.

What is a React component?

A React component is simply a TypeScript function that returns JSX. JSX is HTML-like syntax inside JavaScript/TypeScript — it compiles to React.createElement() calls under the hood, but you write it like markup. That's all there is to it at the base level.

The simplest possible component TypeScript
// A component is a function that returns JSX
export default function Greeting() {
  return <h1>Hello, osztromok.com</h1>
}

// Use it inside another component like an HTML tag:
export default function HomePage() {
  return (
    <main>
      <Greeting />   {/* Self-closing if no children */}
      <p>Welcome to my learning site.</p>
    </main>
  )
}
Component naming convention: always start with a capital letter — Nav, Footer, SubjectCard. Lowercase names (nav, footer) are treated as native HTML elements, not components. This is how React tells them apart.

JSX — where it differs from HTML

JSX looks like HTML but has a handful of rules that catch everyone coming from plain HTML. TypeScript will point these out as errors, which makes them easy to fix.

❌ Multiple root elements
<h1>Title</h1>
<p>Text</p>
<>
<h1>Title</h1>
<p>Text</p>
</>
Wrap in a Fragment <>…</> or a real element. Every component must return one root.
❌ class instead of className
<div class="nav">…</div>
<div className="nav">…</div>
class is a reserved word in JS. Use className for CSS classes in JSX.
❌ for instead of htmlFor
<label for="name">Name</label>
<label htmlFor="name">Name</label>
Same reason as classNamefor is a JS keyword.
✓ Expressions in curly braces
<h1>{subject.name}</h1>
<p>{count * 2}</p>
<div className={isActive ? "on" : "off"}>
Any JS expression inside {} is evaluated. Strings, numbers, function calls, ternaries all work.
✓ Self-closing tags required
<img src="…">
<input type="text">
<img src="…" />
<input type="text" />
All void elements must be explicitly self-closed in JSX.
✓ Inline styles are objects
<div style="color: red">
<div style={{ color: 'red', fontSize: '14px' }}>
Style is a JS object, not a string. camelCase property names. Double braces: outer for expression, inner for object literal.
TypeScript + JSX = TSX. Files with JSX must use the .tsx extension (not .ts). Pure utility files with no JSX can use .ts. If you forget and get a "JSX not allowed" error, just rename the file.

Passing data into components

Props are how you pass data into a component — like constructor arguments in Java. In TypeScript we define an interface for each component's props, which gives us autocomplete and compile-time errors for missing or wrong data.

components/SubjectCard.tsx — a typed component with props TypeScript · Server Component
// 1. Define what data this component expects
interface SubjectCardProps {
  name:        string
  slug:        string
  description: string | null   // can be null (matches DB column)
  pageCount:   number
}

// 2. Destructure props directly in the function signature
export default function SubjectCard({ name, slug, description, pageCount }: SubjectCardProps) {
  return (
    <a
      href={`/${slug}`}
      className="block bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-cyan-700 transition-colors"
    >
      <h2 className="text-lg font-bold text-white">{name}</h2>

      {description && (   {/* conditional rendering */}
        <p className="text-gray-400 text-sm mt-1">{description}</p>
      )}

      <span className="text-xs text-gray-600 mt-2 block">
        {pageCount} pages
      </span>
    </a>
  )
}

// 3. Use it — TypeScript enforces that all required props are provided
// <SubjectCard name="Japanese" slug="japan" description={null} pageCount={42} />
Props patterns — defaults, optional, children TypeScript
// ── Optional prop with default value ─────────────────────────
interface BadgeProps {
  text:    string
  variant?: 'default' | 'accent' | 'muted'  // ? = optional
}

function Badge({ text, variant = 'default' }: BadgeProps) {
  const colour = variant === 'accent' ? 'text-cyan-400' : 'text-gray-400'
  return <span className={`text-xs ${colour}`}>{text}</span>
}

// ── Children prop — wrap arbitrary content ────────────────────
interface CardProps {
  title:    string
  children: React.ReactNode  // anything renderable: string, JSX, array…
}

function Card({ title, children }: CardProps) {
  return (
    <div className="border border-gray-800 rounded-lg p-4">
      <h3 className="font-bold mb-2">{title}</h3>
      {children}
    </div>
  )
}

// Usage: content between the tags becomes "children"
// <Card title="Japanese">
//   <p>Some content here</p>
// </Card>

The component boundary — a mental model

This is the concept that takes the longest to fully internalise in the App Router. Think of it as a boundary between two worlds — the server renders first, then passes the result to the client.

Component tree for a content page
Server RootLayout app/layout.tsx
contains →
Server Nav components/Nav.tsx
Client MobileMenu components/MobileMenu.tsx
Server ContentPage app/[subject]/…/page.tsx
Key insight: Server Components can import and render Client Components — but not the reverse. Client Components cannot import Server Components (they'd lose their server-only capabilities). The children prop is the bridge — a Server Component can pass rendered JSX as children to a Client Component.

Stay as Server Component when…

  • You need to query the database
  • You access environment variables / secrets
  • You do file system reads
  • You do heavy computation you don't want in the browser bundle
  • There's no interactivity — it just displays data

Add 'use client' only when…

  • You use useState or useReducer
  • You use useEffect or useRef
  • You attach event handlers (onClick, onChange)
  • You use browser APIs (window, localStorage)
  • You use third-party client-only libraries (rich-text editors, charts)
Push 'use client' down the tree. If only the hamburger menu button needs interactivity, only MobileMenu.tsx gets 'use client'. The parent Nav.tsx stays as a Server Component and does the DB query for the nav links — no prop drilling, no redundant client-side fetching.

The Nav component

The navigation bar fetches subjects from the database (placeholder for now, wired to Prisma in Chapter 5) and renders them as links. It's a Server Component — the subject list is fetched server-side and rendered to HTML. The mobile toggle is a separate Client Component nested inside it.

components/Nav.tsx TypeScript · Server Component
import Link from 'next/link'
// import { prisma } from '@/lib/prisma'   ← added in Chapter 5

// Placeholder data until Prisma is set up
const PLACEHOLDER_SUBJECTS = [
  { id: 1, name: 'Japanese',  slug: 'japan'   },
  { id: 2, name: 'Hungarian', slug: 'hungarian' },
  { id: 3, name: 'French',    slug: 'french'  },
  { id: 4, name: 'Python',    slug: 'python'  },
  { id: 5, name: 'Bash',      slug: 'bash'    },
]

export default async function Nav() {
  // Chapter 5: replace placeholder with real DB query
  // const subjects = await prisma.subject.findMany({ orderBy: { position: 'asc' } })
  const subjects = PLACEHOLDER_SUBJECTS

  return (
    <nav className="bg-gray-950 border-b border-gray-800 sticky top-0 z-50">
      <div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">

        {/* Site logo / name */}
        <Link
          href="/"
          className="font-bold text-white tracking-tight hover:text-cyan-400 transition-colors"
        >
          osztromok<span className="text-cyan-400">.com</span>
        </Link>

        {/* Desktop subject links */}
        <ul className="hidden md:flex items-center gap-1">
          {subjects.map((s) => (
            <li key={s.id}>
              <Link
                href={`/${s.slug}`}
                className="px-3 py-1.5 text-sm text-gray-400 hover:text-white hover:bg-gray-800 rounded-md transition-colors"
              >
                {s.name}
              </Link>
            </li>
          ))}
        </ul>

        {/* Mobile toggle — Client Component (handles click state) */}
        <MobileMenuButton subjects={subjects} />
      </div>
    </nav>
  )
}
components/MobileMenuButton.tsx TypeScript · Client Component — needs useState
'use client'

import { useState } from 'react'
import Link from 'next/link'

interface Subject {
  id:   number
  name: string
  slug: string
}

export default function MobileMenuButton({ subjects }: { subjects: Subject[] }) {
  const [open, setOpen] = useState(false)

  return (
    <div className="md:hidden">
      <button
        onClick={() => setOpen(!open)}
        className="text-gray-400 hover:text-white p-2"
        aria-label="Toggle menu"
      >
        {open ? '✕' : '☰'}
      </button>

      {open && (
        <div className="absolute top-14 left-0 right-0 bg-gray-950 border-b border-gray-800 px-6 py-4">
          <ul className="flex flex-col gap-2">
            {subjects.map((s) => (
              <li key={s.id}>
                <Link
                  href={`/${s.slug}`}
                  className="text-gray-300 hover:text-white text-sm"
                  onClick={() => setOpen(false)}
                >
                  {s.name}
                </Link>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  )
}

The Breadcrumb component

Breadcrumbs show the current path — Home → Japanese → Kanji → Stroke Order. This component accepts an array of { label, href } objects and renders them with separators. It's purely presentational — no data fetching — so it stays a Server Component.

components/Breadcrumb.tsx TypeScript · Server Component
import Link from 'next/link'

interface Crumb {
  label: string
  href:  string
}

interface BreadcrumbProps {
  crumbs: Crumb[]
}

export default function Breadcrumb({ crumbs }: BreadcrumbProps) {
  return (
    <nav
      aria-label="breadcrumb"
      className="text-sm text-gray-500 mb-6"
    >
      <ol className="flex flex-wrap items-center gap-1">

        {/* Home is always the first crumb */}
        <li>
          <Link href="/" className="hover:text-white transition-colors">
            Home
          </Link>
        </li>

        {crumbs.map((crumb, index) => {
          const isLast = index === crumbs.length - 1
          return (
            <li key={crumb.href} className="flex items-center gap-1">
              <span aria-hidden="true">/</span>
              {isLast ? (
                {/* Last crumb — not a link, shows current page */}
                <span className="text-gray-300" aria-current="page">
                  {crumb.label}
                </span>
              ) : (
                <Link href={crumb.href} className="hover:text-white transition-colors">
                  {crumb.label}
                </Link>
              )}
            </li>
          )
        })}
      </ol>
    </nav>
  )
}

// Usage in a content page:
// <Breadcrumb crumbs={[
//   { label: 'Japanese', href: '/japan' },
//   { label: 'Kanji',    href: '/japan/kanji' },
//   { label: 'Stroke Order', href: '/japan/kanji/stroke-order' },
// ]} />

The Footer component

components/Footer.tsx TypeScript · Server Component
export default function Footer() {
  const year = new Date().getFullYear()

  return (
    <footer className="border-t border-gray-800 mt-16 py-8 text-center">
      <p className="text-gray-600 text-sm">
        © {year} osztromok.com
      </p>
    </footer>
  )
}
new Date().getFullYear() runs on the server at request time (or build time if the page is statically generated). The year is baked into the HTML — no JavaScript needed in the browser to display it. This is a small but concrete example of the Server Component performance benefit.

Updating the root layout

Now wire Nav and Footer into app/layout.tsx so they appear on every page. Also set up the globals.css with the dark background colour so the whole site has the right base style.

app/layout.tsx — updated with Nav and Footer TypeScript · Server Component
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Nav    from '@/components/Nav'
import Footer from '@/components/Footer'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: {
    template: '%s | osztromok.com',
    default:  'osztromok.com',
  },
  description: 'Philip\'s learning site — Japanese, Hungarian, French, Python, Bash',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={`${inter.className} bg-gray-950 text-gray-100 min-h-screen flex flex-col`}>
        <Nav />
        <main className="flex-1 max-w-5xl mx-auto w-full px-6 py-8">
          {children}
        </main>
        <Footer />
      </body>
    </html>
  )
}
app/globals.css — base dark-theme styles CSS
@tailwind base;
@tailwind components;
@tailwind utilities;

/* Base dark background — Tailwind's gray-950 = #030712 */
:root {
  --foreground: 230 236 243;
  --background: 3 7 18;
}

body {
  color: rgb(var(--foreground));
  background: rgb(var(--background));
}

/* Smooth scrolling */
html {
  scroll-behavior: smooth;
}

/* Remove default list styles for nav elements */
nav ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

Nested layouts — the admin shell

The (admin) route group gets its own layout.tsx — a sidebar layout that wraps only admin pages. The root layout still applies too, so admin pages have: root layout (nav + footer) → admin layout (sidebar) → page content.

app/(admin)/layout.tsx — admin shell (placeholder) TypeScript · Server Component
// This layout wraps every /admin/* route
// Auth protection is added in Chapter 8

export default function AdminLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="flex gap-8">

      {/* Admin sidebar */}
      <aside className="w-48 shrink-0">
        <nav className="flex flex-col gap-1">
          {[
            { href: '/admin',           label: 'Dashboard'  },
            { href: '/admin/subjects',  label: 'Subjects'   },
            { href: '/admin/subtopics', label: 'Subtopics'  },
            { href: '/admin/pages',     label: 'Pages'      },
          ].map((item) => (
            <a
              key={item.href}
              href={item.href}
              className="px-3 py-2 text-sm text-gray-400 hover:text-white hover:bg-gray-800 rounded-md"
            >
              {item.label}
            </a>
          ))}
        </nav>
      </aside>

      {/* Page content */}
      <div className="flex-1 min-w-0">
        {children}
      </div>

    </div>
  )
}
Why use a route group for admin? The (admin) folder keeps all admin routes under one layout without adding /admin to every URL twice. URLs are still /admin/subjects — the route group folder name is invisible to the router. It's purely an organisation tool.

Rendering lists and conditionals

Two patterns you'll use in almost every component — rendering arrays of items and showing content conditionally.

Rendering lists — always provide a key prop TypeScript
// ── Basic list ────────────────────────────────────────────────
const subjects = [{ id: 1, name: 'Japanese', slug: 'japan' }, /* … */]

return (
  <ul>
    {subjects.map((subject) => (
      <li key={subject.id}>           {/* key must be unique and stable */}
        <Link href={`/${subject.slug}`}>{subject.name}</Link>
      </li>
    ))}
  </ul>
)

// ── Empty state ───────────────────────────────────────────────
return (
  <div>
    {subjects.length === 0 ? (
      <p className="text-gray-500">No subjects found.</p>
    ) : (
      subjects.map((s) => <SubjectCard key={s.id} {...s} />)
    )}
  </div>
)
Conditional rendering patterns TypeScript
// ── && short-circuit — render only when truthy ────────────────
{description && <p className="text-gray-400">{description}</p>}

// ⚠️  Gotcha: 0 && <p> renders "0", not nothing!
// Use !! to convert to boolean if the value might be 0:
{!!count && <p>Count: {count}</p>}
// Or use an explicit comparison:
{count > 0 && <p>Count: {count}</p>}

// ── Ternary — show one thing or another ───────────────────────
{isAdmin
  ? <AdminBadge />
  : <span className="text-gray-500">Visitor</span>
}

// ── Early return — clean for complex conditions ───────────────
if (!subject) return null   // renders nothing
if (loading)  return <Spinner />
return <SubjectCard {...subject} />
The 0 && gotcha is one of the most common React bugs. If count is 0, {count && <p>} renders a literal 0 on the page because 0 is falsy but still a renderable value. Always use an explicit boolean condition when the left side might be a number.

Spread props and extending HTML elements

A common pattern is building a wrapper component that extends a native HTML element — adding default styles but still accepting all the native element's props.

components/Button.tsx — extending an HTML element TypeScript
// Extend native button props — gets all HTML button attributes for free
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'danger' | 'ghost'
}

export default function Button({
  variant = 'primary',
  className = '',
  children,
  ...props           // captures onClick, disabled, type, aria-*, etc.
}: ButtonProps) {
  const base = 'px-4 py-2 rounded-md text-sm font-medium transition-colors'
  const variants = {
    primary: 'bg-cyan-600 hover:bg-cyan-500 text-white',
    danger:  'bg-red-700 hover:bg-red-600 text-white',
    ghost:   'text-gray-400 hover:text-white hover:bg-gray-800',
  }

  return (
    <button
      className={`${base} ${variants[variant]} ${className}`}
      {...props}       {/* spread remaining HTML props */}
    >
      {children}
    </button>
  )
}

// Usage — all native button attributes work automatically:
// <Button variant="danger" onClick={handleDelete} disabled={loading}>
//   Delete Subject
// </Button>

What the site should look like now

Navigation bar visible on all pages

Visit http://localhost:3000/, /japan, and /japan/kanji/stroke-order — the nav bar with "osztromok.com" and the subject links appears on every page without any extra code in the page files.

Footer appears on all pages

The footer with copyright year is visible below the content, pushed to the bottom of the viewport even on short pages (thanks to flex flex-col on the body and flex-1 on main).

Dark background applied globally

The bg-gray-950 class on <body> in the root layout gives the whole site the dark background — no page needs to set its own background colour.

Mobile menu toggles open and closed

On a narrow viewport the desktop links are hidden and the hamburger button appears. Clicking it opens the mobile menu; clicking a link or clicking again closes it.

Admin pages use the sidebar layout

Visit http://localhost:3000/admin — the admin sidebar is visible alongside the page content. Other non-admin pages are unaffected.

TypeScript check: run npx tsc --noEmit — zero errors. If you see a complaint about a missing prop, check that every component that uses SubjectCard or Breadcrumb is passing all required fields. The compiler message will tell you exactly which prop is missing and in which file.