Protected Routes
Eliminate unauthenticated flash with server-side auth decisions using Next.js middleware, server components, and httpOnly cookies.
Overview
This playbook covers how to eliminate the "unauthenticated flash" and achieve clean, predictable redirects. The key principle: do auth decisions on the server (middleware + server components) and only use client state for UX, not for deciding if the user may see a page.
Stack: Next.js App Router + Express API + access token in memory + refresh token in httpOnly cookie.
1. What to show while auth is resolving
For protected routes (e.g. /dashboard/*), you should not be "resolving auth" on the client at all — middleware should already have decided whether the user can see the page.
-
Protected pages:
- Do auth in
middleware.ts(check refresh cookie, optionally ping your Express/auth/meor/auth/refresh) - If unauthenticated → redirect to
/loginbefore rendering - If authenticated → render normally. No loading state for auth is needed, only for page data
- Do auth in
-
Public/auth pages (
/login,/signup):- If user is already authenticated, middleware redirects them to
/dashboard - While client-side requests are pending, show local loading UI (spinner, disabled button), not a global splash
- If user is already authenticated, middleware redirects them to
This removes the flash because the first render is already the correct page.
2. Middleware strategy
Production-friendly approach — don't fully validate JWTs in middleware (costly and tightly couples front/back):
- Cookie missing → block access to protected routes, redirect to
/login - Cookie present → assume "probably logged in" and let the request proceed
- Optionally make a fast call to your Express
/auth/verifyfor stricter checks
Example middleware
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const PROTECTED_ROUTES = ['/dashboard'];
const AUTH_ROUTES = ['/login', '/signup'];
function isRoute(pathname: string, list: string[]) {
return list.some((r) => pathname === r || pathname.startsWith(`${r}/`));
}
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const hasRefresh = !!req.cookies.get('refresh_token')?.value;
const isProtected = isRoute(pathname, PROTECTED_ROUTES);
const isAuthRoute = isRoute(pathname, AUTH_ROUTES);
// 1) Protected routes → require refresh cookie
if (isProtected && !hasRefresh) {
const url = req.nextUrl.clone();
url.pathname = '/login';
url.searchParams.set('from', pathname);
return NextResponse.redirect(url);
}
// 2) Auth routes → if cookie exists, skip login
if (isAuthRoute && hasRefresh) {
const url = req.nextUrl.clone();
url.pathname = '/dashboard';
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|api|assets).*)',
],
};3. Protected layout (server-side)
Protected pages are server components that fetch the user object server-side with cookies. If that call fails, they redirect from the server — no client-side flash.
// app/dashboard/layout.tsx
import type { ReactNode } from 'react';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
async function fetchCurrentUser() {
const cookieStore = cookies();
const refreshToken = cookieStore.get('refresh_token')?.value;
if (!refreshToken) return null;
try {
const res = await fetch(`${process.env.API_BASE_URL}/auth/me`, {
headers: {
cookie: cookieStore.toString(),
},
cache: 'no-store',
});
if (!res.ok) return null;
const data = await res.json();
return data.user as { id: string; email: string };
} catch {
return null;
}
}
export default async function DashboardLayout({
children,
}: {
children: ReactNode;
}) {
const user = await fetchCurrentUser();
if (!user) {
redirect('/login?reason=session_expired');
}
return (
<div className="min-h-screen">
{children}
</div>
);
}Because this runs on the server, the user never sees an intermediate login page for a logged-in session.
4. Loading & splash UI
With server-side auth decisions, you rarely need a global "auth loading" splash:
- Suspense boundaries — use
React.Suspensefor heavy data fetching inapp/(app)/layout.tsx - Client-side navigation — use
useTransitionor a progress bar (likenprogress) for transitions between dashboard subpages - Login form — use local state inside the form component, no global splash
5. Token refresh flow
- Browser hits
/dashboard - Middleware sees
refresh_tokencookie → allows request DashboardLayoutcallsGET /auth/meon Express with cookies:- Access token valid → returns user
- Access token expired but refresh valid → issues new tokens, returns user
- Refresh token invalid/expired → 401
DashboardLayout:- User received → render
- 401 →
redirect('/login?reason=session_expired')
This makes expiry handling a pure server concern.
6. Redirect strategies & edge cases
Aim for single-hop redirects and consistent rules:
- Unauthenticated → protected:
/dashboard→ middleware → redirect/login?from=/dashboard - Authenticated → auth page:
/login→ middleware → redirect/dashboard - First visit: No cookie → protected routes go to
/login, public routes unaffected - Expired session: Middleware passes (cookie exists), server
fetchCurrentUser→ 401 → server-side redirect. No flash.
Login page with redirect
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const from = searchParams.get('from') || '/dashboard';
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/login`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.get('email'),
password: formData.get('password'),
}),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Login failed');
}
router.push(from);
} catch (err: any) {
setError(err.message ?? 'Unexpected error');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
{error && <p className="text-sm text-red-500">{error}</p>}
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
);
}7. Summary
| Concern | Where | Notes |
|---|---|---|
| "Can this route be seen?" | middleware.ts | Check refresh cookie, redirect login / dashboard |
| "Who is the user?" | Server (layout.tsx, server actions) | Call Express /auth/me with cookies |
| Auth UI state | Client components (forms, buttons) | Local loading and error states only |
| Access token | Client state (Context/Zustand) | Used only for authenticated client API calls |
| Refresh token | HttpOnly cookie | Read only on server and by Express, never in JS |