← Tất cả bài viết
Page Transition Next.js Vỡ Vì Redirect: Cách Sửa Tận Gốc
Page transition Next.js vỡ vì redirect? App Router commit route redirect trước — một trang rỗng. Đây là nguyên nhân gốc và cách sửa triệt để, không vá tạm.

Một cái link duy nhất trong menu của một site khách hàng đã làm vỡ custom page transition của tôi hai lần. Lần đầu: bấm vào, overlay fade in, router.push() chạy, overlay fade out — và lộ ra một trang trắng trơn. Tôi vá animation, ship, quên nó đi. Một tuần sau đúng cái link đó quay lại với một bộ mặt khác: white flash và layout shift giật đùng đùng lúc trang vừa vào. Cùng một link. Cùng một nguyên nhân. Lý do page transition Next.js vỡ vì redirect nằm ở chỗ App Router commit route redirect trước — và cách sửa bền vững là xoá cái redirect đi, không phải vá animation.
Dưới đây là toàn bộ khám nghiệm, những con số chứng minh nó, và ba cách sửa xếp theo đúng thứ tự quan trọng.
Bối cảnh (một custom SPA transition bình thường)
Không có gì lạ — đây là mô hình đa số site creative đang dùng:
- Overlay full-screen fade in (che màn hình).
router.push()chạy.- Overlay fade out (lộ trang mới).
Các reveal được gate bằng một store zustand nhỏ, đưa trang qua ba trạng thái — LEAVE → PLAY → ENTER. Mỗi component có animation sẽ subscribe trong useEffect vào một event one-shot tên pagePlay, và chạy intro khi event bắn. Trước đó, phần tử nằm im ở visibility: hidden từ lúc mount. Overlay còn kiêm thêm một việc: nó che luôn cú reset smooth scroll bắt buộc phải xảy ra giữa hai route.
Cái store đó là tường chịu lực của cả hệ thống. Nó chạy hoàn hảo — cho đến khi có một route nói dối về nơi nó dẫn tới.
Triệu chứng, và dấu vết tố cáo
Cái link hỏng trỏ tới một route index của một section — trang mà việc duy nhất của nó là redirect() phía server sang slug con đầu tiên (/guides → /guides/getting-started).
Triệu chứng:
- Bấm link trong app → trang đích hiện ra trắng trơn. Header và footer có, nội dung vô hình.
- Mở đúng cái URL cuối đó trực tiếp ở tab mới → hoàn hảo. Mọi reveal chạy đủ.
Sự bất đối xứng đó chính là toàn bộ dấu vết. Một lỗi chỉ xuất hiện khi điều hướng trong app và không bao giờ xuất hiện khi load trực tiếp thì không bao giờ là lỗi CSS, và hiếm khi là lỗi animation — nó là lỗi vòng đời (lifecycle). Có gì đó trong bản thân cú điều hướng khác đi, và việc bạn cần làm là đi đo cú điều hướng, chứ không phải đo animation. (Tôi đã học đúng bài này theo cách đau đớn khi một WebGL hero biến mất lúc bấm Back — khác API, cùng một loại sai lầm.)
Vì sao page transition Next.js vỡ vì redirect
Đây là mấu chốt, và bạn có thể tự kiểm chứng trong mười phút:
Khi điều hướng phía client, Next.js App Router commit route redirect trước — một trang rỗng (stub) — rồi mới đi tiếp sang trang đích thật.
Nó không phải một cú điều hướng. Nó là hai. Route redirect là một trang thật, được commit, được render, nằm chen vào giữa: nó có layout của bạn (header, footer), và không có nội dung. Sau đó một cú điều hướng thứ hai mới thay nó bằng trang đích thật.
Tôi chứng minh điều này bằng cách lấy mẫu DOM mỗi khung hình (một lần trên mỗi frame) xuyên suốt cú điều hướng (công thức ở dưới). Cột đáng chú ý là document.body.scrollHeight:
| Frame | location.pathname | body.scrollHeight |
|---|---|---|
| … | / (trang xuất phát) | 4212 |
| n | /guides | 517 ← stub: header + footer, không nội dung |
| n + ~1.7s (dev) | /guides/getting-started | 4699 ← trang thật |
517 pixel chiều cao kẹp giữa hai trang cao đầy đủ. Đó là một layout rỗng ruột. Nhìn thấy dòng đó là mọi thứ sụp đổ về một lời giải thích duy nhất — và cú nhảy thừa đó làm hỏng hai thứ hoàn toàn độc lập nhau.
Hỏng 1 — trang đích trắng trơn
Event one-shot pagePlay bắn cho cái stub. Các component reveal của trang thật mount sau đó. Subscription trong useEffect của chúng chỉ bắt được những thay đổi tương lai của store, nên chúng lỡ mất event đó vĩnh viễn, và mọi phần tử nằm chết ở visibility: hidden từ lúc mount.
Animation chưa bao giờ hỏng. Nó chạy đúng giờ, trên một trang không có gì để chạy.
Hỏng 2 — white flash và layout shift
Reveal của overlay được gate bằng một bộ đếm "assets loaded" mà khi đọc kỹ, thực chất chỉ là một timer ~100ms đội cái tên nghe rất nghiêm túc. Thế nên overlay nhấc lên trong lúc trang đích còn đang trên đường tới — lộ ra cái stub rỗng, rồi nội dung thật mới "bụp" một cái hiện ra ngay trước mắt người dùng. Đó chính là cú flash và cú shift, và nó đúng là loại lỗi phá nát ngân sách CLS trên một site nhiều chuyển động.
Tệ hơn: hàm cleanup của effect ở cú nhảy redirect đã huỷ mất một lời gọi unregister đang chờ, khiến bộ đếm có thể kẹt dưới 100% — nghĩa là overlay có thể không bao giờ fade. Một trang trắng vĩnh viễn, không lối thoát.
Công thức debug: đo cú điều hướng, đừng đoán
Đây là phần đáng lấy về dùng bất kể stack của bạn là gì. Ngừng canh animation bằng mắt, bắt đầu diff từng frame. Dán đoạn này vào console, bấm link, rồi đọc bảng:
const rows: Record<string, unknown>[] = [];
let raf = 0;
const sample = () => {
const overlay = document.querySelector<HTMLElement>('[data-transition-overlay]');
rows.push({
t: Math.round(performance.now()),
path: location.pathname,
overlay: overlay ? getComputedStyle(overlay).opacity : 'n/a',
height: document.body.scrollHeight,
});
raf = requestAnimationFrame(sample);
};
sample();
// ...giờ bấm link. Khi mọi thứ đã ổn định:
// cancelAnimationFrame(raf); console.table(rows);
Ba cột, mỗi frame một dòng. Cú nhảy stub rỗng lộ diện ngay lập tức dưới dạng một chiều cao vài trăm pixel kẹp giữa hai trang cao đầy đủ, và bạn thấy chính xác cái frame mà opacity của overlay cắt qua 0 so với thời điểm path đổi. Số liệu ăn đứt việc ngồi đoán đường cong easing.
Sửa 1 — xoá cú nhảy (đây mới là cách sửa thật)
Một route mà server redirect() thì không được phép là đích của điều hướng client. Đừng vá từng cái link — menu, CTA ở hero, footer và cả breadcrumb đều trỏ về đúng cái URL index đó. Vá từng link là trò đập chuột chũi, và người tiếp theo thêm một cái link mới sẽ mở lại lỗi.
Hãy sửa cái route. Render thẳng trang đích ngay tại URL đó thay vì bật đi:
// app/guides/page.tsx — TRƯỚC: một đích điều hướng client mà lại redirect.
import { redirect } from 'next/navigation';
import { getFirstGuideSlug } from '@/lib/guides';
export default async function GuidesIndex() {
redirect(`/guides/${await getFirstGuideSlug()}`); // ← cú nhảy thừa
}
// app/guides/page.tsx — SAU: render trang đích INLINE ngay tại URL này.
import type { Metadata } from 'next';
import { getFirstGuideSlug, getGuide } from '@/lib/guides';
import { GuideView } from '@/modules/guides/GuideView';
export async function generateMetadata(): Promise<Metadata> {
const slug = await getFirstGuideSlug();
// Trỏ canonical về slug thật để hai URL không thành duplicate content.
return { alternates: { canonical: `/guides/${slug}` } };
}
export default async function GuidesIndex() {
const slug = await getFirstGuideSlug();
return <GuideView guide={await getGuide(slug)} />;
}
app/guides/[slug]/page.tsx render đúng cái <GuideView /> đó. Một thân trang dùng chung, hai route, không cú nhảy nào — và thẻ canonical giữ cho SEO sạch sẽ. redirect() vẫn là công cụ đúng cho auth gate và URL đã dời đi; nó là công cụ sai cho việc "trang index này nên hiện đứa con đầu tiên của nó".
Sửa 2 — gate reveal theo route-commit + paint, tuyệt đối không theo timer
Kể cả khi đã xoá cú nhảy, một reveal gate bằng timer vẫn là quả bom hẹn giờ chờ một đường mạng chậm. Overlay phải nhấc lên khi trang đích thật sự đã được commit và đã paint — không phải khi một cái setTimeout chỉ tình cờ trùng với thời điểm đó.
usePathname() là tín hiệu trung thực: nó chỉ đổi khi router đã commit trang đích. Hai nhịp requestAnimationFrame sau đó, trang đích đã layout và paint xong ở dưới lớp overlay:
const pathName = usePathname();
useEffect(() => {
// Chỉ hành động khi đang giữa transition, lúc overlay còn che màn hình.
if (usePageStore.getState().pageStatus !== PageState.LEAVE) return;
const reveal = () => revealRef.current?.();
const frame = requestAnimationFrame(() => requestAnimationFrame(reveal));
// Failsafe: overlay kẹt = trang trắng không lối thoát. Luôn chừa một đường ra.
const failsafe = window.setTimeout(reveal, 2000);
return () => {
cancelAnimationFrame(frame);
clearTimeout(failsafe);
};
}, [pathName]);
Hai luật nằm sẵn trong khối đó:
- Làm cho
reveal()idempotent. Cả failsafe lẫn nhánh rAF đều có thể bắn; lần gọi thứ hai phải là no-op. - Luôn ship cái failsafe. Một reveal không bao giờ chạy thì tệ hơn hẳn một reveal chạy sớm — cái sau là một frame xấu, cái trước là một site chết.
Sửa 3 — reveal ngay khi mount nếu trang đã "yên vị"
Đây là lưới an toàn cho mọi component mount muộn — async chunk, list bị re-key, bất cứ thứ gì nằm dưới một suspense boundary. Nếu trang đã đi qua LEAVE rồi thì event one-shot đã bay mất; đừng ngồi chờ một event thứ hai không bao giờ tới:
useEffect(() => {
const status = usePageStore.getState().pageStatus;
if (status === PageState.PLAY || status === PageState.ENTER) ctxAnimation();
}, []);
Đọc giá trị hiện tại của store lúc mount, thay vì chỉ subscribe vào các thay đổi tương lai. Đó là pattern tốt trong mọi hệ animation gate-bằng-event — và cũng chính là kỷ luật giúp các reveal bằng GSAP ScrollTrigger không bị kẹt khi trigger của chúng mount sau lúc vị trí scroll đã đi qua nó.
Bài học: sửa cái route, đừng sửa cái animation
Đây là phần tôi đã làm sai, và là lý do bài viết này tồn tại.
Tôi viết Sửa 3 trước tiên. Nó làm trang trắng biến mất, nên trông rất giống một cách sửa. Nó chỉ là vá triệu chứng — nó bắt được đám component mount muộn, nhưng để nguyên cú nhảy redirect nằm lì trong router, vẫn commit một trang rỗng ở mỗi lần điều hướng trong app. Một tuần sau, đúng cú nhảy đó ngoi lên thành white flash và layout shift, và tôi được debug lại đúng cái link đó lần thứ hai.
| Cách tiếp cận | Hết trang trắng | Hết flash / layout shift | Sống sót khi có link mới |
|---|---|---|---|
Vá từng <Link> để trỏ thẳng slug con | ✅ | ✅ | ❌ đập chuột chũi |
| Reveal-on-mount nếu đã yên vị (Sửa 3) | ✅ | ❌ | ⚠️ vá triệu chứng |
| Gate reveal theo commit + paint (Sửa 2) | ✅ | ✅ | ✅ |
| Xoá redirect, render inline (Sửa 1) | ✅ | ✅ | ✅ bền vững |
Hai luật đổi bằng máu, và giờ tôi áp dụng mặc định cả hai:
- Một
redirect()phía server không phải là một route có thể điều hướng tới trong SPA transition. Nếu một URL có thể bị bấm từ bên trong app, nó phải render, không được bật đi. - Reveal của transition phải được gate theo việc trang đích đã thực sự commit và paint — không bao giờ theo một cái timer chỉ tình cờ trùng với nó.
Sửa 2 và Sửa 3 làm hệ thống bền. Sửa 1 làm cái lỗi không tồn tại. Hãy làm cả ba, nhưng phải biết cái nào là cái nào — vì một miếng vá che triệu chứng sẽ trả lại nó cho bạn sau này, đội một bộ mặt mà bạn không nhận ra.
FAQ
Vì sao page transition chỉ vỡ khi điều hướng trong app, mà load trực tiếp thì không?
Load trực tiếp không có cú nhảy redirect nào để quan sát — server giải quyết xong redirect trước cả khi trình duyệt paint, nên cây React của bạn chỉ mount đúng một lần, tại URL cuối. Còn điều hướng client-side thì commit cái route redirect như một trang thật trước đã. Sự bất đối xứng đó (chạy ngon khi load thẳng, vỡ khi đi qua <Link>) là cách nhanh nhất để nhận diện một lỗi lifecycle.
Tôi thêm đại một cái delay cho reveal chờ trang thật được không?
Không — đó đúng là con bug tôi đã ship rồi. Timer chỉ tương quan với thời điểm trang đích tới; gặp mạng chậm hoặc cache lạnh là nó bắn sớm và lộ nguyên cái stub. Hãy gate theo usePathname() đổi (tức là đã commit) cộng hai nhịp rAF (tức là đã paint), và chỉ giữ một timeout failsafe dài như một lối thoát hiểm.
Vậy redirect() trong App Router là xấu à?
Hoàn toàn không — nó đúng cho auth gate, chuyển locale, và URL đã dời vĩnh viễn, những chỗ mà cú nhảy thừa là vô hình hoặc không tránh được. Nó chỉ sai đúng khi URL redirect đó là một đích bấm được nằm ngay trong SPA của bạn, vì lúc đó transition của bạn buộc phải animate một trang chỉ tồn tại đúng một lần commit và không chứa nội dung gì.
Làm sao bắt được loại lỗi này trước khi khách hàng thấy?
Lấy mẫu location.pathname, opacity của overlay và document.body.scrollHeight mỗi requestAnimationFrame trên mọi đường điều hướng của site (menu, CTA, footer, breadcrumb) rồi diff các dòng. Bất kỳ route nào sinh ra một frame chiều cao ngắn ngủn kẹp giữa hai frame cao đầy đủ đều là một cú nhảy redirect, và sớm muộn nó cũng sẽ làm vỡ transition của bạn.
Nếu một custom page transition đang giở chứng trên một dự án thật — hoặc một cái link menu đang âm thầm bắt bạn trả giá bằng một cú white flash mỗi lượt truy cập — thì đó đúng là loại việc tôi sửa để kiếm sống. Xem cách tôi làm việc với các team.


