Writing

Fast navigation between protected routes in Next.js

Next.js prefetching makes client-side navigation fast, but it creates a problem on authenticated routes. You want the page skeleton to load instantly (like a static page), but private data shouldn't be accessible without a valid session. Here's how to get both.

The problem

Next.js prefetches page data as .json files so client-side navigation feels instant. But on authenticated routes, that prefetched data could contain private content. You need the skeleton to prefetch normally while keeping actual data behind auth.

The approach

The solution combines Next.js middleware, static skeletons, and authenticated API routes:

  • Page skeletons load instantly, so navigation feels static.
  • Private data only loads after the user's session is verified.
  • Unauthenticated users can't access private data or even the page skeleton.

Step 1: Middleware configuration

The key trick is skipMiddlewareUrlNormalize: true in next.config.js. This lets your middleware see the raw request URL, so you can tell the difference between a .json prefetch request and a real page load.

// next.config.js
module.exports = {
  experimental: {
    skipMiddlewareUrlNormalize: true
  }
};

The middleware lets .json requests through (these are just page skeletons) and checks auth on everything else:

export async function middleware(request) {
  // Skip processing for .json requests
  if (request.nextUrl.pathname.endsWith('.json')) {
    return NextResponse.next();
  }
}

Step 2: Authenticated API route for private data

Private data gets fetched through a server-side API route that reads the auth token from an HTTP-only cookie:

// /pages/api/content/get-protected-resource.js
import axios from 'axios';
import { CMS_API } from '@/common/cms-api/cms-api';

export default async function handler(req, res) {
  const { slug, type } = req.query;
  const token = req.cookies.token; // Securely obtained from an HTTP-only cookie

  const { data } = await axios.get(`${CMS_API}/${type}/${slug}`, {
    headers: {
      Authorization: `Bearer ${token}`,
      'X-API-Key': process.env.WP_API_KEY,
    },
  });

  res.status(200).json(data);
}

The API route proxies the request to your CMS with the user's token. No token, no data.

Step 3: Client-side fetching

On the client, components call this API route after the skeleton loads. The page appears immediately with a loading state, then fills in with real content:

const getLessonData = async (slug) => {
  const { data } = await axios('/api/content/get-protected-resource', {
    withCredentials: true,
    params: {
      slug: slug,
      type: 'lesson',
    },
  });

  return data.content;
};

Step 4: Blocking direct access

What about users who type a protected URL directly into the browser? They bypass client-side navigation entirely, so the middleware needs to catch them and redirect to login before anything renders:

// middleware.js
import { NextResponse } from 'next/server';

export async function middleware(req) {
  const token = req.cookies.get('token')?.value;

  // Skip processing for .json requests for static page data
  if (req.nextUrl.pathname.endsWith('.json')) {
    return NextResponse.next();
  }

  // Check for authenticated access to lesson content
  if (req.nextUrl.pathname.includes('/lessons/')) {
    const lesson_slug = req.nextUrl.pathname.split('/').pop();
    const isValidToken = token && await verifyToken(token, lesson_slug, 'lesson');

    // Redirect unauthenticated users to the login page
    if (!isValidToken) {
      return NextResponse.redirect(new URL('/my-account', req.url));
    }
  }

  // Additional middleware logic for other protected routes...
}

For /lessons/ paths, the middleware extracts the slug, verifies the token against it, and redirects to /my-account if auth fails. No valid token means no page at all - not even the skeleton.

Why redirect early

Redirecting before the page loads matters for three reasons. Users don't see confusing empty layouts. No part of the protected page structure leaks to unauthenticated users. And the server skips unnecessary data fetching for users who'd get redirected anyway.

Putting it together

The pattern is: middleware distinguishes prefetch requests (.json) from full page loads, allowing skeletons to be prefetched for fast navigation. Full page loads to protected routes check authentication first. Private data loads client-side through an authenticated API route. The result is navigation that feels static, with data that stays private.