Build log: shipping this site with Next.js 16, Tailwind v4, and a monochrome design system
How cjjutba.com is put together — a no-radius monochrome design system, Next.js 16's sharp edges, two modals that share one pattern, and a content-as-data model with zero MDX. The post you're reading is the page it describes.
A portfolio has one job: sell outcomes, not a résumé. So before I wrote a line of code I picked the constraints I wanted to live inside — a monochrome palette, square corners, three typefaces, and a content model where every page is typed data rather than hand-written markup. Constraints chosen up front are constraints you don't relitigate at 2am.
This is the build log for the site you're reading right now. It's deliberately meta: the floating table-of-contents island you can see at the bottom of the screen, the [Reach out](/) sheet, the OG image on this post's social card — they were all built as part of this feature, and I'll point at the actual files as I go. Nothing here is invented; if I write that a token is #0c0c0c, it's because that's the value in src/app/globals.css.
The stack, in one breath: Next.js 16 (App Router, Turbopack) on React 19, Tailwind v4, shadcn components built on Base UI, next-themes for light/dark, and Vitest plus Playwright/axe holding the line. Let's start with the part you notice first — the look.
1. A monochrome design system
The whole site runs on two colors and a hairline. There is no brand blue, no accent gradient, no second hue waiting in the wings. The palette is --ink (near-black) on --paper (warm off-white), with a single muted --ink-2 for secondary text and a translucent --line for borders. Dark mode just swaps ink and paper — the structure is identical, so I never maintain two designs.
These are the brand tokens (abbreviated) from globals.css. Tailwind v4 picks them up through @theme inline, which is what lets me write bg-paper or text-ink-2 in JSX and have it resolve to a CSS variable that flips with the theme:
:root { /* Brand monochrome raw tokens (light) */ --paper: #f1f0ea; --ink: #0c0c0c; --ink-2: #56564f; --faint: #6b6b62; /* passes WCAG AA 4.5:1 on --paper */ --line: rgba(12, 12, 12, 0.16); --line-2: rgba(12, 12, 12, 0.32); --inv-bg: #0c0c0c; --inv-ink: #f1f0ea; --ease-brand: cubic-bezier(0.22, 1, 0.36, 1); --radius: 0px;} .dark { --paper: #0c0c0c; --ink: #edece4; --ink-2: #97968d; --line: rgba(237, 236, 228, 0.16); /* …plus --faint, --line-2, --inv-bg, --inv-ink and shadcn overrides… */}Every shadcn radius scale (--radius-sm … --radius-4xl) is derived as a multiple of --radius. Setting the base to 0px means every component — buttons, cards, inputs, the modal sheets — renders with square corners for free, with no per-component overrides. One variable enforces the entire aesthetic.
Type does the work color usually does. Three families, each with a job: Instrument Serif for display and headings (it's the editorial voice — note the section title above), Archivo for body and UI, and JetBrains Mono for labels, code, and the small uppercase eyebrows. They're loaded with next/font/google and exposed as CSS variables, so there's no layout shift and no FOUT.
import { Archivo, Instrument_Serif, JetBrains_Mono } from "next/font/google"; export const instrumentSerif = Instrument_Serif({ subsets: ["latin"], weight: "400", style: ["normal", "italic"], variable: "--font-instrument-serif", display: "swap",});The variables get attached to <html> in the root layout, and @theme inline maps --font-display, --font-sans, and --font-mono onto them. After that, every font-display / font-mono class in the app just works. Selection color is inverted ink-on-paper, focus rings are a 2px solid --ink — the monochrome rule even governs the states you only see when you interact.
2. The stack, and its sharp edges
Choosing a modern stack means choosing its rough patches too. The two that actually changed how I write code were Next.js 16's async dynamic APIs and the move from Radix to Base UI under shadcn. Both are worth understanding before you copy a pattern from older tutorials.
2.1 Next.js 16: App Router, Turbopack, and async params
The site is App Router end to end, built and dev-served with Turbopack. The headline gotcha in Next 16 is that the dynamic request APIs are now Promises — params, searchParams, cookies(), headers() all have to be awaited. Muscle memory from earlier Next versions will hand you a Promise where you expect an object and the types won't let you forget it.
export default async function BlogPostPage({ params,}: { params: Promise<{ slug: string }>;}) { const { slug } = await params; const post = getPost(slug); if (!post) notFound(); // …build the TOC, reading time, related posts}The same Promise<{ slug: string }> shape shows up in generateMetadata and in the per-post opengraph-image.tsx. Static params are still synchronous via generateStaticParams, so every blog route and its OG image are prerendered at build time — await only enters once you're inside the request.
The repo's AGENTS.md opens with a warning I took literally: "This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices."
For an AI-native workflow that line is load-bearing: it tells every agent (and me) to check the vendored docs instead of trusting a model's stale memory of Next.js. Async params is exactly the kind of change that bites you if you skip it.
2.2 Base UI under shadcn, and theming with next-themes
shadcn components here are the base-nova style — which means the primitives underneath are Base UI, not Radix. The components.json declares "style": "base-nova", and the generated components import from @base-ui/react: the button wraps @base-ui/react/button, the input wraps @base-ui/react/input, and so on. The component API you copy/paste is familiar, but the imports and some prop contracts differ from the Radix-era shadcn you may have used.
{ "$schema": "https://ui.shadcn.com/schema.json", "style": "base-nova", "rsc": true, "tsx": true, "tailwind": { "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true }, "iconLibrary": "lucide"}Theming is next-themes with the class strategy: attribute="class", defaultTheme="system", enableSystem. It toggles a .dark class on <html>, and a matching @custom-variant dark (&:is(.dark *)) in globals.css is what makes Tailwind's dark: utilities respond to that class. Because the brand tokens already redefine themselves under .dark, flipping the class is the entire dark-mode implementation — no component knows it's in dark mode.
| Dependency | Version | Role |
|---|---|---|
| next | 16.2.6 | App Router + Turbopack |
| react / react-dom | 19.2.4 | UI runtime |
| tailwindcss | v4 | Styling via @theme inline |
| shadcn | 4.8.2 | Components (base-nova style) |
| @base-ui/react | 1.5.0 | Headless primitives (not Radix) |
| next-themes | 0.4.6 | Light/dark via .dark class |
| motion | 12.x | Reveal + micro-interactions |
3. One modal, two patterns
The site has two overlay surfaces — the reach-out sheet and the table-of-contents island — and they intentionally look and behave like siblings. Both slide up from the bottom edge, both use the --ease-brand curve, both lock body scroll and move focus on open, both respect prefers-reduced-motion. Building the second one was mostly a matter of mirroring the first.
3.1 The reach-out sheet
"Reach out" is the primary call to action, available from anywhere via a tiny React context. The provider is mounted once in the root layout and exposes isOpen, open(), and close(); any component — a nav button, a footer link, a CTA at the end of a case study — can call useReachOut().open() without prop-drilling.
type ReachOutCtx = { isOpen: boolean; open: () => void; close: () => void };const Ctx = createContext<ReachOutCtx | null>(null); export function ReachOutProvider({ children }: { children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false); const open = useCallback(() => setIsOpen(true), []); const close = useCallback(() => setIsOpen(false), []); return <Ctx.Provider value={{ isOpen, open, close }}>{children}</Ctx.Provider>;}The sheet itself is rendered once near the root, beside the nav and footer, so it overlays the whole app. It's the canonical bottom-slide pattern the rest of the site borrows from.
3.2 The TOC island you're using right now
Look at the bottom-center of this page: that pill with a progress ring and the current section name is the TOC island, and it exists because of this post. It derives entirely from the post's own data — buildToc(post.sections) walks the typed sections/subsections array and produces numbered items ("1", "1.1", "2", …). The content model literally generates its own navigation.
export function buildToc(sections: Section[]): TocItem[] { const items: TocItem[] = []; sections.forEach((s, i) => { const n = String(i + 1); items.push({ id: s.id, label: s.heading, level: 1, number: n }); s.subsections?.forEach((ss, j) => { items.push({ id: ss.id, label: ss.heading, level: 2, number: `${n}.${j + 1}` }); }); }); return items;}The pill is a deliberate echo of the reach-out sheet. Tapping it opens a TocModal that slides up from the bottom with the same --ease-brand transition, the same backdrop, the same scroll-lock and focus-on-open behavior, and a pull-to-close handle. The TocModal moves focus into the sheet on open and marks it inert when closed, but does not run a full focus-trap loop. The two overlays even cooperate: the island reads useReachOut().isOpen and hides itself whenever the reach-out sheet is open, so you never see two bottom sheets fighting for the same edge.
A small scroll-spy hook drives the live state. An IntersectionObserver (with a -80px 0px -65% root margin so a heading activates near the top of the viewport) tracks the top-most visible section, and a scroll listener computes overall progress 0..1 for the SVG ring. The island only appears once you've scrolled past ~2%, so it stays out of the way at the top of the page.
Because both surfaces share the bottom-slide vocabulary, the second one cost almost nothing to design. When your overlays are consistent, users learn the gesture once — and you write the scroll-lock and focus-management logic once.
4. Content as data
Nothing on this site is hand-written markup. Projects, case studies, and blog posts are all typed TypeScript objects, and the components are pure renderers over that data. The case studies live in src/content/projects.ts as a Project[], each with structured sections, metrics, highlights, and screenshots. The blog mirrors that shape exactly.
A blog post is a BlogPost: an intro array plus sections, where every section holds an array of typed Blocks. The block union is the whole content vocabulary — this post is built entirely from these eight types:
export type Block = | { type: "paragraph"; text: string } | { type: "code"; lang?: string; filename?: string; code: string; highlight?: number[]; added?: number[]; removed?: number[] } | { type: "callout"; variant: "note" | "warn" | "tip"; label: string; body: string[] } | { type: "list"; ordered?: boolean; items: string[] } | { type: "table"; head: string[]; rows: string[][] } | { type: "figure"; label: string; src?: string; alt?: string; caption?: string; ratio?: string } | { type: "quote"; text: string; cite?: string };Rich text lives inside the strings as a tiny light-markdown subset — ` code becomes an inline chip, text becomes a link, bold becomes strong — rendered by a 50-line tokenizer in src/lib/inline.tsx`. That's the entire authoring surface. The deliberate non-choice here is MDX:
- Type safety over freeform. A
BlogPosteither satisfies the type or fails the build. You can't ship a post with a missing heading or a malformed callout — the compiler catches it before Vercel does. - The data is queryable. The blog index, the sitemap, the OG images, and the "Keep reading" related-posts logic all read the same array. No frontmatter parsing, no content layer to keep in sync.
- One rendering path. Every post looks consistent because there's exactly one set of block renderers. There's no escape hatch for arbitrary JSX, which is a feature, not a limitation.
- Reading time is derived, not declared.
readingTime()walks the blocks and counts words at 220 wpm, so the estimate is always honest.
Adding a post is: create src/content/posts/<slug>.ts, export a BlogPost, register it in the allPosts array. The index page, route, sitemap entry, and OG card all light up automatically. (This very file is src/content/posts/building-this-site.ts.)
5. Shipping it
The site deploys to Vercel, which is the path of least resistance for App Router and Turbopack. Most of the SEO and social surface is generated at build time rather than maintained by hand.
Open Graph images are real, dynamic PNGs rendered with next/og's ImageResponse — a JSX-to-image renderer running in the edge runtime. There's a site-wide card at src/app/opengraph-image.tsx and a per-post one at src/app/blog/[slug]/opengraph-image.tsx that pulls the post title straight from getPost(slug). Same monochrome palette as the site: ink-on-paper, JetBrains-style mono eyebrow, serif headline. No design tool, no exporting screenshots.
The src/app/sitemap.ts route builds the sitemap from data too: a list of static routes, then projects.map(...) for every case study, then getAllPostSlugs().map(...) for every post. Publish a post and it's in the sitemap on the next build — there's no manual list to forget.
Quality is enforced by two test runners. Vitest (with Testing Library and jsdom) covers the pure logic and renderers — the TOC builder, reading-time math, the inline tokenizer, the block components. Playwright drives real browser flows, and @axe-core/playwright runs accessibility audits against rendered pages so contrast, focus order, and ARIA regressions get caught before they ship. The monochrome --faint token, for instance, was darkened specifically to pass WCAG AA 4.5:1 on --paper.
- Static generation — every blog route and OG image is prerendered via
generateStaticParams. - Dynamic OG images —
next/ogrenders the social card per post, on brand, at build time. - Sitemap from data — static routes + projects + post slugs, generated by
sitemap.ts. - Reading time — derived from the block content, not hand-entered.
- Unit tests — Vitest + Testing Library over libs and renderers.
- E2E + a11y — Playwright flows with
@axe-core/playwrightaccessibility checks.
Pick your constraints up front, encode them in types and tokens, and let the build enforce them. The monochrome palette, the zero radius, the content-as-data model — none of it can drift, because there's nowhere for it to drift to.
That's the site, end to end: a design system that's one variable away from collapse-proof, a stack whose sharp edges I learned on purpose, two modals that share a vocabulary, and content that generates its own navigation, sitemap, and social cards. If you got this far using the island at the bottom of the screen — that's the whole thesis, working.
From concept to creation let's make it happen.
I'm available for full-time roles & freelance projects.
I thrive on crafting dynamic web applications, and delivering seamless user experiences.