Styling
What we’re building
Section titled “What we’re building”apps/web/app/globals.css grows from App Router & layout’s reset into a small design system — CSS custom properties for color, spacing, and typography, plus real styles for the header, nav, and footer that lesson already built. Alongside it, apps/web/components/Card.module.css and apps/web/components/PostCard.tsx — a presentational component that renders one post summary.
A hand-rolled set of CSS variables is enough to keep every later page and component visually consistent without adding a build-time dependency: this module’s job is to prove the App Router, the GraphQL data layer, and now a small styling system all fit together, not to teach a utility-class vocabulary. Plain CSS also stays portable — globals.css and a .module.css file work unchanged if this project ever moved off Next.js, where a Tailwind config or a CSS-in-JS runtime would not.
CSS Modules specifically solve a problem plain global CSS doesn’t: PostCard’s .card, .title, and .excerpt class names would collide with any other component that happened to reuse the same names in its own plain CSS file. Next.js compiles every *.module.css file’s class names into locally-scoped, hashed identifiers automatically — styles.card in PostCard.tsx can never leak into or clash with an unrelated .card class in some other file, with no naming convention (BEM, prefixing) required to enforce it by hand.
PostCard itself is a Server Component, for the same reason layout.tsx was in the previous lesson: it takes props and renders markup, with no state, no event handler, and no browser API anywhere in it, so it needs no 'use client' directive at all. Every list that renders it — starting with Public Blog’s home list in Module 9 — keeps that entire subtree on the server.
Pros & cons
Section titled “Pros & cons”Plain CSS + CSS Modules (what we’re using) vs. Tailwind CSS. Tailwind’s utility classes let you iterate on a layout directly in JSX without naming anything or leaving the file, and its config file enforces one consistent spacing/color scale project-wide almost by accident, since every value comes from the same tailwind.config rather than being retyped by hand. The cost is a real dependency (Tailwind itself, PostCSS, a config file) and a class-name vocabulary every contributor has to learn before a component reads clearly; className="flex items-center gap-4 rounded-md border p-4" says less at a glance than className={styles.card} does, even though the CSS behind styles.card is doing more. Plain CSS + Modules costs nothing to add — Next.js supports .module.css with zero configuration — and keeps a component’s markup readable, at the price of one extra file per component and a design system (the CSS variables below) that nothing stops a contributor from ignoring and hand-typing a new color anyway; Tailwind’s config makes that same drift harder to get away with.
…vs. CSS-in-JS (styled-components / Emotion). Co-locating styles with the component they belong to, and being able to interpolate a prop straight into a style, is CSS-in-JS’s real appeal — no separate file to open, no class name to invent. Almost every mainstream CSS-in-JS library injects those styles at runtime through a React context and a styling engine, which means the component using it needs 'use client', forcing exactly the client-side boundary this module has been deliberately keeping out of the public blog’s read path. That’s not a small compatibility footnote — it directly undoes the Server Component default App Router & layout established. A CSS Module has no runtime at all: the class-name mapping happens at build time, so PostCard stays a Server Component with real, static CSS, not a Client Component pretending to look like one.
Set it up
Section titled “Set it up”Update apps/web/app/globals.css:
:root { /* Colors */ --color-bg: #ffffff; --color-fg: #1a1a1a; --color-muted: #6b7280; --color-primary: #2563eb; --color-border: #e5e7eb;
/* Spacing */ --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem;
/* Typography */ --font-sans: system-ui, -apple-system, 'Segoe UI', sans-serif; --text-sm: 0.875rem; --text-base: 1rem; --text-lg: 1.25rem; --text-xl: 1.5rem; --line-height-body: 1.6;
/* Radius */ --radius-md: 0.5rem;}
* { box-sizing: border-box;}
body { margin: 0; font-family: var(--font-sans); font-size: var(--text-base); line-height: var(--line-height-body); color: var(--color-fg); background: var(--color-bg);}
a { color: inherit; text-decoration: none;}
header { border-bottom: 1px solid var(--color-border); padding: var(--space-4) var(--space-6);}
header nav { display: flex; gap: var(--space-4); align-items: center;}
header nav a:hover { color: var(--color-primary);}
main { padding: var(--space-6); max-width: 720px; margin: 0 auto;}
footer { border-top: 1px solid var(--color-border); padding: var(--space-4) var(--space-6); color: var(--color-muted); font-size: var(--text-sm);}- The
:rootblock is the whole design system — five colors, six spacing steps, and a small type scale. Every component built from here on reaches forvar(--color-border)orvar(--space-4)instead of retyping#e5e7ebor1rem; changing a value here changes it everywhere at once. header,header nav, andfooterstyle the exact elements App Router & layout already wrote intolayout.tsx— nothing about that file’s markup changes, only how it looks.
Create apps/web/components/Card.module.css:
.card { display: block; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-4); margin-bottom: var(--space-4);}
.card:hover { border-color: var(--color-primary);}
.cover { width: 100%; border-radius: var(--radius-md); margin-bottom: var(--space-3);}
.title { font-size: var(--text-lg); margin: 0 0 var(--space-2);}
.excerpt { color: var(--color-muted); margin: 0 0 var(--space-3);}
.meta { display: flex; gap: var(--space-3); font-size: var(--text-sm); color: var(--color-muted);}Create apps/web/components/PostCard.tsx:
import Link from 'next/link';import styles from './Card.module.css';import type { Post } from '@/lib/graphql';
interface PostCardProps { post: Pick<Post, 'title' | 'slug' | 'excerpt' | 'coverImage' | 'tags' | 'publishedAt'>;}
export function PostCard({ post }: PostCardProps) { return ( <Link href={`/posts/${post.slug}`} className={styles.card}> {post.coverImage && <img className={styles.cover} src={post.coverImage} alt="" />} <h2 className={styles.title}>{post.title}</h2> {post.excerpt && <p className={styles.excerpt}>{post.excerpt}</p>} <div className={styles.meta}> {post.publishedAt && <time dateTime={post.publishedAt}>{post.publishedAt}</time>} {post.tags.length > 0 && <span>{post.tags.join(', ')}</span>} </div> </Link> );}Pick<Post, ...>takes only the fields a card actually renders, straight from the sharedPosttype in GraphQL client & auth — a query that omitsbodyorstatuscan still satisfy this prop type exactly, since a card was never going to render either one.- No
'use client'—PostCardrenders<Link>, a Next.js component that happens to be a Client Component internally, but a Server Component is allowed to render a Client Component as a normal child; that’s the standard way the two interleave.PostCarditself stays server the whole time. - Plain
<img>, notnext/image, deliberately.next/imagerequires allow-listing every remote domain acoverImagemight come from innext.config.ts, and this course doesn’t know in advance which domain an author will paste in. A plain<img>works with anything; swapping innext/imageonce the set of allowed domains is decided is a drop-in change later, not a rewrite. alt=""is deliberately empty —Postdoesn’t model alt text yet. A named gap, not an oversight: a real deployment would add anPost.coverImageAltfield and thread it through here.
Verify
Section titled “Verify”Continuing the temporary apps/web/app/page.tsx from GraphQL client & auth, swap the <li> for the new component:
import { PostCard } from '@/components/PostCard';// ...inside the returned JSX:<ul> {posts.items.map((post) => ( <PostCard key={post.id} post={post} /> ))}</ul>cd apps/webnpm run devOpen http://localhost:3000 and open your browser’s element inspector on a card. The rendered class should look like Card_card__<hash> (the exact hash varies by build) rather than plain card — that’s CSS Modules’ scoping doing its job. Confirm the card’s border color matches --color-border from globals.css, and that it turns --color-primary blue on hover, proving the CSS variables are flowing from the design system into the component, not hard-coded twice.
globals.css now holds DevBlog’s whole design system — five color variables, a six-step spacing scale, and a small type scale — plus real styles for the header, nav, and footer layout.tsx already structured. PostCard is a Server Component reading a Picked subset of the shared Post type, styled through Card.module.css’s automatically-scoped class names, with the plain-<img>-over-next/image and empty-alt-text trade-offs both named rather than hidden. Public Blog is where this component, the layout, and gqlFetch all come together in the real home list, post page, and tag pages.
Next: Public Blog →