Next.js 15 Partial Prerendering (PPR): Kết hợp Vỏ tĩnh với Streaming động

Mục lục bài viết(16 mục)
Next.js 15 giới thiệu Partial Prerendering (PPR), một chiến lược tối ưu hóa mới lạ làm thay đổi cơ bản cách các ứng dụng web động được render và phân phối. PPR không chỉ là một cải tiến gia tăng; nó đại diện cho một sự thay đổi mô hình, kết hợp những khía cạnh tốt nhất của tạo trang tĩnh (SSG) và render phía máy chủ (SSR) với khả năng streaming dựa trên Suspense của React. Hướng dẫn này sẽ phân tích kiến trúc của PPR, tác động của nó đến các chỉ số hiệu suất quan trọng và các cân nhắc triển khai thực tế cho các ứng dụng có độ đồng thời cao.
Tổng quan kiến trúc của Partial Prerendering
PPR hoạt động dựa trên nguyên tắc cung cấp một shell tĩnh, tức thì của một trang trong khi đồng thời truyền tải nội dung động vào các ranh giới Suspense được chỉ định. Điều này đạt được thông qua một quy trình render nhiều giai đoạn:
- Tạo shell tĩnh tại thời điểm build: Trong quá trình
next build, Next.js xác định các phần tĩnh của một trang và tạo ra một shell HTML nhẹ. Shell này bao gồm bố cục cơ bản, điều hướng và bất kỳ nội dung nào không được bọc trong ranh giới Suspense. HTML tĩnh này sau đó được phục vụ trực tiếp từ CDN. - Truyền tải nội dung động phía máy chủ: Khi một yêu cầu đến máy chủ, Next.js render các thành phần động (những thành phần nằm trong ranh giới Suspense) trên máy chủ. Thay vì chờ tất cả dữ liệu được giải quyết, nó truyền tải HTML đã giải quyết cho các thành phần này trực tiếp đến trình duyệt của client qua cùng một kết nối HTTP đã phân phối shell tĩnh.
- Hydration phía client: Khi HTML được truyền tải đến, React dần dần hydrate các thành phần, làm cho chúng tương tác mà không cần tải lại toàn bộ trang.
Cách tiếp cận này đảm bảo Thời gian đến Byte đầu tiên (TTFB) và Lần hiển thị nội dung đầu tiên (FCP) cực kỳ nhanh bằng cách tận dụng các tài sản tĩnh được lưu trữ trong CDN, đồng thời vẫn cung cấp trải nghiệm hoàn toàn động, được cá nhân hóa mà không có các thác nước tìm nạp dữ liệu phía client.
PPR so với SSR/SSG/ISR truyền thống
| Tính năng | SSR truyền thống | SSG truyền thống | ISR truyền thống | Next.js 15 PPR |
|---|---|---|---|---|
| TTFB | Cao (máy chủ chờ tất cả dữ liệu) | Thấp (được lưu trữ trong CDN) | Thấp (được lưu trữ trong CDN) | Thấp (shell tĩnh được lưu trữ trong CDN) |
| FCP | Cao (máy chủ chờ tất cả dữ liệu) | Thấp (được lưu trữ trong CDN) | Thấp (được lưu trữ trong CDN) | Thấp (shell tĩnh được lưu trữ trong CDN) |
| Nội dung động | Hoàn toàn động | Không (tìm nạp phía client) | Hoàn toàn động (xác thực lại) | Hoàn toàn động (truyền tải từ máy chủ) |
| Thời gian Build | N/A (theo yêu cầu) | Cao (tất cả các trang) | Cao (tất cả các trang) | Trung bình (shell tĩnh) |
| Khả năng lưu trữ | Thấp (mỗi yêu cầu) | Cao (CDN) | Cao (CDN, với xác thực lại) | Cao (shell tĩnh) |
| Độ phức tạp | Trung bình | Thấp | Trung bình | Trung bình-Cao (ranh giới Suspense) |
Ảnh hưởng đến hiệu suất: TTFB và FCP
Ưu điểm chính của PPR nằm ở tác động của nó đến TTFB và FCP, đặc biệt đối với các ứng dụng phức tạp như trang sản phẩm thương mại điện tử hoặc bảng điều khiển SaaS.
Trang sản phẩm thương mại điện tử
Hãy xem xét một trang chi tiết sản phẩm (PDP). Bố cục cốt lõi, hình ảnh sản phẩm, tiêu đề và mô tả tĩnh thường nhất quán. Các yếu tố động bao gồm giá, tình trạng còn hàng, đề xuất được cá nhân hóa và đánh giá của người dùng.
Với PPR:
- Yêu cầu ban đầu: Trình duyệt nhận một shell HTML tĩnh chứa hình ảnh sản phẩm, tiêu đề và bố cục gần như ngay lập tức từ CDN. TTFB là tối thiểu.
- FCP: Người dùng nhìn thấy thông tin sản phẩm chính rất nhanh.
- Streaming: Đồng thời, máy chủ tìm nạp giá, tình trạng còn hàng và dữ liệu đề xuất theo thời gian thực. Khi mỗi phần dữ liệu được giải quyết, HTML tương ứng của nó được truyền tải vào trang.
- Hydration: Nội dung được truyền tải dần dần được hydrate, làm cho nút "Thêm vào giỏ hàng" có thể tương tác và các đề xuất có thể nhấp được.
Điều này mang lại trải nghiệm người dùng vượt trội so với SSR truyền thống (nơi người dùng chờ tất cả dữ liệu động trước khi nhìn thấy bất cứ điều gì) hoặc SSG (nơi dữ liệu động yêu cầu tìm nạp phía client, dẫn đến thay đổi bố cục và tương tác bị trì hoãn).
Bảng điều khiển SaaS
Bảng điều khiển SaaS thường có nhiều widget hiển thị dữ liệu thời gian thực (ví dụ: phân tích người dùng, số liệu bán hàng, tình trạng hệ thống).
Với PPR:
- Yêu cầu ban đầu: Bố cục bảng điều khiển, điều hướng và tiêu đề tĩnh được phân phối dưới dạng một shell tĩnh.
- FCP: Người dùng nhìn thấy cấu trúc bảng điều khiển ngay lập tức.
- Streaming: Các widget dữ liệu riêng lẻ (ví dụ: "Người dùng hoạt động," "Doanh thu hôm nay," "Tải hệ thống") được bọc trong ranh giới Suspense. Máy chủ tìm nạp dữ liệu cho mỗi widget một cách độc lập. Khi dữ liệu cho "Người dùng hoạt động" được giải quyết, HTML của nó được truyền tải. Sau đó là "Doanh thu hôm nay," v.v.
- Hydration: Mỗi widget trở nên tương tác khi nội dung của nó đến và được hydrate.
Điều này ngăn chặn một lần tìm nạp dữ liệu chậm làm chặn toàn bộ quá trình render bảng điều khiển, cải thiện hiệu suất cảm nhận và mức độ tương tác của người dùng.
Triển khai Partial Prerendering
PPR tận dụng Suspense của React và các thành phần async/await. Ý tưởng cốt lõi là bọc các phần động hoặc phụ thuộc vào dữ liệu của trang của bạn trong các ranh giới <Suspense>.
// app/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from '@/components/ProductDetails';
import { ProductRecommendations } from '@/components/ProductRecommendations';
import { UserReviews } from '@/components/UserReviews';
import { Skeleton } from '@/components/Skeleton'; // A simple loading skeleton
interface ProductPageProps {
params: {
productId: string;
};
}
export default async function ProductPage({ params }: ProductPageProps) {
const productId = params.productId;
// Static content rendered immediately
const staticProductInfo = await getStaticProductInfo(productId);
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-4">{staticProductInfo.name}</h1>
<img src={staticProductInfo.imageUrl} alt={staticProductInfo.name} className="w-full h-64 object-cover mb-4" />
<p className="text-gray-700 mb-6">{staticProductInfo.description}</p>
{/* Dynamic content wrapped in Suspense boundaries */}
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Product Details</h2>
<Suspense fallback={<Skeleton height="h-48" />}>
{/* ProductDetails is an async component that fetches dynamic data */}
<ProductDetails productId={productId} />
</Suspense>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Recommendations</h2>
<Suspense fallback={<Skeleton height="h-32" />}>
{/* ProductRecommendations is an async component */}
<ProductRecommendations productId={productId} />
</Suspense>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Customer Reviews</h2>
<Suspense fallback={<Skeleton height="h-64" />}>
{/* UserReviews is an async component */}
<UserReviews productId={productId} />
</Suspense>
</section>
</div>
);
}
// Example of a static data fetch (could be from a CMS or build-time data)
async function getStaticProductInfo(productId: string) {
// Simulate a fast, static data fetch
return {
name: `Product ${productId} - Static Title`,
imageUrl: `/images/product-${productId}.jpg`,
description: `This is a static description for product ${productId}. It provides general information that doesn't change frequently.`,
};
}
// components/ProductDetails.tsx
import { delay } from '@/lib/utils'; // Utility for simulating network delay
interface ProductDetailsProps {
productId: string;
}
export async function ProductDetails({ productId }: ProductDetailsProps) {
// Simulate a dynamic data fetch (e.g., real-time price, stock)
await delay(1500); // Simulate network latency
const price = (Math.random() * 100 + 50).toFixed(2);
const stock = Math.floor(Math.random() * 200);
return (
<div className="border p-4 rounded-lg bg-white shadow-sm">
<p className="text-xl font-bold text-green-600 mb-2">Price: ${price}</p>
<p className="text-lg text-gray-800">In Stock: {stock} units</p>
<button className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
Add to Cart
</button>
</div>
);
}
// components/ProductRecommendations.tsx
import { delay } from '@/lib/utils';
interface ProductRecommendationsProps {
productId: string;
}
export async function ProductRecommendations({ productId }: ProductRecommendationsProps) {
await delay(2500); // Simulate longer network latency for recommendations
const recommendations = [
`Related Product A for ${productId}`,
`Related Product B for ${productId}`,
`Related Product C for ${productId}`,
];
return (
<div className="border p-4 rounded-lg bg-white shadow-sm">
<ul className="list-disc pl-5">
{recommendations.map((rec, i) => (
<li key={i} className="text-gray-700">{rec}</li>
))}
</ul>
</div>
);
}
// components/UserReviews.tsx
import { delay } from '@/lib/utils';
interface UserReviewsProps {
productId: string;
}
export async function UserReviews({ productId }: UserReviewsProps) {
await delay(3000); // Simulate even longer latency for reviews
const reviews = [
{ user: 'Alice', rating: 5, comment: `Excellent product for ${productId}!` },
{ user: 'Bob', rating: 4, comment: `Good value, but delivery was slow.` },
];
return (
<div className="border p-4 rounded-lg bg-white shadow-sm">
{reviews.map((review, i) => (
<div key={i} className="mb-4 pb-4 border-b last:border-b-0">
<p className="font-semibold">{review.user} - <span className="text-yellow-500">{'★'.repeat(review.rating)}</span></p>
<p className="text-gray-700">{review.comment}</p>
</div>
))}
</div>
);
}
// components/Skeleton.tsx
interface SkeletonProps {
height?: string;
}
export function Skeleton({ height = 'h-24' }: SkeletonProps) {
return (
<div className={`animate-pulse bg-gray-200 rounded-md ${height}`}></div>
);
}
// lib/utils.ts
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
Trong thiết lập này:
- Bản thân
ProductPagelà một thành phầnasync, nhưng quá trình render ban đầu của nó nhanh vìgetStaticProductInfođược giả định là nhanh hoặc đã được tìm nạp trước. ProductDetails,ProductRecommendationsvàUserReviewscũng là các thành phầnasync, mô phỏng việc tìm nạp dữ liệu. Chúng được bọc trong<Suspense>, cung cấp giao diện người dùng dự phòng tức thì (Skeleton) trong khi dữ liệu của chúng đang tải.- Shell tĩnh bao gồm các thẻ
h1,imgvàptừProductPage. Các dự phòngSuspensecũng là một phần của shell tĩnh ban đầu.
Những lỗi cần lưu ý khi di chuyển & Khắc phục sự cố
Việc di chuyển các ứng dụng hiện có hoặc phát triển các ứng dụng mới với PPR đòi hỏi phải xem xét cẩn thận các mẫu tìm nạp dữ liệu và các tính năng đồng thời của React.
cookies() và headers() trong PPR
Các hàm cookies() và headers() từ next/headers là các hàm động. Khi được sử dụng trong một thành phần là một phần của shell tĩnh, chúng buộc toàn bộ trang phải được render động trên máy chủ, làm vô hiệu hóa PPR cho trang đó.
Vấn đề: Sử dụng cookies() hoặc headers() bên ngoài ranh giới Suspense.
// app/page.tsx (BAD EXAMPLE)
import { cookies } from 'next/headers';
export default function MyPage() {
const cookieStore = cookies(); // This makes the whole page dynamic
const theme = cookieStore.get('theme')?.value || 'light';
return (
<div className={`theme-${theme}`}>
{/* ... rest of your page */}
</div>
);
}
Cách khắc phục: Cô lập việc sử dụng tiêu đề/cookie động trong các thành phần được bọc bởi Suspense, hoặc đảm bảo chúng chỉ được sử dụng trong các tuyến đường thực sự động. Đối với chủ đề, hãy xem xét trạng thái phía client hoặc một thành phần client chuyên dụng.
// app/page.tsx (GOOD EXAMPLE)
import { Suspense } from 'react';
import { DynamicHeader } from '@/components/DynamicHeader';
export default function MyPage() {
return (
<div>
<Suspense fallback={<div>Loading Header...</div>}>
<DynamicHeader /> {/* DynamicHeader uses cookies() */}
</Suspense>
{/* ... rest of your static page content */}
</div>
);
}
// components/DynamicHeader.tsx
import { cookies } from 'next/headers';
export async function DynamicHeader() {
const cookieStore = cookies();
const userName = cookieStore.get('userName')?.value || 'Guest';
return <header>Welcome, {userName}!</header>;
}
Điều này đảm bảo chỉ thành phần DynamicHeader được render động trên máy chủ, trong khi phần còn lại của MyPage vẫn có thể là một phần của shell tĩnh.
Tìm nạp dữ liệu động
Bất kỳ lệnh gọi fetch hoặc truy vấn cơ sở dữ liệu nào trong một Server Component async mà không được bọc trong ranh giới Suspense sẽ chặn quá trình tạo shell tĩnh ban đầu nếu nó chậm.
Vấn đề: Tìm nạp dữ liệu chậm bên ngoài Suspense.
// app/dashboard/page.tsx (BAD EXAMPLE)
import { getCriticalMetrics } from '@/lib/api';
export default async function DashboardPage() {
const metrics = await getCriticalMetrics(); // This fetch blocks the entire page
return (
<div>
<h1>Dashboard</h1>
<p>Critical Metric: {metrics.value}</p>
{/* ... other components, potentially in Suspense */}
</div>
);
}
Nếu getCriticalMetrics chậm, toàn bộ bảng điều khiển sẽ chờ, làm mất đi lợi ích của PPR đối với lần render ban đầu.
Cách khắc phục: Bọc tất cả các lần tìm nạp dữ liệu có khả năng chậm hoặc động trong các ranh giới Suspense.
// app/dashboard/page.tsx (GOOD EXAMPLE)
import { Suspense } from 'react';
import { CriticalMetrics } from '@/components/CriticalMetrics';
import { SalesChart } from '@/components/SalesChart';
export default function DashboardPage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">Dashboard Overview</h1>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Key Performance Indicators</h2>
<Suspense fallback={<Skeleton height="h-24" />}>
<CriticalMetrics /> {/* Async component fetching data */}
</Suspense>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Sales Trends</h2>
<Suspense fallback={<Skeleton height="h-96" />}>
<SalesChart /> {/* Another async component */}
</Suspense>
</section>
</div>
);
}
// components/CriticalMetrics.tsx
import { delay } from '@/lib/utils';
export async function CriticalMetrics() {
await delay(2000); // Simulate slow API call
const value = (Math.random() * 1000).toFixed(0);
return <div className="p-4 border rounded-lg bg-green-50">Current Metric: {value}</div>;
}
Điều này đảm bảo tiêu đề và bố cục Dashboard Overview được phân phối ngay lập tức, với các số liệu được truyền tải khi chúng có sẵn.
Thành phần use client và PPR
Các thành phần client không tham gia vào quá trình truyền tải phía máy chủ theo cách mà các Server Component làm. Nếu một thành phần client cần dữ liệu động, nó sẽ tìm nạp dữ liệu đó trên client sau khi hydrate, có khả năng dẫn đến các thác nước. Để có PPR tối ưu, hãy đẩy việc tìm nạp dữ liệu càng cao càng tốt vào các Server Component.
Vấn đề: Thành phần client tìm nạp dữ liệu.
// components/ClientCounter.tsx (BAD EXAMPLE for PPR)
'use client';
import { useState, useEffect } from 'react';
export function ClientCounter() {
const [count, setCount] = useState(0);
const [dynamicValue, setDynamicValue] = useState(null);
useEffect(() => {
// This fetch happens on the client after hydration
fetch('/api/dynamic-value')
.then(res => res.json())
.then(data => setDynamicValue(data.value));
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
{dynamicValue && <p>Dynamic Value: {dynamicValue}</p>}
</div>
);
}
Mặc dù hợp lệ cho tương tác phía client, nếu dynamicValue là nội dung quan trọng, việc tìm nạp nó theo cách này sẽ trì hoãn sự xuất hiện của nó cho đến khi thành phần client hydrate và thực thi useEffect của nó.
Cách khắc phục: Truyền dữ liệu động dưới dạng props từ một Server Component cha.
// app/page.tsx
import { Suspense } from 'react';
import { ServerFetchedClientCounter } from '@/components/ServerFetchedClientCounter';
import { getInitialCount } from '@/lib/api'; // Server-side data fetch
export default async function HomePage() {
const initialCount = await getInitialCount(); // Fetch on server
return (
<div>
<h1>Home</h1>
<Suspense fallback={<div>Loading Counter...</div>}>
<ServerFetchedClientCounter initialCount={initialCount} />
</Suspense>
</div>
);
}
// components/ServerFetchedClientCounter.tsx
'use client';
import { useState } from 'react';
interface ServerFetchedClientCounterProps {
initialCount: number;
}
export function ServerFetchedClientCounter({ initialCount }: ServerFetchedClientCounterProps) {
const [count, setCount] = useState(initialCount); // Initial state from server
return (
<div className="border p-4 rounded-lg bg-blue-50">
<p className="text-lg">Server-initialized Count: {count}</p>
<button
onClick={() => setCount(c => c + 1)}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
>
Increment
</button>
</div>
);
}
// lib/api.ts
import { delay } from './utils';
export async function getInitialCount() {
await delay(1000); // Simulate server-side data fetch
return 42;
}
Ở đây, initialCount được tìm nạp trên máy chủ và được truyền dưới dạng một prop, đảm bảo thành phần client có dữ liệu ban đầu mà không cần tìm nạp bổ sung phía client.
Các câu hỏi thường gặp
1. PPR ảnh hưởng đến SEO như thế nào?
PPR nói chung là có lợi cho SEO. Shell tĩnh ban đầu, bao gồm nội dung quan trọng, có sẵn ngay lập tức cho các trình thu thập thông tin của công cụ tìm kiếm, đảm bảo lập chỉ mục nhanh chóng. Nội dung động được truyền tải sau đó cũng là một phần của HTML được render phía máy chủ, vì vậy các trình thu thập thông tin thực thi JavaScript (như Googlebot) cuối cùng sẽ thấy toàn bộ nội dung. FCP và TTFB được cải thiện cũng góp phần tích cực vào thứ hạng tìm kiếm.
2. Tôi có thể sử dụng PPR với generateStaticParams không?
Có. generateStaticParams được sử dụng để render trước các tuyến đường tại thời điểm build. Khi kết hợp với PPR, Next.js sẽ tạo một shell tĩnh cho mỗi tuyến đường được render trước này. Nội dung động trong các ranh giới Suspense vẫn sẽ được truyền tải theo yêu cầu khi các tuyến đường đó được truy cập. Điều này lý tưởng cho các trang có nhiều bố cục tương tự nhưng dữ liệu động khác nhau (ví dụ: hàng nghìn trang sản phẩm).
3. Tác động của các ranh giới Suspense lồng nhau sâu đến hiệu suất là gì?
Các ranh giới Suspense lồng nhau sâu có thể làm tăng độ phức tạp của quá trình truyền tải và có khả năng dẫn đến việc xả mạng chi tiết hơn, nhưng có thể thường xuyên hơn. Mặc dù React được tối ưu hóa cho điều này, việc lồng ghép quá mức mà không có lợi ích hiệu suất rõ ràng có thể gây ra chi phí. Nói chung, nên sử dụng Suspense tại các ranh giới nội dung hợp lý nơi việc tìm nạp dữ liệu xảy ra, thay vì bọc mọi yếu tố. Ưu tiên bọc các thành phần tìm nạp dữ liệu độc lập hoặc có khả năng chậm.
4. PPR có thay thế getServerSideProps hay getStaticProps không?
PPR, trong ngữ cảnh của App Router, phần lớn thay thế nhu cầu về getServerSideProps và getStaticProps bằng cách tích hợp trực tiếp các chức năng của chúng vào Server Component. Một Server Component async có thể tìm nạp dữ liệu, hoạt động hiệu quả như getServerSideProps cho cây con cụ thể của nó. Nếu không có hàm động nào (cookies(), headers(), searchParams) được sử dụng và không có tùy chọn revalidate nào được đặt, thành phần sẽ hoạt động như getStaticProps. PPR sau đó tối ưu hóa việc phân phối các Server Component này bằng cách truyền tải. Đối với các trang hoàn toàn tĩnh và không yêu cầu nội dung động, một Server Component đơn giản không có tìm nạp async hoặc Suspense vẫn sẽ hoàn toàn tĩnh.
5. Xử lý lỗi hoạt động như thế nào với PPR và Suspense?
Các lỗi trong ranh giới Suspense có thể được bắt bởi một thành phần Error Boundary. Nếu lỗi xảy ra trong quá trình truyền tải phía máy chủ của một thành phần, Error Boundary sẽ render giao diện người dùng dự phòng của nó trên client sau khi luồng cho phân đoạn đó hoàn tất hoặc gặp lỗi. Điều này ngăn chặn một lỗi thành phần duy nhất làm sập toàn bộ trang. Điều quan trọng là phải triển khai các ranh giới lỗi mạnh mẽ xung quanh các thành phần được bọc Suspense của bạn.
// components/ErrorBoundary.tsx
'use client';
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_: Error): ErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
// You can log the error to an error reporting service here
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage:
// <ErrorBoundary fallback={<div>Something went wrong loading recommendations.</div>}>
// <Suspense fallback={<Skeleton height="h-32" />}>
// <ProductRecommendations productId={productId} />
// </Suspense>
// </ErrorBoundary>
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

TIÊU ĐỀ: Next.js 15 Partial Prerendering (PPR): Hybrid Streaming, Vòng đời Cache & Kiến trúc Suspense
TÓM TẮT: Hướng dẫn toàn diện về Next.js 15 Partial Prerendering (PPR): hybrid streaming, vòng đời cache và kiến trúc Suspense với kiến trúc cấp độ sản xuất và các ví dụ mã.
Read more
TanStack Query v5 với Next.js 15: Cập nhật lạc quan, đồng bộ hóa bộ nhớ đệm & Server Actions
Hướng dẫn toàn diện về TanStack Query v5 với Next.js 15: cập nhật lạc quan, đồng bộ hóa bộ nhớ đệm và Server Actions với kiến trúc cấp độ sản xuất và ví dụ mã.
Read more
Cấu trúc thư mục Next.js App Router: Các phương pháp hay nhất & Kiến trúc doanh nghiệp (2026)
Hướng dẫn cấu trúc thư mục Next.js App Router đã được kiểm nghiệm trong thực tế. Tìm hiểu về route groups, private folders, colocation vs FSD, và tải xuống một template doanh nghiệp có khả năng mở rộng.
Read more