EngineeringSenior DeveloperTechnical Sessions

Session 08: Next.js App Router & Server Components

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

StrategyBuild TimeRequest TimeStale Data?Use Case
SSGGenerate HTMLServe cachedCó (rebuild cần)Blog, docs, marketing
ISRGenerate HTMLServe cached, revalidate backgroundTạm staleProduct pages, catalog
SSR-Generate mỗi requestKhông bao giờDashboard, personalized
CSR-Client fetchKhôngAdmin 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

ConceptKey Takeaway
App RouterFile-system routing, nested layouts, parallel routes
Server ComponentsDefault, zero JS, direct DB access
Client Components'use client', interactivity, event handlers
Server ActionsMutations từ client, form handling, revalidation
Caching4 layers: Request Memo → Data Cache → Full Route → Router Cache
RenderingSSG (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.