Nativ ui
Documentation

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/me or /auth/refresh)
    • If unauthenticated → redirect to /login before rendering
    • If authenticated → render normally. No loading state for auth is needed, only for page data
  • 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

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/verify for 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.Suspense for heavy data fetching in app/(app)/layout.tsx
  • Client-side navigation — use useTransition or a progress bar (like nprogress) for transitions between dashboard subpages
  • Login form — use local state inside the form component, no global splash

5. Token refresh flow

  1. Browser hits /dashboard
  2. Middleware sees refresh_token cookie → allows request
  3. DashboardLayout calls GET /auth/me on Express with cookies:
    • Access token valid → returns user
    • Access token expired but refresh valid → issues new tokens, returns user
    • Refresh token invalid/expired → 401
  4. 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

ConcernWhereNotes
"Can this route be seen?"middleware.tsCheck refresh cookie, redirect login / dashboard
"Who is the user?"Server (layout.tsx, server actions)Call Express /auth/me with cookies
Auth UI stateClient components (forms, buttons)Local loading and error states only
Access tokenClient state (Context/Zustand)Used only for authenticated client API calls
Refresh tokenHttpOnly cookieRead only on server and by Express, never in JS