Mục lục

Next.js App Router & Server Components

Hiểu sâu về Server Components, Client Components và cách tối ưu performance với Next.js App Router

Tổng quan

Next.js 13+ App Router mang đến React Server Components (RSC) - một paradigm shift trong cách xây dựng React apps. Hiểu rõ sự khác biệt giữa Server và Client Components là chìa khóa để tối ưu performance.


1. Server Components vs Client Components

Server Components vs Client Components

Giải thích chi tiết hình minh họa:

Cột trái - Server Components (màu xanh):

Đặc điểm:

  • Fetching Data trực tiếp: Có thể gọi database, API, đọc file system
  • No Interactivity: Không có event handlers (onClick, onChange)
  • Rendered on Server: Chạy trên server, tạo HTML và stream về client
  • Zero JavaScript: Không gửi JavaScript code về browser → Giảm bundle size
  • Access Server Resources: Truy cập biến môi trường, database credentials an toàn

Môi trường chạy:

  • Server-side (Node.js runtime)
  • Có quyền truy cập database, file system, server APIs

Khi nào dùng:

tsx:
// ✅ Server Component - Default trong App Router
async function ProductList() {
  // Fetch data trực tiếp từ database
  const products = await db.product.findMany();
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} data={product} />
      ))}
    </div>
  );
}

Cột phải - Client Components (màu cam):

Đặc điểm:

  • useState & useReducer: Quản lý state
  • Event Handlers: onClick, onChange, onSubmit
  • useEffect & useLayoutEffect: Side effects
  • Browser APIs: window, localStorage, navigator
  • Interactive UI: Forms, buttons, animations

Môi trường chạy:

  • Browser (client-side)
  • Hydration: Server render HTML trước, sau đó "hydrate" (gắn JavaScript) trên client

Khi nào dùng:

tsx:
// ✅ Client Component - Phải khai báo "use client"
'use client';

import { useState } from 'react';

function AddToCartButton({ productId }) {
  const [loading, setLoading] = useState(false);
  
  const handleClick = async () => {
    setLoading(true);
    await addToCart(productId);
    setLoading(false);
  };
  
  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

Luồng dữ liệu (Server → Client):

  1. Server-Side Data & HTML Flow:

    • Server Component fetch data từ database
    • Render thành HTML với data đã có
    • Stream HTML về browser
  2. Hydration:

    • Browser nhận HTML (hiển thị ngay nội dung tĩnh)
    • Client Component JavaScript được tải
    • React "hydrate" - gắn event handlers vào HTML tĩnh

2. Composition Patterns (Mô hình kết hợp)

Pattern 1: Server Component bọc Client Component

tsx:
// ✅ BEST: Server Component làm root
// app/products/page.tsx (Server Component)
import ClientSidebar from './ClientSidebar'; // Client Component

async function ProductsPage() {
  const categories = await db.category.findAll(); // Fetch server-side
  
  return (
    <div className="flex">
      {/* Server Component fetch data, Client Component handle UI */}
      <ClientSidebar categories={categories} />
      <ProductList />
    </div>
  );
}

Pattern 2: Passing Server Components as Children

tsx:
// ✅ ADVANCED: Server Component làm children của Client Component
// ClientLayout.tsx
'use client';

export function ClientLayout({ children }) {
  const [sidebarOpen, setSidebarOpen] = useState(false);
  
  return (
    <div>
      <button onClick={() => setSidebarOpen(!sidebarOpen)}>Toggle</button>
      {/* children VẪN là Server Component! */}
      <div>{children}</div>
    </div>
  );
}

// page.tsx (Server Component)
import ClientLayout from './ClientLayout';

async function Page() {
  const data = await fetchData(); // Server-side
  
  return (
    <ClientLayout>
      {/* Component này render trên server */}
      <ServerContent data={data} />
    </ClientLayout>
  );
}

Pattern 3: Avoid Client-Server Boundary Issues

tsx:
// ❌ BAD: Client Component không thể import Server Component
'use client';

import ServerData from './ServerData'; // ERROR!

// ✅ GOOD: Pass Server Component as prop
'use client';

export function ClientWrapper({ serverContent }) {
  return <div>{serverContent}</div>;
}

// Usage in Server Component
<ClientWrapper serverContent={<ServerData />} />

3. Data Fetching Strategies

Strategy 1: Parallel Data Fetching

tsx:
// ✅ BEST: Fetch song song
async function DashboardPage() {
  // Các request chạy song song
  const [user, posts, analytics] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchAnalytics()
  ]);
  
  return (
    <>
      <UserProfile user={user} />
      <PostsList posts={posts} />
      <AnalyticsDashboard data={analytics} />
    </>
  );
}

Strategy 2: Sequential Data Fetching (khi cần)

tsx:
// ✅ GOOD: Fetch tuần tự khi có dependency
async function UserDashboard({ userId }) {
  const user = await fetchUser(userId);
  // Chỉ fetch posts SAU KHI có user
  const posts = await fetchUserPosts(user.id);
  
  return (
    <>
      <UserProfile user={user} />
      <PostsList posts={posts} />
    </>
  );
}

Strategy 3: Streaming với Suspense

tsx:
// ✅ ADVANCED: Stream từng phần
import { Suspense } from 'react';

async function Page() {
  return (
    <div>
      {/* Phần này render ngay */}
      <Header />
      
      {/* Phần này render khi data ready */}
      <Suspense fallback={<Skeleton />}>
        <SlowComponent />
      </Suspense>
      
      {/* Phần này cũng stream riêng */}
      <Suspense fallback={<Skeleton />}>
        <AnotherSlowComponent />
      </Suspense>
    </div>
  );
}

async function SlowComponent() {
  const data = await fetchSlowData(); // 2s
  return <div>{data}</div>;
}

4. Caching & Revalidation

Next.js tự động cache requests

tsx:
// Default: Cache vĩnh viễn
async function getData() {
  const res = await fetch('https://api.example.com/data');
  return res.json();
}

// Revalidate mỗi 60s
async function getData() {
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 60 }
  });
  return res.json();
}

// No cache (dynamic data)
async function getData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store'
  });
  return res.json();
}

// Revalidate on-demand
import { revalidatePath, revalidateTag } from 'next/cache';

async function updatePost() {
  await db.post.update(...);
  revalidatePath('/posts'); // Invalidate cache cho route này
}

5. Performance Optimization Checklist

✅ Do's (Nên làm)

  1. Maximize Server Components
tsx:
// ✅ Default: Server Component
async function Page() {
  const data = await fetchData();
  return <View data={data} />;
}
  1. Push "use client" xuống thấp nhất có thể
tsx:
// ❌ BAD: Toàn bộ page là Client Component
'use client';
function Page() {
  return (
    <div>
      <Header />
      <InteractiveButton /> {/* Chỉ cái này cần client */}
      <Footer />
    </div>
  );
}

// ✅ GOOD: Chỉ component cần thiết là Client
function Page() {
  return (
    <div>
      <Header />
      <InteractiveButton /> {/* Chỉ cái này "use client" */}
      <Footer />
    </div>
  );
}
  1. Sử dụng Server Actions cho mutations
tsx:
// app/actions.ts
'use server';

export async function createPost(formData) {
  const title = formData.get('title');
  await db.post.create({ data: { title } });
  revalidatePath('/posts');
}

// Component (Server Component!)
import { createPost } from './actions';

function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

❌ Don'ts (Tránh)

  1. Không fetch data trong Client Component
tsx:
// ❌ BAD
'use client';
function Posts() {
  const [posts, setPosts] = useState([]);
  
  useEffect(() => {
    fetch('/api/posts')
      .then(res => res.json())
      .then(setPosts);
  }, []);
  
  return <div>{posts.map(...)}</div>;
}

// ✅ GOOD: Fetch trong Server Component
async function Posts() {
  const posts = await db.post.findMany();
  return <div>{posts.map(...)}</div>;
}
  1. Không import Client Component vào Server Component không cần thiết
tsx:
// ❌ BAD: Import carousel làm toàn bộ page thành Client
import Carousel from 'react-slick'; // Client Component

// ✅ GOOD: Tách riêng Client Component
// CarouselWrapper.tsx
'use client';
import Carousel from 'react-slick';
export function CarouselWrapper({ children }) {
  return <Carousel>{children}</Carousel>;
}

6. Common Mistakes

Mistake 1: Mixing Server/Client Logic

tsx:
// ❌ BAD: Không thể dùng useState trong Server Component
async function Page() {
  const [count, setCount] = useState(0); // ERROR!
  const data = await fetchData();
  return <div onClick={() => setCount(count + 1)}>{data}</div>;
}

// ✅ GOOD: Tách thành 2 component
async function Page() {
  const data = await fetchData(); // Server
  return <ClientInteractive data={data} />; // Client
}

Mistake 2: Serialization Issues

tsx:
// ❌ BAD: Không thể pass functions/Date từ Server → Client
async function Page() {
  const data = {
    createdAt: new Date(), // ERROR: Date không serialize được
    onClick: () => {} // ERROR: Function không serialize được
  };
  return <ClientComponent data={data} />;
}

// ✅ GOOD: Chỉ pass JSON-serializable data
async function Page() {
  const data = {
    createdAt: new Date().toISOString(), // String
    // onClick xử lý trong ClientComponent
  };
  return <ClientComponent data={data} />;
}

Mistake 3: Unnecessary Client Components

tsx:
// ❌ BAD: Chỉ vì 1 button mà làm toàn bộ thành Client
'use client';
async function ProductPage({ id }) {
  const product = await fetchProduct(id); // Mất khả năng server fetch!
  
  return (
    <div>
      <ProductDetails product={product} />
      <AddToCartButton productId={id} />
    </div>
  );
}

// ✅ GOOD: Chỉ button là Client Component
async function ProductPage({ id }) {
  const product = await fetchProduct(id); // Server fetch!
  
  return (
    <div>
      <ProductDetails product={product} />
      <AddToCartButton productId={id} /> {/* Chỉ cái này "use client" */}
    </div>
  );
}

7. Bundle Size Analysis

Check Bundle Size:

bash:
# Build và analyze
npm run build

# Output sẽ hiện:
# Route (app)                              Size     First Load JS
# ├ ○ /                                    5.2 kB        85.3 kB
# ├ ƒ /posts/[id]                          3.1 kB        83.2 kB
# └ ○ /about                               2.9 kB        82.9 kB

Ký hiệu:

  • = Static (pre-rendered)
  • ƒ = Dynamic (server-rendered on request)
  • λ = Server-side rendered (SSR)

Optimize Bundle:

tsx:
// ❌ BAD: Import toàn bộ library (300kb)
import _ from 'lodash';

// ✅ GOOD: Import chỉ function cần (5kb)
import debounce from 'lodash/debounce';

// ❌ BAD: Client Component import heavy library
'use client';
import Chart from 'chart.js'; // 200kb vào client bundle

// ✅ GOOD: Dynamic import
'use client';
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('chart.js'), {
  ssr: false, // Không render trên server
  loading: () => <div>Loading chart...</div>
});

8. Best Practices Summary

CategoryBest Practice
DefaultDùng Server Components mặc định
InteractivityChỉ dùng Client Components khi cần interactivity
Data FetchingFetch data trong Server Components
MutationsDùng Server Actions thay vì API routes
Bundle SizePush "use client" xuống thấp nhất có thể
CachingLeverage Next.js automatic caching
StreamingDùng Suspense để stream slow components
CompositionPass Server Components as children của Client Components

Kết luận

React Server Components và Next.js App Router mang lại:

  • Faster initial page load (ít JavaScript client-side)
  • Better SEO (content rendered server-side)
  • Secure data fetching (API keys, database credentials an toàn)
  • Automatic code splitting (Next.js tự động split theo routes)

Hiểu rõ boundary giữa Server và Client Components là then chốt để xây dựng ứng dụng Next.js hiệu quả!

Next step: Đọc Performance OptimizationCaching Strategies để tối ưu toàn diện!

Quảng cáo
mdhorizontal