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àoCài Node.js 20+ qua nvm nếu cần:
nvm install 20
nvm use 201. 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 devMở 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.svgThay 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 darkcho Tailwind biết cách xử lý dark mode (dùng class.dark)@theme inlineánh xạ CSS variable sang Tailwind color utility —--color-backgroundtrở thànhbg-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/images4. 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 initCLI 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 íchcn()để merge Tailwind classes- Cập nhật
globals.cssvớ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-themesTạ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:
suppressHydrationWarningtrên<html>ngăn React warning donext-themeschuyển đổi thuộc tínhclasstrong quá trình hydrationtemplate: '%s | Tên Site'tự động thêm tên site vào mọi tiêu đề trangmetadataBasebắ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-react9. 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 trangbackdrop-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 ThemeToggleluôn hiển thị
10. Component Footer
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
contentrõ ràng - Dark mode được cấu hình qua
@custom-variant darktrongglobals.css(đã thêm ở Phần 2) - Mở rộng theme dùng
@theme inlinetrongglobals.css(ShadCN/UI thiết lập tự động) - Animation dùng package
tw-animate-cssthay chotailwindcss-animatecũ
Cài package animation mà ShadCN cần:
npm install tw-animate-cssSau đó 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 buildBạn sẽ thấy output như:
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /about
└ ○ /blog
○ (Static) prerendered as static contentKhô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.jsonVấ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ài | Tiêu Đề | Trạng Thái |
|---|---|---|
| BLOG-1 | Xây Dựng Blog Cá Nhân — Lộ Trình | ✅ Hoàn Thành |
| BLOG-2 | Phase 1: Thiết Lập Dự Án — Next.js 16 + ShadCN/UI | ✅ Bạn đang ở đây |
| BLOG-3 | Phase 2: MDX On-Demand Rendering | ✅ Hoàn Thành |
| BLOG-4 | Phase 3: PostgreSQL + Drizzle ORM | ✅ Hoàn Thành |
| BLOG-5 | Phase 4: Tags, Search & Pagination | ✅ Hoàn Thành |
| BLOG-6 | Phase 5: Docker Compose | ✅ Hoàn Thành |
| BLOG-7 | Phase 6: Deploy lên Ubuntu VPS trên Hostinger | ✅ Hoàn Thành |
| BLOG-8 | Phase 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.