Mục tiêu: Master Next.js App Router, hiểu Server vs Client Components, caching strategies, và data fetching patterns.
1. App Router Architecture
File-System Routing
app/
├── layout.tsx ← Root layout (shared UI)
├── page.tsx ← / (home page)
├── loading.tsx ← Loading UI (Suspense boundary)
├── error.tsx ← Error UI (Error Boundary)
├── not-found.tsx ← 404 page
├── products/
│ ├── layout.tsx ← Nested layout cho /products/*
│ ├── page.tsx ← /products
│ ├── loading.tsx ← Loading cho products section
│ ├── [id]/
│ │ ├── page.tsx ← /products/:id
│ │ └── @modal/ ← Parallel route (modal)
│ │ └── page.tsx
│ └── (filters)/ ← Route group (không ảnh hưởng URL)
│ ├── layout.tsx ← Layout cho filtered views
│ └── category/
│ └── [slug]/
│ └── page.tsx ← /products/category/:slug
├── api/
│ └── products/
│ └── route.ts ← API Route Handler: /api/products
└── (auth)/ ← Route group cho auth pages
├── login/
│ └── page.tsx ← /login
└── register/
└── page.tsx ← /register
Layouts - Shared UI, Preserved State
tsx// app/layout.tsx - Root Layout (BẮT BUỘC)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="vi">
<body>
<Providers> {/* Theme, Auth, QueryClient providers */}
<Header />
<main>{children}</main> {/* Chỉ phần này re-render khi navigate */}
<Footer />
</Providers>
</body>
</html>
);
}
// app/dashboard/layout.tsx - Nested Layout
export default function DashboardLayout({
children,
analytics, // Parallel route: @analytics
}: {
children: React.ReactNode;
analytics: React.ReactNode;
}) {
return (
<div className="dashboard-grid">
<Sidebar />
<div className="main-content">{children}</div>
<aside className="analytics-panel">{analytics}</aside>
</div>
);
}
Loading & Error UI
tsx// app/products/loading.tsx - Auto Suspense boundary
export default function ProductsLoading() {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
);
}
// app/products/error.tsx - Auto Error Boundary
'use client'; // Error components PHẢI là Client Component
export default function ProductsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log error to monitoring
reportError(error);
}, [error]);
return (
<div className="error-container">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
2. Server Components vs Client Components
Mental Model
Server Components (DEFAULT):
┌──────────────────────────────┐
│ Chạy trên SERVER only │
│ ✅ Truy cập DB trực tiếp │
│ ✅ fetch data (no API call) │
│ ✅ Access file system │
│ ✅ Zero JS bundle size │
│ ❌ Không useState/useEffect │
│ ❌ Không event handlers │
│ ❌ Không browser APIs │
└──────────────────────────────┘
Client Components ('use client'):
┌──────────────────────────────┐
│ Render trên CẢ server + client │
│ ✅ useState, useEffect │
│ ✅ Event handlers (onClick) │
│ ✅ Browser APIs │
│ ❌ Tăng bundle size │
│ ❌ Không truy cập DB trực tiếp │
└──────────────────────────────┘
Pattern: Server Component fetch, Client Component render
tsx// app/products/page.tsx - Server Component
import { ProductFilter } from './product-filter'; // Client Component
async function getProducts(category?: string) {
// Truy cập DB trực tiếp - KHÔNG cần API!
const products = await db.product.findMany({
where: category ? { category } : undefined,
orderBy: { createdAt: 'desc' },
});
return products;
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
}) {
const params = await searchParams;
const products = await getProducts(params.category);
return (
<div>
<h1>Products</h1>
{/* Client Component cho interactivity */}
<ProductFilter currentCategory={params.category} />
{/* Server-rendered list */}
<ProductGrid products={products} />
</div>
);
}
// components/product-filter.tsx - Client Component
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
export function ProductFilter({ currentCategory }: { currentCategory?: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const handleCategoryChange = (category: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set('category', category);
router.push(`/products?${params.toString()}`);
};
return (
<select
value={currentCategory || ''}
onChange={(e) => handleCategoryChange(e.target.value)}
>
<option value="">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
);
}
Composition Pattern: Client wraps Server
tsx// ✅ Server Component CÓ THỂ là children của Client Component
// Client Component
'use client';
export function Accordion({ children, title }: { children: ReactNode; title: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>{title}</button>
{isOpen && children} {/* children có thể là Server Component */}
</div>
);
}
// Server Component page
export default async function Page() {
const data = await fetchData(); // Server-only
return (
<Accordion title="Details">
<ServerRenderedContent data={data} /> {/* Server Component */}
</Accordion>
);
}
3. Data Fetching
Server Actions (Next.js 14+)
tsx// app/actions/order.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createOrder(formData: FormData) {
// Validation
const rawData = {
productId: formData.get('productId') as string,
quantity: parseInt(formData.get('quantity') as string),
};
// Server-side validation (zod)
const validated = createOrderSchema.safeParse(rawData);
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors };
}
// Database operation
const order = await db.order.create({
data: {
productId: validated.data.productId,
quantity: validated.data.quantity,
userId: await getCurrentUserId(),
},
});
// Revalidate cache
revalidatePath('/orders');
redirect(`/orders/${order.id}`);
}
// Client component sử dụng Server Action
'use client';
import { createOrder } from '@/app/actions/order';
import { useActionState } from 'react';
export function CreateOrderForm() {
const [state, formAction, isPending] = useActionState(createOrder, null);
return (
<form action={formAction}>
<input name="productId" required />
<input name="quantity" type="number" required />
{state?.errors && <ErrorList errors={state.errors} />}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create Order'}
</button>
</form>
);
}
Route Handlers
tsx// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const category = searchParams.get('category');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const products = await db.product.findMany({
where: category ? { category } : undefined,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({
data: products,
meta: { page, limit, total: await db.product.count() },
});
}
export async function POST(request: NextRequest) {
const body = await request.json();
// Auth check
const session = await getServerSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const product = await db.product.create({ data: body });
return NextResponse.json(product, { status: 201 });
}
4. Caching Layers
┌─────────────────────────────────────────────────────┐
│ Request Flow │
│ │
│ Client ──→ Full Route Cache ──→ Data Cache │
│ (HTML cache) (fetch cache) │
│ │ │ │
│ ▼ ▼ │
│ Route Handler ──→ Request Memoization │
│ (Server Component) (same request dedup) │
│ │ │
│ ▼ │
│ Data Source (DB / API) │
└─────────────────────────────────────────────────────┘
Revalidation Strategies
tsx// 1. Time-based revalidation
// Page level
export const revalidate = 3600; // Revalidate every hour
// Fetch level
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 },
});
// 2. On-demand revalidation
// Revalidate path
import { revalidatePath } from 'next/cache';
revalidatePath('/products'); // Page
revalidatePath('/products', 'layout'); // Layout + pages below
// Revalidate tag
import { revalidateTag } from 'next/cache';
const data = await fetch('https://api.example.com/products', {
next: { tags: ['products'] },
});
// Khi data thay đổi:
revalidateTag('products'); // Invalidate tất cả fetch có tag 'products'
// 3. No cache
const data = await fetch('https://api.example.com/live-data', {
cache: 'no-store',
});
// Hoặc page level
export const dynamic = 'force-dynamic';
5. SSR vs SSG vs ISR - Decision Matrix
| Strategy | Build Time | Request Time | Stale Data? | Use Case |
|---|---|---|---|---|
| SSG | Generate HTML | Serve cached | Có (rebuild cần) | Blog, docs, marketing |
| ISR | Generate HTML | Serve cached, revalidate background | Tạm stale | Product pages, catalog |
| SSR | - | Generate mỗi request | Không bao giờ | Dashboard, personalized |
| CSR | - | Client fetch | Không | Admin panels, SPA |
tsx// SSG: generateStaticParams
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { id: true } });
return products.map(p => ({ id: p.id }));
}
// ISR: revalidate interval
export const revalidate = 3600; // ISR: regenerate every hour
// SSR: force dynamic
export const dynamic = 'force-dynamic';
6. Middleware
tsx// middleware.ts (root of project)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 1. Authentication check
const token = request.cookies.get('auth-token');
const isAuthPage = request.nextUrl.pathname.startsWith('/login');
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');
if (isProtectedRoute && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (isAuthPage && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// 2. Add headers
const response = NextResponse.next();
response.headers.set('x-request-id', crypto.randomUUID());
// 3. Geolocation-based routing
const country = request.geo?.country || 'US';
response.cookies.set('country', country);
return response;
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
Tổng Kết Session 08
| Concept | Key Takeaway |
|---|---|
| App Router | File-system routing, nested layouts, parallel routes |
| Server Components | Default, zero JS, direct DB access |
| Client Components | 'use client', interactivity, event handlers |
| Server Actions | Mutations từ client, form handling, revalidation |
| Caching | 4 layers: Request Memo → Data Cache → Full Route → Router Cache |
| Rendering | SSG (build), ISR (background), SSR (per-request), CSR (client) |
Bài tập: Tạo Next.js app với App Router: product listing page (SSG + ISR), product detail (dynamic), và shopping cart (Client Component). Implement Server Action cho add-to-cart.