Back to blog

Xây Dựng Blog Cá Nhân — Phase 1: Thiết Lập Next.js 16 + ShadCN/UI

nextjsreactshadcntailwindtypescript
Xây Dựng Blog Cá Nhân — Phase 1: Thiết Lập Next.js 16 + ShadCN/UI

Đây là Phase 1 của series Xây Dựng Blog Cá Nhân. Trong bài này bạn sẽ scaffold dự án Next.js 16, cài ShadCN/UI, xây dựng layout trang web (Header, Footer, mobile nav), và thiết lập dark mode cùng metadata. Kết thúc bài, bạn sẽ có một skeleton chạy được trong trình duyệt.

Series: Xây Dựng Blog Cá Nhân — Lộ Trình Hoàn Chỉnh
Trước: BLOG-1 — Tổng Quan Lộ Trình
Tiếp theo: Phase 2 — Render MDX Theo Yêu Cầu
Source Code: GitHub — personal-blog-nextjs-shadcn-setup


Yêu Cầu Trước

Trước khi bắt đầu, hãy đảm bảo bạn có:

node --version    # v20.x.x trở lên
npm --version     # 10.x.x trở lên
git --version     # bất kỳ version gần đây nào

Cài Node.js 20+ qua nvm nếu cần:

nvm install 20
nvm use 20

1. Scaffold Dự Án Next.js 16

Chạy create-next-app với tất cả tùy chọn cần thiết:

npx create-next-app@16 my-blog \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --no-src-dir \
  --import-alias "@/*"

Khi được hỏi, chấp nhận các giá trị mặc định. Lệnh này cho bạn:

  • TypeScript — type safety đầy đủ
  • Tailwind CSS — styling utility-first
  • ESLint — bắt lỗi sớm
  • App Router — file-based routing với Server Components
  • @/* alias — import gọn gàng từ thư mục gốc dự án

Vào thư mục dự án và khởi động dev server:

cd my-blog
npm run dev

Mở http://localhost:3000 — bạn sẽ thấy trang chào mừng mặc định của Next.js.


2. Dọn Dẹp Boilerplate Mặc Định

create-next-app đi kèm với nội dung placeholder. Hãy xóa chúng:

# Xóa assets và styles mặc định không cần thiết
rm -rf app/favicon.ico public/next.svg public/vercel.svg

Thay thế app/globals.css bằng một baseline sạch. Tailwind v4 dùng @import thay cho các directive @tailwind cũ, và CSS variable được đăng ký với @theme inline để Tailwind tạo các utility class tương ứng (ví dụ: 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);
}

Thay đổi chính so với Tailwind v3:

  • @import "tailwindcss" thay thế ba directive @tailwind base/components/utilities
  • @custom-variant dark cho Tailwind biết cách xử lý dark mode (dùng class .dark)
  • @theme inline ánh xạ CSS variable sang Tailwind color utility — --color-background trở thành bg-background, v.v.
  • Màu sắc dùng định dạng OKLCH, tiêu chuẩn hiện đại mà ShadCN/UI mặc định sử dụng

Thay thế app/page.tsx bằng một placeholder đơn giản:

export default function HomePage() {
  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold">Chào mừng đến blog của tôi</h1>
      <p className="mt-2 text-muted-foreground">Bài viết sắp có.</p>
    </main>
  );
}

3. Cấu Trúc Thư Mục Dự Án

Tổ chức dự án với các thư mục sau:

my-blog/
├── app/
│   ├── blog/
│   │   ├── page.tsx          # /blog — danh sách bài viết
│   │   └── [slug]/
│   │       └── page.tsx      # /blog/[slug] — chi tiết bài viết
│   ├── about/
│   │   └── page.tsx          # /about
│   ├── layout.tsx            # Root layout
│   ├── page.tsx              # Trang chủ
│   └── globals.css
├── components/
│   ├── Header.tsx
│   ├── Footer.tsx
│   └── ThemeProvider.tsx
├── content/
│   └── posts/                # Bài viết MDX (thêm ở Phase 2)
├── lib/
│   └── config.ts             # Cấu hình toàn site
├── public/
│   └── images/
└── ...

Tạo các thư mục ngay bây giờ:

mkdir -p app/blog/\[slug\] app/about components content/posts lib public/images

4. Cấu Hình Site

Tạo lib/config.ts để lưu các cài đặt toàn site ở một nơi:

// lib/config.ts
export const siteConfig = {
  name: "Blog Của Tôi",
  description: "Blog cá nhân về phát triển phần mềm.",
  url: process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000",
  author: {
    name: "Tên Của Bạn",
    email: "[email protected]",
    twitter: "@tentwittercuaban",
  },
  links: {
    github: "https://github.com/tentengithub",
    twitter: "https://twitter.com/tentwittercuaban",
  },
} as const;

5. Cài ShadCN/UI

ShadCN/UI không phải npm package truyền thống — đây là bộ component bạn copy vào dự án và tự sở hữu. Điều này có nghĩa là không xung đột version và toàn quyền kiểm soát styling.

Chạy lệnh khởi tạo:

npx shadcn@latest init

CLI tự động phát hiện setup Tailwind v4 của bạn và cấu hình mọi thứ. Lệnh này tạo ra:

  • components/ui/ — thư mục component (tự động được điền khi bạn thêm component)
  • lib/utils.ts — tiện ích cn() để merge Tailwind classes
  • Cập nhật globals.css với đầy đủ biến CSS của ShadCN và ánh xạ @theme inline (màu sắc, radius, sidebar, chart variable, v.v.)

Thêm các component cần thiết

npx shadcn@latest add button card badge separator sheet
  • Button — nút CTA, nav link
  • Card — preview card cho bài viết blog
  • Badge — nhãn tag trên bài viết
  • Separator — divider ngang
  • Sheet — ngăn kéo navigation mobile

6. Cài next-themes

ShadCN/UI dùng next-themes cho dark/light mode. Cài đặt:

npm install next-themes

Tạo 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

Root layout bao bọc mọi trang. Cập nhật 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: "vi_VN",
    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="vi" 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>
  );
}

Điểm chính:

  • suppressHydrationWarning trên <html> ngăn React warning do next-themes chuyển đổi thuộc tính class trong quá trình hydration
  • template: '%s | Tên Site' tự động thêm tên site vào mọi tiêu đề trang
  • metadataBase bắt buộc để có URL Open Graph tuyệt đối

8. Component Theme Toggle

Tạo 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="Chuyển đổi 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>
  );
}

Cài package Lucide icons:

npm install lucide-react

9. Component Header

Header chứa tên site, navigation links và theme toggle. Trên mobile, navigation thu gọn vào Sheet (ngăn kéo).

Tạo 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: "Trang Chủ" },
  { href: "/blog", label: "Blog" },
  { href: "/about", label: "Giới Thiệu" },
];
 
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 / Tên site */}
        <Link
          href="/"
          className="text-lg font-bold tracking-tight hover:opacity-80 transition-opacity"
        >
          {siteConfig.name}
        </Link>
 
        {/* Navigation desktop */}
        <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>
 
        {/* Bên phải: theme toggle + menu mobile */}
        <div className="flex items-center gap-2">
          <ThemeToggle />
 
          {/* Navigation mobile */}
          <Sheet>
            <SheetTrigger asChild>
              <Button
                variant="ghost"
                size="icon"
                className="md:hidden"
                aria-label="Mở 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>
  );
}

Quyết định thiết kế chính:

  • sticky top-0 z-50 — header ở lại khi cuộn trang
  • backdrop-blur — hiệu ứng frosted glass trên nội dung trang
  • Nav desktop ẩn trên mobile (hidden md:flex), thay bằng Sheet
  • ThemeToggle luôn hiển thị

Tạo 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}. Mọi quyền được
          bảo lưu.
        </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. Trang Stub

Tạo các trang stub để navigation link không bị 404.

app/blog/page.tsx:

// app/blog/page.tsx
import type { Metadata } from "next";
 
export const metadata: Metadata = {
  title: "Blog",
  description: "Tất cả bài viết.",
};
 
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">Bài viết sẽ xuất hiện ở đây ở 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: "Giới Thiệu",
  description: `Về ${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">Giới Thiệu</h1>
      <p className="text-muted-foreground leading-relaxed">
        Xin chào, tôi là {siteConfig.author.name}. Tôi viết về phát triển phần
        mềm, công cụ và những thứ tôi đang học.
      </p>
    </div>
  );
}

12. Trang Chủ

Cập nhật app/page.tsx thành trang chủ đúng nghĩa:

// 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">
        Xin chào, tôi là {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">Đọc blog</Link>
        </Button>
        <Button variant="outline" asChild>
          <Link href="/about">Về tôi</Link>
        </Button>
      </div>
    </div>
  );
}

13. Tailwind v4 — Cấu Hình CSS-First

Khác với Tailwind v3, không có file tailwind.config.ts. Tailwind v4 chuyển mọi thứ vào CSS:

  • Phát hiện nội dung tự động — Tailwind quét các file dự án mà không cần đường dẫn content rõ ràng
  • Dark mode được cấu hình qua @custom-variant dark trong globals.css (đã thêm ở Phần 2)
  • Mở rộng theme dùng @theme inline trong globals.css (ShadCN/UI thiết lập tự động)
  • Animation dùng package tw-animate-css thay cho tailwindcss-animate cũ

Cài package animation mà ShadCN cần:

npm install tw-animate-css

Sau đó thêm import ở đầu globals.css (lệnh init của ShadCN có thể đã làm điều này):

@import "tailwindcss";
@import "tw-animate-css";
 
@custom-variant dark (&:is(.dark *));
 
/* ... phần còn lại của biến và block @theme inline ... */

Nếu bạn cần tùy chỉnh container hoặc thêm giá trị theme khác, mở rộng block @theme inline trong globals.css trực tiếp — không cần file config riêng.


14. Kiểm Tra Build

Trước khi chuyển sang Phase 2, kiểm tra mọi thứ có compile không:

npm run build

Bạn sẽ thấy output như:

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

Không có TypeScript error, không có module thiếu.

Cũng chạy dev server và kiểm tra thủ công:

  • http://localhost:3000 — trang chủ load được
  • /blog — blog stub load được
  • /about — about stub load được
  • Navigation header hoạt động trên desktop và mobile
  • Theme toggle chuyển đổi giữa light và dark
  • Tiêu đề trang hiển thị đúng trên tab trình duyệt

Cây Thư Mục Hiện Tại

my-blog/
├── app/
│   ├── about/
│   │   └── page.tsx
│   ├── blog/
│   │   └── page.tsx
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── components/
│   ├── ui/                   ← ShadCN/UI components (tự động tạo)
│   │   ├── button.tsx
│   │   ├── card.tsx
│   │   ├── badge.tsx
│   │   ├── separator.tsx
│   │   └── sheet.tsx
│   ├── Footer.tsx
│   ├── Header.tsx
│   ├── ThemeProvider.tsx
│   └── ThemeToggle.tsx
├── content/
│   └── posts/                ← trống hiện tại, sẽ thêm ở Phase 2
├── lib/
│   ├── config.ts
│   └── utils.ts              ← tiện ích cn() (thêm bởi ShadCN init)
├── public/
│   └── images/
├── tsconfig.json
└── package.json

Vấn Đề Thường Gặp

useTheme gây hydration mismatch
Bọc bất kỳ component đọc theme trong kiểm tra mounted:

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;

Hoặc dùng suppressHydrationWarning trên phần tử thay đổi — đã được xử lý bởi suppressHydrationWarning trên <html> trong layout.

ShadCN component hiển thị không có style
Xác nhận globals.css có biến CSS của ShadCN và block @theme inline ánh xạ chúng sang Tailwind utility. Nếu thiếu, chạy lại npx shadcn@latest init để tạo lại.

Sheet không đóng khi navigate
Sheet ở lại mở sau khi click link vì navigation không unmount component theo mặc định. Fix bằng cách thêm usePathname và reset trạng thái open khi route thay đổi, hoặc dùng SheetClose bọc mỗi nav link:

import { SheetClose } from "@/components/ui/sheet";
 
<SheetClose asChild>
  <Link href={link.href}>...</Link>
</SheetClose>

Tóm Tắt

Trong phase này bạn đã:

✅ Scaffold dự án Next.js 16 App Router với TypeScript và Tailwind CSS
✅ Cài đặt và cấu hình ShadCN/UI với Button, Card, Badge, Sheet, Separator
✅ Xây dựng Header responsive với nav desktop và Sheet drawer mobile
✅ Thêm dark/light theme toggle với next-themes
✅ Cấu hình site metadata với generateMetadata và Open Graph tags
✅ Xác nhận production build không có lỗi

Tiếp theo — Phase 2: Render MDX Theo Yêu Cầu Bạn sẽ thêm tầng nội dung: đọc file MDX từ đĩa, parse frontmatter, compile với next-mdx-remote, và render bài viết với code block có syntax highlighting.


Mục Lục Series

BàiTiêu ĐềTrạng Thái
BLOG-1Xây Dựng Blog Cá Nhân — Lộ Trình✅ Hoàn Thành
BLOG-2Phase 1: Thiết Lập Dự Án — Next.js 16 + ShadCN/UI✅ Bạn đang ở đây
BLOG-3Phase 2: MDX On-Demand Rendering✅ Hoàn Thành
BLOG-4Phase 3: PostgreSQL + Drizzle ORM✅ Hoàn Thành
BLOG-5Phase 4: Tags, Search & Pagination✅ Hoàn Thành
BLOG-6Phase 5: Docker Compose✅ Hoàn Thành
BLOG-7Phase 6: Deploy lên Ubuntu VPS trên Hostinger✅ Hoàn Thành
BLOG-8Phase 7: Cấu Hình Tên Miền Riêng trên Hostinger✅ Hoàn Thành

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.