Back to blog

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

nextjsreactshadcntailwindtypescript
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 version

Install Node.js 20+ via nvm if needed:

nvm install 20
nvm use 20

1. 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 dev

Open 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.svg

Replace 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/utilities directives
  • @custom-variant dark tells Tailwind how to handle dark mode (using the .dark class)
  • @theme inline maps CSS variables to Tailwind color utilities — --color-background becomes bg-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/images

4. 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 init

The 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 — the cn() utility for merging Tailwind classes
  • Updates globals.css with the full set of ShadCN CSS variables and @theme inline mappings (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-themes

Create 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:

  • suppressHydrationWarning on <html> prevents a React warning caused by next-themes switching the class attribute during hydration
  • template: '%s | Site Name' automatically appends your site name to every page title
  • metadataBase is 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-react

9. 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 scrolling
  • backdrop-blur — frosted glass effect against page content
  • Desktop nav is hidden on mobile (hidden md:flex), replaced by the Sheet
  • ThemeToggle is always visible

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&apos;m {siteConfig.author.name}. I write about software
        development, tools, and things I&apos;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&apos;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 content paths
  • Dark mode is configured via @custom-variant dark in globals.css (already added in Section 2)
  • Theme extensions use @theme inline in globals.css (ShadCN/UI sets this up automatically)
  • Animations use the tw-animate-css package instead of the old tailwindcss-animate

Install the animation package ShadCN depends on:

npm install tw-animate-css

Then 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 build

You should see output like:

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
└ ○ /blog
 
○  (Static)  prerendered as static content

No 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.json

Common 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

PostTitleStatus
BLOG-1Build a Personal Blog — Roadmap✅ Complete
BLOG-2Phase 1: Project Setup — Next.js 16 + ShadCN/UI✅ You are here
BLOG-3Phase 2: MDX On-Demand Rendering✅ Complete
BLOG-4Phase 3: PostgreSQL + Drizzle ORMComing Soon
BLOG-5Phase 4: Tags, Search & PaginationComing Soon
BLOG-6Phase 5: Docker ComposeComing Soon
BLOG-7Phase 6: Deploy to Ubuntu VPS on HostingerComing Soon
BLOG-8Phase 7: Custom Domain Setup on HostingerComing 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.