Skip to main content

NextAuth Session Returns Null? Fixing the Next.js 14 Refresh Bug

 There are few things more frustrating in the React ecosystem than a successful login flow that vanishes the moment you refresh the page. You see the user object, the dashboard loads, and you feel great. Then you hit F5 (or Cmd+R), and suddenly session is null, the UI flickers back to the login screen, and your console is flooded with hydration errors.

If you are using Next.js 14 (App Router) with NextAuth.js (Auth.js), this "phantom session" bug is a common implementation oversight, not necessarily a library defect.

Here is the deep dive into why your session persistence is failing and the architectural patterns required to fix it permanently.

The Root Cause: Hydration vs. Server Architecture

To fix the problem, you must understand the race condition occurring under the hood.

When using the App Router, Next.js aggressively separates Server Components (RSC) from Client Components.

  1. Server-Side: NextAuth relies on HTTP Cookies (next-auth.session-token) to identify the user. If the server cannot read this cookie due to domain mismatches, security flags, or build-time static generation, getServerSession returns null.
  2. Client-Side: useSession uses React Context. On a hard refresh, the React tree is rebuilt from scratch. The SessionProvider must initiate a network request to /api/auth/session to re-fetch user data.

The "bug" usually occurs because the application tries to render protected content before this asynchronous fetch completes, or because the Server Component renders statically and has no access to the request cookies at build time.

Solution 1: The Client-Side Context Wrapper

In the Next.js App Router (/app), the root layout.tsx is a Server Component by default. You cannot use the SessionProvider (which uses React Context) directly inside it.

If you are importing SessionProvider directly into layout.tsx and adding "use client" to the entire layout, you are de-optimizing your entire application.

Instead, create a strictly typed client wrapper.

Step 1: Create the AuthProvider

Create a new file at src/components/AuthProvider.tsx (or app/context/AuthProvider.tsx).

"use client";

import { SessionProvider } from "next-auth/react";
import { ReactNode } from "react";

interface AuthProviderProps {
  children: ReactNode;
}

export default function AuthProvider({ children }: AuthProviderProps) {
  return <SessionProvider>{children}</SessionProvider>;
}

Step 2: Implement in Root Layout

Wrap your application in src/app/layout.tsx. By keeping the layout a Server Component and importing the Client Component wrapper, you maintain SEO benefits and performance while fixing the context issue.

import AuthProvider from "@/components/AuthProvider";
import "./globals.css";
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Next.js Auth Fix",
  description: "Persisting sessions correctly",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {/* Pass the session prop if you are fetching it server-side */}
        <AuthProvider>{children}</AuthProvider>
      </body>
    </html>
  );
}

Note: While you can pass a session prop to SessionProvider to prime the data, relying on the internal fetch is often safer for ensuring the token hasn't expired.

Solution 2: Correcting getServerSession Usage

The most common reason for a null session in Server Components is an incorrect configuration of the authOptions.

In Next.js 13/14, you should strictly separate your configuration from your route handler to avoid circular dependency errors and ensure the server has access to the correct scopes.

Step 1: Centralize Auth Options

Do not define options inside app/api/auth/[...nextauth]/route.ts. Move them to src/lib/auth.ts.

// src/lib/auth.ts
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

export const authOptions: NextAuthOptions = {
  // Debug enabled only in development
  debug: process.env.NODE_ENV === "development", 
  session: {
    strategy: "jwt",
  },
  secret: process.env.NEXTAUTH_SECRET, // CRITICAL: Must be in .env
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        // Mock logic - replace with actual DB call
        const user = { id: "1", name: "Dev", email: "dev@example.com" };
        
        if (user) {
          return user;
        } else {
          return null;
        }
      },
    }),
  ],
  callbacks: {
    // Required to persist ID across sessions
    async session({ session, token }) {
      if (token && session.user) {
        // @ts-expect-error - extend your types if needed
        session.user.id = token.sub; 
      }
      return session;
    },
  },
};

Step 2: Use in Server Components

Now, when fetching the session in a Server Component (page.tsx), you must pass this configuration object.

import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; // Import the separated options
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  // 1. Pass the options directly
  const session = await getServerSession(authOptions);

  // 2. Handle the null case explicitly on the server
  if (!session) {
    redirect("/api/auth/signin");
  }

  return (
    <main className="p-4">
      <h1 className="text-2xl font-bold">Welcome back, {session.user?.name}</h1>
      <p>Your session is secure and persisted.</p>
    </main>
  );
}

If you call getServerSession() without arguments, it will almost always fail in the App Router environment.

Solution 3: The Environment Variable Trap

If the code above looks correct but production (or Vercel) is still failing, the issue is almost certainly Environment Variables.

NextAuth requires NEXTAUTH_SECRET to sign and decrypt the JWT. If this is missing or differs between build and runtime, the session cookie becomes invalid immediately.

  1. Generate a Secret: Run openssl rand -base64 32 in your terminal.
  2. Update .env:
    NEXTAUTH_SECRET="your-generated-secret-value"
    NEXTAUTH_URL="http://localhost:3000" # production-domain.com in prod
    

Critical Vercel Note: In Vercel deployment settings, ensure NEXTAUTH_URL is set to your canonical domain (e.g., https://myapp.vercel.app). Without this, the callback URL validation may fail, causing the session to drop.

Deep Dive: Handling "Loading" States in Client Components

Even with the fixes above, useSession has three states: "loading""authenticated", and "unauthenticated".

If you render your user profile component while the status is "loading"session will be undefined. If you treat undefined as "not logged in" and immediately redirect, you will create a redirect loop on refresh.

The Correct Pattern

"use client";

import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

export default function UserProfile() {
  const { data: session, status } = useSession();
  const router = useRouter();

  useEffect(() => {
    if (status === "unauthenticated") {
      router.push("/login");
    }
  }, [status, router]);

  // 1. Handle loading state explicitly
  if (status === "loading") {
    return <div className="animate-pulse h-10 w-full bg-gray-200 rounded"></div>;
  }

  // 2. Render only when authenticated
  if (session) {
    return <div>Signed in as {session.user?.email}</div>;
  }

  return null;
}

Edge Case: Middleware Configuration

If your session is still returning null, check your middleware.ts. If your middleware is configured to run on the API routes that handle authentication, it might be stripping cookies or interfering with the handshake.

Ensure your matcher excludes the NextAuth API routes and static files:

// middleware.ts
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api/auth (NextAuth routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    "/((?!api/auth|_next/static|_next/image|favicon.ico).*)",
  ],
};

Conclusion

The "null session" bug in Next.js 14 and NextAuth usually stems from a misunderstanding of the boundary between the server and the client.

By decoupling your authOptions into a standalone library file, wrapping your app properly in a client-side SessionProvider, and explicitly handling loading states, you ensure robust session persistence.

These changes don't just fix the bug; they align your application with the intended architecture of the Next.js App Router, resulting in better performance and easier debugging down the road.