Components
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.
// 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>
)
}
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.
<p>Text</p>
<h1>Title</h1>
<p>Text</p>
</>
<>…</> or a real element. Every component must return one root.class is a reserved word in JS. Use className for CSS classes in JSX.className — for is a JS keyword.<p>{count * 2}</p>
<div className={isActive ? "on" : "off"}>
{} is evaluated. Strings, numbers, function calls, ternaries all work.<input type="text">
<input type="text" />
.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.
// 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} />
// ── 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.
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
useStateoruseReducer - You use
useEffectoruseRef - You attach event handlers (
onClick,onChange) - You use browser APIs (
window,localStorage) - You use third-party client-only libraries (rich-text editors, charts)
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.
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>
)
}
'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.
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
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.
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>
)
}
@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.
// 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>
)
}
(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.
// ── 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>
)
// ── && 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} />
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.
// 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.
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.