# Bài 10 — Next.js App Router
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
- Tạo dự án Next.js 14
- Hiểu App Router: thư mục = URL
- Nắm 3 file đặc biệt:
layout.js,page.js,loading.js - Route group
(user), dynamic route[id] - Phân biệt Server Component và Client Component — khái niệm quan trọng nhất
- Điều hướng với
Link,useRouter,usePathname
💻 1. Tạo dự án Next.js
Từ thư mục gốc lith-perfume/:
npx create-next-app@14 frontend
Trả lời các câu hỏi:
✔ Would you like to use TypeScript? → No
✔ Would you like to use ESLint? → Yes
✔ Would you like to use Tailwind CSS? → No (dự án dùng Bootstrap)
✔ Would you like to use `src/` directory? → No
✔ Would you like to use App Router? → Yes ← QUAN TRỌNG
✔ Would you like to customize import alias? → Yes → @/*
Cài các thư viện dự án cần:
cd frontend
npm install @reduxjs/toolkit react-redux redux-persist swr formik
| Thư viện | Dùng cho |
|---|---|
@reduxjs/toolkit + react-redux |
Quản lý giỏ hàng, sort, filter (bài 13) |
redux-persist |
Lưu giỏ hàng vào localStorage (bài 14) |
swr |
Gọi API, tự cache và revalidate (bài 12) |
formik |
Xử lý form thêm sản phẩm (bài 18) |
Chạy thử:
npm run dev
Mở http://localhost:3000 — thấy trang mặc định của Next.js là được.
⚠️ Nhớ backend đang chạy ở port 5000 (bài 04). Nếu vẫn để 3000, một trong hai sẽ không khởi động được.
📚 2. App Router — thư mục chính là URL
Đây là ý tưởng cốt lõi: cấu trúc thư mục quyết định cấu trúc URL.
app/
├── page.js → /
├── about/
│ └── page.js → /about
├── product/
│ ├── page.js → /product
│ └── [id]/
│ └── page.js → /product/123
└── admin/
├── page.js → /admin
└── product/
└── page.js → /admin/product
Chỉ file tên page.js mới tạo ra URL. Các file khác trong thư mục
(component/product.js chẳng hạn) không sinh route — bạn có thể để component
ngay cạnh page mà không lo.
Đây là điểm mạnh so với Pages Router cũ (mọi file trong pages/ đều thành route).
📚 3. Các file đặc biệt
| Tên file | Vai trò |
|---|---|
page.js |
Giao diện của một URL. Bắt buộc để URL truy cập được |
layout.js |
Khung bao quanh, giữ nguyên khi chuyển trang con |
loading.js |
Hiện tự động khi trang con đang tải |
error.js |
Hiện khi trang con ném lỗi |
not-found.js |
Trang 404 tùy chỉnh |
template.js |
Giống layout nhưng render lại mỗi lần chuyển trang |
Layout lồng nhau
Layout cộng dồn theo cấp thư mục:
app/
├── (user)/
│ ├── layout.js ← A: <html>, <body>, Navbar, Redux Provider
│ ├── page.js ← trang chủ, được bọc bởi A
│ └── menu/
│ ├── layout.js ← B: sidebar lọc + sắp xếp
│ ├── page.js ← /menu, được bọc bởi A rồi B
│ └── [id]/
│ └── page.js ← /menu/123, cũng được bọc bởi A rồi B
Khi vào /menu/123, React render:
<LayoutA> {/* html, body, Navbar */}
<LayoutB> {/* sidebar danh mục, sắp xếp, khoảng giá */}
<PageMenuId /> {/* danh sách sản phẩm của danh mục */}
</LayoutB>
</LayoutA>
Lợi ích: khi bạn bấm từ /menu sang /menu/123, Navbar và sidebar không render lại —
chỉ phần children thay đổi. Trang chuyển mượt, state trong layout được giữ nguyên.
📚 4. Route Group (user) — nhóm mà không tạo URL
app/
├── (user)/
│ ├── layout.js
│ ├── page.js → / (KHÔNG phải /user)
│ ├── cart/page.js → /cart
│ └── menu/page.js → /menu
└── admin/
├── layout.js
└── page.js → /admin
Thư mục đặt trong ngoặc tròn không xuất hiện trên URL.
Vì sao dự án cần nó?
Vì khu khách hàng và khu admin cần layout hoàn toàn khác nhau:
Khu (user) |
Khu admin |
|
|---|---|---|
| Navbar | Navbar tối, có giỏ hàng | Sidebar xanh + topbar |
| CSS | globals.css + bootstrap |
bootstrap + style.css + fontawesome |
| Redux Provider | ✅ Có (cần giỏ hàng) | ❌ Không |
Nếu không dùng route group, bạn buộc phải để URL là /user/cart và /admin/...,
hoặc phải viết logic điều kiện phức tạp trong một layout chung.
Đặt tên gì cũng được: (user), (shop), (main) — chỉ để bạn tự phân nhóm.
📚 5. Dynamic Route [id]
app/(user)/product/[id]/page.js → /product/6675905e...
app/(user)/menu/[id]/page.js → /menu/669a978e...
Giá trị thực tế được truyền vào qua props params:
// frontend/app/(user)/product/[id]/page.js
export default function Productdetail({ params }) {
console.log(params.id); // "6675905ecb0dded448a58bb0"
const { data: product } = useSWR(
`${process.env.NEXT_PUBLIC_API_URL}/products/id/${params.id}`,
fetcher
);
// ...
}
Các biến thể
| Cú pháp | Khớp với | params |
|---|---|---|
[id] |
/product/123 |
{ id: "123" } |
[...slug] |
/blog/a/b/c |
{ slug: ["a","b","c"] } |
[[...slug]] |
/blog và /blog/a/b |
{ slug: undefined } hoặc mảng |
Dự án chỉ dùng [id].
💻 6. Layout gốc của khu khách hàng
Tạo frontend/app/(user)/layout.js:
import { Inter } from "next/font/google";
import "./globals.css";
import "@/public/css/bootstrap.min.css";
import Nabar from "./component/navbar.js";
import Providers from "@/redux/provider";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "Lith Perfume",
description: "Website bán nước hoa chính hãng",
};
export default function RootLayout({ children }) {
return (
<html lang="vi">
<body className={inter.className}>
<Providers>
<Nabar />
{children}
</Providers>
<script src="https://kit.fontawesome.com/7b8b40ab0a.js" crossOrigin="anonymous"></script>
<script src="/js/bootstrap.bundle.min.js"></script>
</body>
</html>
);
}
🔍 Giải thích
export const metadata — Next.js đọc và sinh thẻ <title>, <meta> tự động.
Tốt cho SEO. (Source gốc để title: "Drink_ping" — tên của một dự án khác,
dấu vết copy template. Đã sửa thành "Lith Perfume".)
Inter từ next/font/google — Next.js tải font Google về, tự host và tối ưu.
Không có request tới fonts.googleapis.com khi người dùng truy cập → nhanh hơn,
tốt cho quyền riêng tư.
{children} — chỗ mà page.js sẽ được chèn vào.
<Providers> — bọc Redux (bài 13). Phải bọc cả <Nabar/> vì navbar
cần đọc state.cart để hiện số lượng.
<script src="/js/bootstrap.bundle.min.js"> — JavaScript của Bootstrap,
cần cho dropdown, modal, carousel.
⚠️ Lỗi trong source gốc: đường dẫn viết là
src="js/bootstrap.bundle.min.js"(thiếu dấu/đầu). Đây là đường dẫn tương đối, nên:
- Ở
/→ trình duyệt tìm/js/bootstrap...✅- Ở
/product/123→ trình duyệt tìm/product/js/bootstrap...❌ 404Hậu quả: modal đặt hàng, dropdown tài khoản, carousel không hoạt động ở các trang con. Luôn dùng đường dẫn tuyệt đối bắt đầu bằng
/.
⚠️ Lỗi thứ hai:
crossoriginphải viết làcrossOrigintrong JSX (camelCase).
📚 7. Server Component vs Client Component
Đây là khái niệm quan trọng nhất của App Router. Hiểu sai sẽ gặp lỗi liên tục.
Mặc định: Server Component
Trong App Router, mọi component đều chạy trên server trừ khi bạn khai báo khác.
// Không có "use client" → Server Component
export default function Page() {
return <h1>Xin chào</h1>;
}
Server Component:
- Chạy trên server, gửi HTML đã render sẵn về trình duyệt
- Code không được đóng gói vào bundle JS → trang nhẹ hơn
- Có thể
async, truy cập trực tiếp database, đọc file - ❌ Không dùng được
useState,useEffect,onClick, và mọi hook
Client Component — thêm "use client"
"use client" // ← PHẢI ở dòng ĐẦU TIÊN của file
import { useState } from "react";
export default function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
Client Component:
- Vẫn được render trước ở server (SSR), rồi "hydrate" ở trình duyệt
- ✅ Dùng được hooks, sự kiện,
window,localStorage - Code được gửi về trình duyệt → tăng kích thước bundle
Khi nào cần "use client"?
Cần khi component có bất kỳ thứ nào sau đây:
| Dấu hiệu | Ví dụ trong dự án |
|---|---|
useState, useEffect, useRef, useMemo |
product/[id]/page.js |
Sự kiện: onClick, onChange, onSubmit |
addcardbuton.js |
Hook của Next.js: usePathname, useRouter |
navbar.js |
Hook của thư viện: useSWR, useSelector, useDispatch, useFormik |
hầu hết page |
API trình duyệt: window, document, localStorage |
Redux persist |
Quy tắc lan truyền
"use client" lan xuống toàn bộ cây con:
// page.js
"use client";
import Productlist from "./component/productlist"; // ← tự động là client
Nên product.js và productlist.js trong dự án không có "use client"
nhưng vẫn dùng được useSelector — vì chúng luôn được import từ một client component.
⚠️ Đây là cách hoạt động đúng, nhưng mong manh. Nếu sau này ai đó import
Productlistvào một server component, ứng dụng sẽ crash. An toàn hơn là thêm"use client"vào mọi file thực sự cần nó.
⚠️ Lỗi trong source gốc: "use clinet"
// frontend/app/admin/layout.js — dòng đầu tiên
"use clinet" // ❌ Sai chính tả! (clinet → client)
JavaScript coi đây là một chuỗi vô nghĩa, không phải directive. File vẫn là
Server Component. May mà admin/layout.js không dùng hook nào nên chưa lộ lỗi.
Nhưng nếu bạn thêm useState vào, ứng dụng sẽ crash với thông báo khó hiểu.
Cách sửa: viết đúng "use client". Hoặc tốt hơn — bỏ hẳn nó đi vì layout này
không cần là client component.
📚 8. Điều hướng
<Link> — chuyển trang không tải lại
import Link from "next/link";
<Link href="/menu">Menu</Link>
<Link href={`/product/${product._id}`}>Xem chi tiết</Link>
Luôn dùng <Link> thay cho <a> cho các link nội bộ. <Link>:
- Không tải lại toàn trang → nhanh hơn nhiều
- Tự động prefetch trang đích khi link xuất hiện trong viewport
- Giữ nguyên state của layout và Redux
Dùng <a> sẽ khiến trình duyệt tải lại từ đầu — Redux store bị reset
(may là redux-persist khôi phục lại từ localStorage, nhưng vẫn chậm).
⚠️ Source gốc có vài chỗ dùng
<a href="#">cho các nút chưa làm. Không sai, nhưng nên đổi thành<button>cho đúng ngữ nghĩa.
usePathname() — biết đang ở URL nào
"use client";
import { usePathname } from "next/navigation";
const pathname = usePathname(); // "/menu"
<Link className={`nav-link ${pathname == "/" ? "active" : ""}`} href="/">
Trang Chủ
</Link>
Dùng để highlight menu đang active. Dự án dùng ở navbar.js, menu/layout.js, leftbar.js.
useRouter() — chuyển trang bằng code
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
router.push("/"); // chuyển sang trang chủ
router.back(); // quay lại
router.refresh(); // tải lại dữ liệu server
Dùng ở cart/page.js sau khi đặt hàng thành công:
.then(res => {
alert("đặt hàng thành công");
dispatch(removeCart());
closeBtn.current.click();
router.push("/");
})
⚠️ Cực kỳ quan trọng: trong App Router phải import từ
next/navigation, không phảinext/router(đó là của Pages Router cũ). Import sai sẽ gặp lỗi:NextRouter was not mounted.
💻 9. Tạo cấu trúc thư mục hoàn chỉnh
cd frontend
# Windows PowerShell
$dirs = @(
"app/(user)/component",
"app/(user)/menu/[id]",
"app/(user)/product/[id]",
"app/(user)/cart",
"app/(user)/login",
"app/(user)/register",
"app/admin/component",
"app/admin/product/add",
"redux/slices",
"public/css", "public/js", "public/img", "public/font"
)
foreach ($d in $dirs) { New-Item -ItemType Directory -Force -Path $d }
# macOS / Linux
mkdir -p "app/(user)/component" "app/(user)/menu/[id]" "app/(user)/product/[id]" \
"app/(user)/cart" "app/(user)/login" "app/(user)/register" \
app/admin/component app/admin/product/add \
redux/slices public/css public/js public/img public/font
Sau đó xóa app/page.js và app/layout.js mặc định — chúng sẽ được thay bằng
file trong (user)/.
Copy tài nguyên tĩnh
Từ dự án gốc, copy vào frontend/public/:
| Thư mục | Nội dung |
|---|---|
public/css/ |
bootstrap.min.css, style.css, fontawesome.css |
public/js/ |
bootstrap.bundle.min.js, google.chart.js |
public/img/ |
ban1-3.jpg (banner), h1-h10.png (logo thương hiệu) |
public/font/ |
Các file font Font Awesome |
💡 Ảnh sản phẩm (
sp1.jpg...) nằm ở backend, không phải frontend. Frontend tải chúng quahttp://localhost:5000/img/sp1.jpg. Chỉ ảnh giao diện (banner, logo) mới để ởfrontend/public/img/.
⚠️ 10. Lỗi trong source gốc
Lỗi 1 — app/(user)/product/page.js là component rỗng
"use client";
import useSWR from 'swr';
export default function Product() {
} // ← không return gì cả!
Truy cập /product sẽ gặp lỗi:
Error: Product(...): Nothing was returned from render.
This usually means a return statement is missing.
Cách sửa — chọn 1 trong 3:
a) Xóa file (nếu không cần trang /product)
b) Chuyển hướng về /menu:
import { redirect } from "next/navigation";
export default function Product() {
redirect("/menu");
}
c) Làm trang danh sách sản phẩm thật:
"use client";
import useSWR from "swr";
import Productlist from "../component/productlist";
export default function Product() {
const fetcher = (...args) => fetch(...args).then((res) => res.json());
const { data, error, isLoading } = useSWR(
`${process.env.NEXT_PUBLIC_API_URL}/products`, fetcher
);
if (error) return <strong>Đã có lỗi</strong>;
if (isLoading) return <p>Loading...</p>;
return (
<div className="container mt-5">
<h2>Tất cả sản phẩm</h2>
<Productlist data={data} />
</div>
);
}
Lỗi 2 — admin/layout.js chứa <html> và <body> lồng nhau
// frontend/app/admin/layout.js
export default function RootLayout({ children }) {
return (
<html lang="en"> {/* ⚠️ */}
<body> {/* ⚠️ */}
...
</body>
</html>
);
}
Chỉ layout ở gốc mới được có <html> và <body>.
Vì (user) là route group (không tạo URL), Next.js coi app/(user)/layout.js
là root layout. Layout admin nằm ở nhánh khác nên cũng cần root layout riêng.
Cấu trúc hiện tại khiến Next.js lúng túng: hoặc báo lỗi thiếu root layout cho /admin,
hoặc sinh HTML lồng nhau không hợp lệ.
Cách sửa đúng: tách khu admin thành route group riêng:
app/
├── (user)/
│ ├── layout.js ← có <html><body>
│ └── ...
└── (admin)/
├── layout.js ← cũng có <html><body>, hợp lệ vì là nhánh riêng
└── admin/
└── page.js → /admin
Bài 17 sẽ hướng dẫn chi tiết.
Lỗi 3 — metadata trong Client Component
"use clinet" // (sai chính tả nên chưa lộ lỗi)
export const metadata = { ... } // ⚠️
export const metadata chỉ hoạt động trong Server Component. Nếu bạn sửa
"use clinet" thành "use client" cho đúng, Next.js sẽ báo:
You are attempting to export "metadata" from a component marked with "use client",
which is disallowed.
Đây là ví dụ hay: sửa một lỗi làm lộ ra lỗi khác. Cách xử lý là tách phần cần
client (sidebar có usePathname) ra component riêng, giữ layout là server component.
✅ 11. Kiểm thử
Tạo tạm frontend/app/(user)/page.js:
export default function Home() {
return (
<div className="container mt-5">
<h1>Lith Perfume</h1>
<p>Trang chủ đang được xây dựng...</p>
</div>
);
}
Và frontend/app/(user)/layout.js (bản tối giản, chưa có Redux):
import "./globals.css";
import "@/public/css/bootstrap.min.css";
export const metadata = { title: "Lith Perfume" };
export default function RootLayout({ children }) {
return (
<html lang="vi">
<body>{children}</body>
</html>
);
}
Chạy npm run dev, mở http://localhost:3000:
- Thấy tiêu đề "Lith Perfume"
- Tab trình duyệt hiện "Lith Perfume"
- Chữ có style của Bootstrap (font sans-serif, canh lề container)
📝 Bài tập
-
Tạo trang
/aboutgiới thiệu cửa hàng, dùng chung layout(user). -
Tạo
loading.jstrongapp/(user)/menu/:export default function Loading() { return <div className="spinner-border text-dark" role="status" />; }Quan sát nó xuất hiện khi chuyển sang
/menu. -
Tạo
not-found.jsởapp/(user)/với thiết kế 404 đẹp + nút về trang chủ. -
Thử nghiệm Server vs Client: Tạo
app/(user)/test/page.jskhông có"use client", thêmuseStatevào. Đọc kỹ thông báo lỗi. Sau đó thêm"use client"và chạy lại. -
Sửa lỗi
product/page.jstheo cách (b) hoặc (c) ở mục 10. -
Kiểm tra lỗi đường dẫn script: để nguyên
src="js/bootstrap.bundle.min.js", vào/cartvà mở tab Network trong DevTools. Tìm request 404 tới/cart/js/bootstrap.bundle.min.js. Sau đó sửa thành/js/...và kiểm tra lại.
⬅️ Bài trước | Mục lục | Bài tiếp theo: Giao diện với Bootstrap ➡️
All rights reserved