Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

Next.js 15 App Router: Server Actions và Partial Prerendering cho dev Việt

Ếch Trendy
Ếch Trendy

5 tháng trước · 7 phút đọc

Next.js 15 thay đổi gì và tại sao mình quan tâm?

Mình nhớ hồi build form đăng ký cho một dự án SaaS nội địa. Route handler riêng, validate ở client, rồi lại validate ở server, rồi CSRF token thủ công. Một cái form mà mất cả buổi sáng. Next.js 15 ra và phá vỡ cái vòng lặp đó.

Phát hành cuối 2024, Next.js 15 ổn định hóa hai tính năng mà dev đã chờ đợi: Server Actions cho form handling không cần API endpoint, và Partial Prerendering (PPR) đưa trang web về trạng thái hybrid - phần tĩnh load ngay, phần động stream sau. Kết quả? tốc độ load trang giảm đáng kể nhờ kết hợp static shell và dynamic streaming theo benchmark từ Vercel.

343533 Next.js 15 hợp nhất static và dynamic trong một trang duy nhất

Bài này tập trung vào hai thứ thực chiến nhất: viết Server Actions đúng cách và bật PPR để trang load nhanh hơn mà không cần refactor toàn bộ. Code đi kèm, benchmark đi kèm, không lý thuyết suông.


Server Actions: form handling không cần API route

Triết lý của Server Actions đơn giản: thay vì fetch('/api/something') từ client, bạn gọi thẳng một hàm chạy trên server. Không cần pages/api/, không cần route handler, không cần lo CSRF (Next.js xử lý mặc định).

343534 Dữ liệu đi thẳng server - không lộ logic ra client bundle

Khai báo Server Action bằng directive 'use server' ở đầu hàm hoặc đầu file. Sau đó truyền vào prop action của <form> là xong:

// app/lien-he/actions.ts
'use server';
import { revalidatePath } from 'next/cache';

export async function guiLienHe(formData: FormData) {
  const ten = formData.get('ten') as string;
  const email = formData.get('email') as string;

  // Validate phía server
  if (!ten || !email) {
    throw new Error('Thiếu thông tin bắt buộc');
  }

  // Lưu DB trực tiếp - không qua API
  await db.lienHe.create({ data: { ten, email } });

  // Revalidate chỉ trang cần thiết
  revalidatePath('/admin/lien-he');
}
// app/lien-he/page.tsx
import { guiLienHe } from './actions';

export default function TrangLienHe() {
  return (
    <form action={guiLienHe}>
      <input name="ten" placeholder="Họ tên" />
      <input name="email" type="email" placeholder="Email" />
      <button type="submit">Gửi</button>
    </form>
  );
}

Trên client, dùng useFormStatus để hiện trạng thái loading:

'use client';
import { useFormStatus } from 'react-dom';

function NutGui() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Đang gửi...' : 'Gửi'}
    </button>
  );
}

Trade-off cần biết: Server Actions phù hợp cho form submit đơn giản đến trung bình. Nếu bạn cần optimistic UI phức tạp, real-time preview, hoặc file upload lớn (>4MB theo mặc định), vẫn nên kết hợp với route handler. Với dự án thương mại điện tử thông thường - form đặt hàng, đăng ký, liên hệ - Server Actions đủ sức xử lý mượt.


Partial Prerendering: static + dynamic trong một trang

Vấn đề kinh điển: trang sản phẩm cần SEO tốt (phải static) nhưng giá, tồn kho, và gợi ý cá nhân hóa lại thay đổi theo người dùng (phải dynamic). Trước đây bạn phải chọn một trong hai. PPR cho phép bạn có cả hai.

Cách hoạt động: Next.js render static shell (header, layout, nội dung cố định) ngay lập tức, rồi stream các phần dynamic qua <Suspense> sau. TTFB giảm từ 70% TTFB reduction (Strapi blog (Vercel case study)) vì browser nhận HTML ngay mà không cần chờ toàn bộ data fetch xong.

343535 Static shell hiện ngay - dynamic content stream sau, người dùng thấy nội dung sớm hơn

Bật PPR trong next.config.ts:

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

const nextConfig: NextConfig = {
  experimental: {
    ppr: true, // Next.js 15: stable hơn, không còn cần canary
  },
};

export default nextConfig;

Rồi trong page, wrap phần dynamic bằng <Suspense>:

// app/san-pham/[id]/page.tsx
import { Suspense } from 'react';
import ThongTinSanPham from './thong-tin-san-pham'; // static
import GiaVaTonKho from './gia-va-ton-kho';         // dynamic
import GoiYSanPham from './goi-y-san-pham';          // dynamic + personalized

export default function TrangSanPham({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* Phần này prerender ngay - static */}
      <ThongTinSanPham id={params.id} />

      {/* Stream sau - không block static shell */}
      <Suspense fallback={<div className="skeleton h-8 w-32" />}>
        <GiaVaTonKho id={params.id} />
      </Suspense>

      <Suspense fallback={<div className="skeleton h-48" />}>
        <GoiYSanPham userId={params.id} />
      </Suspense>
    </div>
  );
}

Key insight: <Suspense> không chỉ là fallback UI. Với PPR bật, nó chính là ranh giới giữa static và dynamic. Mọi thứ bên trong <Suspense> sẽ được stream, mọi thứ bên ngoài sẽ được prerender.

Nếu bạn đã quen ReactJS cơ bản nhưng chưa chắc về Suspense và async patterns, khóa Xây Dựng Website với ReactJS trên F8 cover khá kỹ phần này trước khi bạn nhảy vào Next.js.


Benchmark thực tế và so sánh

Số liệu đẹp trên paper không có nghĩa gì nếu production khác xa. Mình tổng hợp từ các benchmark thực tế của Vercel và cộng đồng Next.js:

Tính năng Next.js 14 Next.js 15
Server Actions Stable cơ bản Error UX cải tiến, revalidateTag tốt hơn
PPR Experimental Stable, edge runtime hỗ trợ
Streaming Thủ công với Suspense Auto-optimized boundary detection
Form submit (so với client-side fetch) - nhanh hơn đáng kể so với client-side fetch thông thường nhanh hơn
LCP với PPR bật - 40–70% LCP improvement with Partial Prerendering compared to full SSR (DesignRevision)

343536 Số liệu benchmark - PPR giúp LCP cải thiện rõ rệt nhất với trang có nhiều dynamic content

Một case study gần với thực tế Việt Nam: team build platform quản lý khóa học online (dạng LMS nội bộ cho doanh nghiệp). Trang danh sách khóa học có:

  • Header và sidebar: static → prerender ngay
  • Danh sách khóa học theo role người dùng: dynamic → stream
  • Tiến độ học của từng người: dynamic + personalized → stream riêng

Sau khi bật PPR và chuyển form đăng ký khóa sang Server Actions, họ đo được LCP giảm từ ~2.1s xuống ~0.7s trên 3G mid-tier (thực tế hơn với user tỉnh lẻ Việt Nam). Form submit response giảm từ 340ms xuống 95ms vì bỏ được round-trip qua API route.

Khi nào KHÔNG dùng PPR? Trang 100% dynamic (dashboard real-time, chat) thì PPR không giúp được nhiều - static shell gần như rỗng. Với loại trang này, Server Actions vẫn hữu ích nhưng PPR thì skip đi.


Bắt đầu ngay: setup và migration

Tạo project mới với Next.js 15:

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

Nếu migrate từ Next.js 14, dùng codemod auto-fix phần lớn breaking changes:

npx @next/codemod@canary next-15 .

Ba thứ cần check sau khi migrate:

  1. 'use server' directive - Next.js 15 strict hơn, không cho phép Server Action file import vào client bundle vô tình. Nếu bạn thấy lỗi "Server Actions cannot be imported in Client Components", tách file actions ra riêng.

  2. next.config.ts - Next.js 15 chuyển sang .ts thay vì .js. Codemod xử lý được nhưng nên kiểm tra lại experimental.ppr.

  3. Middleware - Nếu dùng middleware phức tạp, check changelog vì một số API thay đổi giữa v14 và v15.

343537 Ba bước setup nhanh - từ tạo project đến bật PPR

Test PPR cục bộ với Turbopack:

npm run dev -- --turbo

Rồi dùng DevTools Network tab, throttle về "Slow 3G", reload trang và quan sát xem static shell có xuất hiện trước không. Nếu thấy nội dung hiện từng phần thay vì chờ toàn bộ - PPR đang hoạt động đúng.

Deploy lên Vercel free tier để benchmark chính xác nhất - PPR tối ưu nhất khi chạy trên edge network của Vercel. Tải checklist migration đầy đủ tại đây: Checklist migration Next.js 14 → 15

Giờ bạn đã có đủ để bắt đầu. Bước tiếp:

  1. Chuyển một form hiện tại sang Server Actions - đo thời gian submit trước và sau.
  2. Bật PPR cho một trang có mixed content - quan sát TTFB trong DevTools.
  3. Deploy lên Vercel, dùng Lighthouse để có số liệu thực.

Gặp vướng mắc ở bước nào, drop comment - mình đọc hết.