Next.js 15 App Router: Làm chủ Server Components, Server Actions và Partial Prerendering để tối ưu hiệu năng

Development tutorial - IT technology blog
Development tutorial - IT technology blog

App Router không phải bản nâng cấp — nó là cách viết React khác hẳn

Lần đầu mở project App Router, nhiều người nghĩ đây chỉ là folder structure mới. Nhầm. App Router thay đổi cách bạn nghĩ về data fetching, form handling, và cả cách component tree hoạt động.

Mình refactor codebase 50K lines từ Pages Router sang App Router mất khoảng 3 tuần. Bài học đắt nhất không phải là “App Router phức tạp” — mà là phải có test coverage trước khi động vào. Khi cấu trúc component tree thay đổi hoàn toàn, không có test thì bạn không biết mình đang break gì.

Ba khái niệm cần hiểu trước khi bắt đầu:

  • Server Components — chạy hoàn toàn trên server, không gửi JavaScript xuống client
  • Server Actions — thay thế API routes cho các mutation (form submit, update data)
  • Partial Prerendering (PPR) — kết hợp static và dynamic trong cùng một page, không cần chọn một trong hai

Cài đặt Next.js 15 với App Router

npx create-next-app@latest my-app --typescript --tailwind --eslint --app
cd my-app
npm run dev

Cấu trúc thư mục App Router sau khi tạo project:

app/
├── layout.tsx          # Root layout (Server Component)
├── page.tsx            # Home page (Server Component)
├── loading.tsx         # Loading UI (Suspense boundary tự động)
├── error.tsx           # Error boundary (BẮT BUỘC là Client Component)
├── globals.css
└── dashboard/
    ├── layout.tsx      # Nested layout
    └── page.tsx

Điểm người mới hay nhầm: mặc định tất cả component trong thư mục app/ đều là Server Component. Chỉ khi bạn thêm "use client" ở đầu file thì mới là Client Component.

Cấu hình chi tiết

Server Components — Fetch data trực tiếp, không cần useEffect

Thay vì vòng lặp quen thuộc useEffect + useState + loading state, Server Component để bạn viết thẳng vào:

// app/posts/page.tsx — Server Component (mặc định)
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 } // Cache 1 giờ, tự động revalidate
  })
  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Bonus ít người để ý: Server Component không gửi JavaScript xuống client. Dùng thư viện nặng như date-fns hay marked chỉ để render — chạy server-side là xong, bundle client không tăng một byte.

Tips về cache strategy:

  • cache: 'force-cache' — static data ít thay đổi (mặc định)
  • next: { revalidate: N } — ISR, tự refresh sau N giây
  • cache: 'no-store' — dynamic data, luôn fresh mỗi request

Nhưng biết khi nào không nên dùng Server Component còn quan trọng hơn. Cần Client Component khi có useState/useEffect, event handlers, browser APIs, hoặc third-party library dùng React context:

// app/components/LikeButton.tsx — Client Component
"use client"

import { useState } from 'react'

export function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount)
  return (
    <button onClick={() => setCount(c => c + 1)}>
      ❤️ {count}
    </button>
  )
}

Server Actions — Form submit không cần API route

Server Actions là thứ mình thích nhất khi chuyển sang App Router. Trước đây mỗi form cần một API route riêng — validation ở đó, revalidate cache ở đây, redirect bên kia. Giờ gom hết vào một chỗ:

// app/actions/posts.ts
"use server"

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  
  if (!title?.trim()) {
    return { error: 'Tiêu đề không được để trống' }
  }
  
  // Gọi database trực tiếp — chạy hoàn toàn trên server
  await db.post.create({ data: { title, content } })
  
  revalidatePath('/posts') // Xóa cache trang /posts
  redirect('/posts')
}

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } })
  revalidatePath('/posts')
}

Kết hợp với useActionState để hiển thị lỗi phía client:

// app/posts/new/CreatePostForm.tsx
"use client"

import { useActionState } from 'react'
import { createPost } from '../actions/posts'

export function CreatePostForm() {
  const [state, action, isPending] = useActionState(createPost, null)
  
  return (
    <form action={action}>
      {state?.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      <input name="title" placeholder="Tiêu đề" required />
      <textarea name="content" placeholder="Nội dung" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Đang tạo...' : 'Tạo bài'}
      </button>
    </form>
  )
}

Partial Prerendering — Bỏ trade-off giữa static và dynamic

Trước đây bạn buộc phải chọn: trang này static (nhanh, nhưng data không realtime) hay dynamic (data mới nhất, nhưng TTFB cao hơn). PPR bỏ trade-off đó. Static shell của trang hiển thị ngay — TTFB vẫn như static page. Phần dynamic stream vào sau qua Suspense.

Bật PPR trong config:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
}

export default nextConfig

Dùng trong page — phần static render ngay, phần dynamic stream vào sau:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { StaticHeader } from './StaticHeader'       // Render ngay — không chờ
import { UserStats } from './UserStats'             // Stream vào sau
import { RecentActivity } from './RecentActivity'   // Stream vào sau

export default function DashboardPage() {
  return (
    <div>
      {/* Static shell — hiển thị ngay, TTFB = static page */}
      <StaticHeader />
      
      {/* Dynamic — stream vào sau khi static đã hiển thị */}
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
      
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  )
}

Kết quả: user thấy header và layout ngay lập tức. Stats và activity stream vào sau từng phần — không có màn hình trắng, không cần chờ toàn bộ data load xong mới render gì.

Kiểm tra & Monitoring hiệu năng

Đọc output của npm run build

npm run build

Next.js liệt kê từng route với ký hiệu:

  • — Static: render tại build time, nhanh nhất
  • — ISR: static nhưng revalidate định kỳ
  • ƒ — Dynamic: render mỗi request

Mục tiêu là giữ càng nhiều route là hoặc càng tốt. Route nào thành ƒ mà không cần thiết thì cần xem lại — thường do vô tình gọi cookies() hoặc headers() trong component.

Debug cache và route behavior

# Xem log chi tiết về cache hit/miss trong development
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev

# Kiểm tra bundle size từng route
npx @next/bundle-analyzer

Checklist trước khi deploy production

  • Các page static đã dùng generateStaticParams() để pre-render đúng chưa?
  • Không có cookies()/headers() vô tình trong component static (sẽ tự chuyển sang dynamic)
  • loading.tsx có mặt ở các route có data fetching chậm
  • Đã dùng <Image> của Next.js thay <img> thuần — tự động optimize và lazy load
  • Server Actions có error handling rõ ràng — return object lỗi thay vì throw
  • Suspense boundaries được đặt đúng chỗ để tránh waterfall request

Chạy App Router trên production vài tháng, lợi ích lớn nhất mình thấy không phải là tốc độ — mà là codebase sạch hơn hẳn. Không còn getServerSidePropsgetStaticProps lẫn lộn. Không cần tạo API route chỉ để xử lý một cái form. Data fetching nằm đúng chỗ nó thuộc về. Nếu bạn đang dùng Pages Router và chưa biết bắt đầu từ đâu — thử viết một feature nhỏ với App Router trước. Next.js chạy song song cả hai nên không cần migrate toàn bộ ngay.

Share: