Build a Personal Blog — Phase 1: Next.js 16 + ShadCN/UI Setup

This is Phase 1 of the Build a Personal Blog series. In this post you'll scaffold the Next.js 16 project, install ShadCN/UI, build the site layout (Header, Footer, mobile nav), and set up dark mode and metadata. By the end you'll have a running skeleton you can open in the browser.
Series: Build a Personal Blog — Complete Roadmap
Previous: BLOG-1 — Roadmap Overview
Next: Phase 2 — MDX On-Demand Rendering
Source Code: GitHub — personal-blog-nextjs-shadcn-setup
Prerequisites
Before starting, make sure you have:
node --version # v20.x.x or higher
npm --version # 10.x.x or higher
git --version # any recent versionInstall Node.js 20+ via nvm if needed:
nvm install 20
nvm use 201. Scaffold the Next.js 16 Project
Run create-next-app with all the options we need:
npx create-next-app@16 my-blog \
--typescript \
--tailwind \
--eslint \
--app \
--no-src-dir \
--import-alias "@/*"When prompted, accept the defaults. This gives you:
- TypeScript — full type safety
- Tailwind CSS — utility-first styling
- ESLint — catch errors early
- App Router — file-based routing with Server Components
@/*alias — clean imports from the project root
Navigate into the project and start the dev server:
cd my-blog
npm run devOpen http://localhost:3000 — you should see the default Next.js welcome page.
2. Clean Up the Default Boilerplate
create-next-app ships with placeholder content. Remove it:
# Remove default assets and styles we don't need
rm -rf app/favicon.ico public/next.svg public/vercel.svgReplace app/globals.css with a clean baseline. Tailwind v4 uses @import instead of the old @tailwind directives, and CSS variables are registered with @theme inline so Tailwind generates matching utility classes (e.g., bg-background, text-foreground, border-border):
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--border: oklch(0.922 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-border: var(--border);
}Key changes from Tailwind v3:
@import "tailwindcss"replaces the three@tailwind base/components/utilitiesdirectives@custom-variant darktells Tailwind how to handle dark mode (using the.darkclass)@theme inlinemaps CSS variables to Tailwind color utilities —--color-backgroundbecomesbg-background, etc.- Colors use OKLCH format, which is the modern standard ShadCN/UI defaults to
Replace app/page.tsx with a simple placeholder:
export default function HomePage() {
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold">Welcome to my blog</h1>
<p className="mt-2 text-muted-foreground">Posts coming soon.</p>
</main>
);
}3. Project Folder Structure
Organise your project with these directories:
my-blog/
├── app/
│ ├── blog/
│ │ ├── page.tsx # /blog — post listing
│ │ └── [slug]/
│ │ └── page.tsx # /blog/[slug] — post detail
│ ├── about/
│ │ └── page.tsx # /about
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ └── globals.css
├── components/
│ ├── Header.tsx
│ ├── Footer.tsx
│ └── ThemeProvider.tsx
├── content/
│ └── posts/ # MDX blog posts (added in Phase 2)
├── lib/
│ └── config.ts # Site-wide configuration
├── public/
│ └── images/
└── ...Create the directories now:
mkdir -p app/blog/\[slug\] app/about components content/posts lib public/images4. Site Configuration
Create lib/config.ts to store site-wide settings in one place:
// lib/config.ts
export const siteConfig = {
name: "My Blog",
description: "A personal blog about software development.",
url: process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000",
author: {
name: "Your Name",
email: "you@example.com",
twitter: "@yourhandle",
},
links: {
github: "https://github.com/yourusername",
twitter: "https://twitter.com/yourhandle",
},
} as const;5. Install ShadCN/UI
ShadCN/UI is not a traditional npm package — it's a collection of components you copy into your project and own. This means no version conflicts and full control over styling.
Run the initializer:
npx shadcn@latest initThe CLI detects your Tailwind v4 setup automatically and configures everything. This creates:
components/ui/— component directory (auto-populated as you add components)lib/utils.ts— thecn()utility for merging Tailwind classes- Updates
globals.csswith the full set of ShadCN CSS variables and@theme inlinemappings (colors, radius, sidebar, chart variables, etc.)
Add the components you need
npx shadcn@latest add button card badge separator sheet- Button — CTA buttons, nav links
- Card — blog post preview cards
- Badge — tag labels on posts
- Separator — horizontal dividers
- Sheet — slide-in mobile navigation drawer
6. Install next-themes
ShadCN/UI uses next-themes for dark/light mode. Install it:
npm install next-themesCreate components/ThemeProvider.tsx:
// components/ThemeProvider.tsx
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}7. Root Layout
The root layout wraps every page. Update app/layout.tsx:
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/ThemeProvider";
import { Header } from "@/components/Header";
import { Footer } from "@/components/Footer";
import { siteConfig } from "@/lib/config";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata: Metadata = {
title: {
default: siteConfig.name,
template: `%s | ${siteConfig.name}`,
},
description: siteConfig.description,
metadataBase: new URL(siteConfig.url),
openGraph: {
type: "website",
locale: "en_US",
url: siteConfig.url,
title: siteConfig.name,
description: siteConfig.description,
siteName: siteConfig.name,
},
twitter: {
card: "summary_large_image",
title: siteConfig.name,
description: siteConfig.description,
creator: siteConfig.author.twitter,
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</ThemeProvider>
</body>
</html>
);
}Key points:
suppressHydrationWarningon<html>prevents a React warning caused bynext-themesswitching theclassattribute during hydrationtemplate: '%s | Site Name'automatically appends your site name to every page titlemetadataBaseis required for absolute Open Graph URLs
8. Theme Toggle Component
Create components/ThemeToggle.tsx:
// components/ThemeToggle.tsx
"use client";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import { Moon, Sun } from "lucide-react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
);
}Install the Lucide icons package:
npm install lucide-react9. Header Component
The Header contains the site name, navigation links, and the theme toggle. On mobile, navigation collapses into a Sheet (slide-in drawer).
Create components/Header.tsx:
// components/Header.tsx
import Link from "next/link";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/ThemeToggle";
import { siteConfig } from "@/lib/config";
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/blog", label: "Blog" },
{ href: "/about", label: "About" },
];
export function Header() {
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
{/* Logo / Site name */}
<Link
href="/"
className="text-lg font-bold tracking-tight hover:opacity-80 transition-opacity"
>
{siteConfig.name}
</Link>
{/* Desktop navigation */}
<nav className="hidden md:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</Link>
))}
</nav>
{/* Right side: theme toggle + mobile menu */}
<div className="flex items-center gap-2">
<ThemeToggle />
{/* Mobile navigation */}
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-64">
<SheetTitle className="sr-only">Menu</SheetTitle>
<div className="flex flex-col gap-1 mt-8">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="flex items-center py-2 px-3 rounded-md text-sm font-medium hover:bg-accent transition-colors"
>
{link.label}
</Link>
))}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
}Key design decisions:
sticky top-0 z-50— header stays visible while scrollingbackdrop-blur— frosted glass effect against page content- Desktop nav is hidden on mobile (
hidden md:flex), replaced by the Sheet ThemeToggleis always visible
10. Footer Component
Create components/Footer.tsx:
// components/Footer.tsx
import Link from "next/link";
import { siteConfig } from "@/lib/config";
export function Footer() {
return (
<footer className="border-t py-8">
<div className="container mx-auto flex flex-col items-center justify-between gap-4 px-4 sm:flex-row">
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} {siteConfig.author.name}. All rights
reserved.
</p>
<div className="flex items-center gap-4">
<Link
href={siteConfig.links.github}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
GitHub
</Link>
<Link
href={siteConfig.links.twitter}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Twitter
</Link>
</div>
</div>
</footer>
);
}11. Stub Pages
Create stub pages so navigation links don't 404.
app/blog/page.tsx:
// app/blog/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Blog",
description: "All posts.",
};
export default function BlogPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-2">Blog</h1>
<p className="text-muted-foreground">Posts will appear here in Phase 2.</p>
</div>
);
}app/about/page.tsx:
// app/about/page.tsx
import type { Metadata } from "next";
import { siteConfig } from "@/lib/config";
export const metadata: Metadata = {
title: "About",
description: `About ${siteConfig.author.name}.`,
};
export default function AboutPage() {
return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
<h1 className="text-3xl font-bold mb-4">About</h1>
<p className="text-muted-foreground leading-relaxed">
Hi, I'm {siteConfig.author.name}. I write about software
development, tools, and things I'm learning.
</p>
</div>
);
}12. Home Page
Update app/page.tsx to a proper home page:
// app/page.tsx
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { siteConfig } from "@/lib/config";
export default function HomePage() {
return (
<div className="container mx-auto px-4 py-16 max-w-2xl">
<h1 className="text-4xl font-bold tracking-tight mb-4">
Hi, I'm {siteConfig.author.name}
</h1>
<p className="text-lg text-muted-foreground mb-8 leading-relaxed">
{siteConfig.description}
</p>
<div className="flex gap-3">
<Button asChild>
<Link href="/blog">Read the blog</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/about">About me</Link>
</Button>
</div>
</div>
);
}13. Tailwind v4 — CSS-First Configuration
Unlike Tailwind v3, there is no tailwind.config.ts file. Tailwind v4 moves everything into CSS:
- Content detection is automatic — Tailwind scans your project files without explicit
contentpaths - Dark mode is configured via
@custom-variant darkinglobals.css(already added in Section 2) - Theme extensions use
@theme inlineinglobals.css(ShadCN/UI sets this up automatically) - Animations use the
tw-animate-csspackage instead of the oldtailwindcss-animate
Install the animation package ShadCN depends on:
npm install tw-animate-cssThen add the import at the top of globals.css (ShadCN's init may have already done this):
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* ... rest of your variables and @theme inline block ... */If you need to customize the container or add other theme values, extend the @theme inline block in globals.css directly — no separate config file needed.
14. Test the Build
Before moving to Phase 2, verify everything compiles:
npm run buildYou should see output like:
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
└ ○ /blog
○ (Static) prerendered as static contentNo TypeScript errors, no missing modules.
Also run the dev server and manually verify:
http://localhost:3000— home page loads/blog— blog stub loads/about— about stub loads- Header navigation works on desktop and mobile
- Theme toggle switches between light and dark
- Page title shows correctly in the browser tab
What the File Tree Looks Like Now
my-blog/
├── app/
│ ├── about/
│ │ └── page.tsx
│ ├── blog/
│ │ └── page.tsx
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── ui/ ← ShadCN/UI components (auto-generated)
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── badge.tsx
│ │ ├── separator.tsx
│ │ └── sheet.tsx
│ ├── Footer.tsx
│ ├── Header.tsx
│ ├── ThemeProvider.tsx
│ └── ThemeToggle.tsx
├── content/
│ └── posts/ ← empty for now, filled in Phase 2
├── lib/
│ ├── config.ts
│ └── utils.ts ← cn() utility (added by ShadCN init)
├── public/
│ └── images/
├── tsconfig.json
└── package.jsonCommon Issues
useTheme causes a hydration mismatch
Wrap any component that reads theme in a mounted check:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;Or use suppressHydrationWarning on the element that changes — already handled by suppressHydrationWarning on <html> in the layout.
ShadCN components show unstyled
Confirm globals.css has the ShadCN CSS variables and the @theme inline block that maps them to Tailwind utilities. If they're missing, run npx shadcn@latest init again to regenerate them.
Sheet doesn't close on navigation
The Sheet stays open after clicking a link because navigation doesn't unmount the component by default. Fix by adding usePathname and resetting the open state when the route changes, or use SheetClose as a wrapper around each nav link:
import { SheetClose } from "@/components/ui/sheet";
<SheetClose asChild>
<Link href={link.href}>...</Link>
</SheetClose>Summary
In this phase you:
✅ Scaffolded a Next.js 16 App Router project with TypeScript and Tailwind CSS
✅ Installed and configured ShadCN/UI with Button, Card, Badge, Sheet, Separator
✅ Built a responsive Header with desktop nav and mobile Sheet drawer
✅ Added a dark/light theme toggle with next-themes
✅ Configured site metadata with generateMetadata and Open Graph tags
✅ Verified the production build passes with no errors
Next up — Phase 2: MDX On-Demand Rendering
You'll add the content layer: read MDX files from disk, parse frontmatter, compile with next-mdx-remote, and render posts with syntax-highlighted code blocks.
Series Index
| Post | Title | Status |
|---|---|---|
| BLOG-1 | Build a Personal Blog — Roadmap | ✅ Complete |
| BLOG-2 | Phase 1: Project Setup — Next.js 16 + ShadCN/UI | ✅ You are here |
| BLOG-3 | Phase 2: MDX On-Demand Rendering | ✅ Complete |
| BLOG-4 | Phase 3: PostgreSQL + Drizzle ORM | Coming Soon |
| BLOG-5 | Phase 4: Tags, Search & Pagination | Coming Soon |
| BLOG-6 | Phase 5: Docker Compose | Coming Soon |
| BLOG-7 | Phase 6: Deploy to Ubuntu VPS on Hostinger | Coming Soon |
| BLOG-8 | Phase 7: Custom Domain Setup on Hostinger | Coming Soon |
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.