How I Built This Site with Next.js, Tailwind v4, and Claude Code
This site - the one you're reading right now - was built in a single session with Claude Code. Not a template, not a drag-and-drop builder. Real code, real decisions, real deployment. Here's how it came together and what I learned.
The Stack#
| Technology | Why |
|---|---|
| Next.js 15 | App Router with server components by default - faster pages, simpler data fetching |
| React 19 | Latest React with server component support |
| Tailwind CSS v4 | New version: all config lives in CSS via @theme blocks. No more tailwind.config.ts |
| shadcn/ui | Copies real component source code into your project. You own it, you can modify it |
| MDX | Blog posts as Markdown files with embedded React components. No database needed |
The Big Decision: Tailwind v4#
Major Change from v3
If you've used Tailwind before, v4 is a significant change. The configuration that used to live in tailwind.config.ts now lives directly in your CSS. No more JavaScript config file.
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme inline {
--color-background: var(--background);
--color-primary: var(--primary);
/* ... */
}
All your design tokens - colors, fonts, spacing, border radii - are defined as CSS variables in :root and bridged to Tailwind utilities via the @theme block. It's actually simpler once you get used to it.
Dark Mode: All In#
I didn't want a light/dark toggle. The site is dark mode, period. One line in the root layout makes this happen:
<html lang="en" className="dark">
The color palette uses the OKLCH color space - a perceptually uniform system where colors with the same lightness value actually look equally bright to human eyes. The accent color is a cyan at oklch(0.75 0.15 195).
Key colors:
| Role | Value | What It Looks Like |
|---|---|---|
| Background | oklch(0.141 0.005 285.823) | Near-black with a subtle blue tint |
| Foreground | oklch(0.985 0 0) | Almost white |
| Primary | oklch(0.75 0.15 195) | Cyan - the accent everywhere |
| Card | oklch(0.178 0.005 285.823) | Slightly lighter than background |
| Border | oklch(1 0 0 / 10%) | Semi-transparent white - subtle |
The Component Architecture#
Glassmorphism Nav#
The sticky navigation uses a glass effect - semi-transparent background with a backdrop blur:
<header className="sticky top-0 z-50 border-b border-border/40 bg-background/80 backdrop-blur-md">
On mobile, the nav links collapse into a hamburger menu that opens a Sheet (a drawer from shadcn/ui built on Radix Dialog). It handles focus trapping, escape key, click-outside, and screen reader announcements automatically.
The asChild Pattern#
Radix Pattern
You'll see asChild throughout the code. This is a Radix pattern that means "don't render your default element - render my child instead." It's how you put button styling on a Next.js Link.
<Button asChild size="lg">
<Link href="/blog">Read the Blog</Link>
</Button>
The result is a <a> tag with button styling, not a <button> wrapping an <a>. Semantically correct and better for accessibility.
Blog Card Interaction Pattern#
Blog cards use a CSS pseudo-element overlay pattern for clickable cards with independently interactive elements. The title link uses after:absolute after:inset-0 to cover the entire card, while tag badges sit above with relative z-10 for independent clickability:
<Card className="group relative h-full hover:border-primary/50">
<div className="relative z-10 flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link href={`/blog?tag=${encodeURIComponent(tag)}`}>
<Badge variant="secondary">{tag}</Badge>
</Link>
))}
</div>
<h3 className="group-hover:text-primary">
<Link href={`/blog/${post.slug}`} className="after:absolute after:inset-0">
{post.title}
</Link>
</h3>
</Card>
Why Pseudo-Elements
This avoids nested <a> tags (invalid HTML) and works as a pure server component - no onClick handlers needed. When you hover anywhere on the card, the title turns cyan. Tags remain independently clickable and link to filtered blog views at /blog?tag=TagName.
The Blog System#
This is the part I'm most proud of. The blog is powered by MDX files - write Markdown, and it becomes a page on the site. No database, no CMS, no API.
How It Works#
- Write a file: Drop an
.mdxfile intosrc/content/blog/ - Add frontmatter: Title, date, description, tags at the top
- Done: The post appears on
/blogand gets its own page at/blog/your-slug
The slug comes from the filename. my-first-post.mdx becomes /blog/my-first-post.
Blog Search and Tag Filtering#
The blog listing page includes a search bar and clickable tag filters. Search filters posts by title, description, and tags in real time. Tags use URL query parameters (/blog?tag=Security&tag=Claude+Code) so filtered views are shareable and bookmarkable. Multiple tags filter with AND logic - selecting "Security" and "Next.js" shows only posts with both tags.
Custom MDX Components#
Blog posts support custom React components embedded directly in Markdown. This includes callout boxes (like the tips and warnings you see throughout), inline product badges for tools like Vercel and Next.js, and SVG architecture diagrams. All components are registered in the MDX renderer and available to every post.
Under the Hood#
A utility file (lib/blog.ts) reads all .mdx files at build time, parses the frontmatter with gray-matter, and returns sorted post data. Because this runs in a Server Component, there's no client-side fetch, no loading spinner, no API call. The data is baked into the HTML at build time.
Blog posts are rendered with next-mdx-remote, which converts MDX to React components on the server. The typography is handled by Tailwind's prose classes - they style headings, paragraphs, lists, code blocks, and links without you writing any CSS.
Static Generation#
Every blog post is pre-built at compile time via generateStaticParams(). The result is static HTML - fast, cacheable, and SEO-friendly. When you add a new post, you push to GitHub and Vercel rebuilds automatically.
The Contact Page#
Update
The contact page was originally a client-side form with mailto: fallback. It has since been redesigned as a server component with a LinkedIn CTA and email fallback - no form state needed, no "use client" directive.
The current contact page features a prominent LinkedIn connect button and a secondary email option (Chris.Johnson@cryptoflexllc.com). Since it's now a pure server component, the metadata workaround (separate layout.tsx for SEO) is still in place but the page itself is simpler and more focused.
The Sticky Footer Trick#
A common problem: on pages with little content, the footer floats in the middle of the screen instead of sticking to the bottom. The fix is three lines of Tailwind:
<body className="min-h-screen flex flex-col">
<Nav />
<main className="flex-1">{children}</main>
<Footer />
</body>
min-h-screen- body fills at least the viewport heightflex flex-col- vertical flex layoutflex-1on main - main grows to fill available space, pushing footer down
What I'd Change#
Looking back, a few things I'd do differently:
-
Add a real contact form.Done! The contact page now has a LinkedIn CTA with email fallback. Themailto:approach was replaced with a cleaner, server-rendered design. -
Add image optimization. Blog post images should use Next.js
<Image>for automatic optimization and lazy loading. -
Add a table of contents. For longer posts, auto-generating a TOC from headings would help navigation.
-
Add reading time.Done! Every post now displays reading time in the header, calculated from word count.
The Full Build Guide#
If you want to build a similar site from scratch, I've written a comprehensive step-by-step guide that covers every command, every file, every decision: BUILD-GUIDE.md.
It's about 1,300 lines and walks through scaffolding, shadcn/ui setup, the dark theme, all components, the MDX blog system, SEO, and deployment. Someone should be able to follow it and build the same site.
Final Thoughts#
Building a production website in a single session feels like it shouldn't be possible. But the combination of Next.js (which handles routing, rendering, and optimization), Tailwind (which eliminates the CSS bottleneck), shadcn/ui (which gives you accessible components), and Claude Code (which writes the code while you make the decisions) made it real.
The code is open source: github.com/chris2ao/cryptoflexllc. Fork it, modify it, learn from it.
Written by Chris Johnson and edited by Claude Code (Opus 4.6). This post is part of a series about AI-assisted development. Previous: Configuring Claude Code. Next up: My First 24 Hours with Claude Code - the full day-one narrative.
Weekly Digest
Get a weekly email with what I learned, summaries of new posts, and direct links. No spam, unsubscribe anytime.
Related Posts
One cybersecurity nerd, one AI coding assistant, one week, 117 commits, 24 pull requests, 17 blog posts, 410 tests at 98% coverage. This is the story of building a production website from scratch with Claude Code, including every mistake, meltdown, and meme along the way.
A technical deep-dive into implementing production-grade SEO for a Next.js site, covering dynamic sitemaps, JSON-LD structured data, OpenGraph, Twitter Cards, RSS feeds, canonical URLs, and everything Google needs to actually find your website.
A detailed, step-by-step guide to vibe coding a production website from the ground up using Claude Code, from someone whose last website ran on Apache with hand-written HTML. Every service, every config, every command.
Comments
Subscribers only — enter your subscriber email to comment
