Ba việc cần làm gọn:
1. Chặn route khi chưa đăng nhập. Bọc các trang cần bảo vệ trong một guard (component wrapper hoặc middleware). Guard đọc auth state; nếu chưa đăng nhập thì redirect về /login kèm returnUrl để quay lại đúng chỗ sau khi vào.
2. Tránh flash nội dung. Auth state thường có ba pha: loading | authenticated | unauthenticated. Lỗi phổ biến là coi "chưa có user" = "chưa đăng nhập" và render nội dung công khai rồi mới chuyển hướng — gây nháy. Cách đúng: trong pha loading (đang kiểm tra session/gọi /me) hiện skeleton hoặc spinner, chỉ quyết định redirect sau khi biết chắc kết quả. Với SSR/Next.js, kiểm tra session ở server để trang bảo vệ không bao giờ gửi HTML nội dung tới client chưa đăng nhập.
function RequireAuth({ children }) {
const { status } = useAuth() // 'loading' | 'authenticated' | 'unauthenticated'
if (status === 'loading') return <PageSkeleton />
if (status === 'unauthenticated') return <Navigate to={`/login?returnUrl=${path}`} />
return children
}3. Refresh token phía FE. Cơ chế interceptor-401 refresh-rồi-retry có câu hỏi riêng; ở tầng thiết kế route/auth, các quyết định đáng nói là: đặt interceptor trên một API client dùng chung (một instance axios / fetch wrapper) để mọi màn hình đi qua cùng một luồng refresh thay vì rải logic theo từng call site; loại endpoint refresh/login khỏi interceptor để không tự tạo vòng lặp refresh; và khi nhiều request cùng nhận 401, chỉ cho một refresh chạy (single-flight) để tránh race. Refresh thất bại → coi phiên đã kết thúc: xoá auth state và đưa về login kèm returnUrl. Không để token trong localStorage (XSS đọc được).
Quan trọng: guard phía client chỉ là UX. Mọi API vẫn phải tự kiểm tra quyền ở server — người dùng có thể gọi thẳng endpoint, bỏ qua UI.