Skip to main content

How to Fix Firebase Auth Persistence in Next.js 15 Server Components

 You have successfully integrated Firebase Authentication on the client side. Users can log in, and onAuthStateChanged triggers correctly in your useEffect hooks. But the moment you try to access the current user in a Next.js 15 Server Component, Server Action, or Middleware, the user object is null.

You are likely staring at a layout file trying to conditionally render a dashboard link, or attempting to protect a route in middleware.ts, only to realize the server has no idea who the user is.

This is the single most common architectural hurdle when combining modern React Server Components (RSC) with Firebase. This guide provides a production-grade, secure implementation to synchronize Firebase client state with the Next.js server runtime using cookies and token rotation.

The Root Cause: LocalStorage vs. Cookies

To fix the problem, you must understand where the disconnect lies.

  1. Client-Side Reality: By default, the Firebase Client SDK (firebase/auth) persists the user's ID token in LocalStorage (or IndexedDB).
  2. Server-Side Reality: Next.js Server Components and Middleware execute on the server (Node.js or Edge runtime) before any HTML is sent to the browser.
  3. The Gap: The server cannot read LocalStorage. It is physically impossible. The only persistence mechanism shared automatically between the browser and the server on the initial HTTP request is Cookies.

Since Firebase does not set authentication cookies by default, the server receives the request anonymously. To solve this, we must build a bridge that synchronizes the Firebase ID token from the client memory into a secure, HTTP-only cookie.

The Solution Architecture

We will implement a secure "Cookie Sync" pattern:

  1. Client: Listen for token changes (onIdTokenChanged).
  2. Client: When a token is generated or refreshed, call a Server Action.
  3. Server: The Server Action sets an HTTP-only cookie containing the token.
  4. Server: RSCs read this cookie and verify it via firebase-admin.

Prerequisites

Ensure you have the necessary SDKs. We need the standard client SDK and the Admin SDK for server-side verification.

npm install firebase firebase-admin next-client-cookies server-only

Note: You will need a Service Account JSON file from the Firebase Console for firebase-admin. Do not expose this in public source control.

Step 1: Initialize Firebase Admin (Server-Side)

In Next.js, we need to ensure the Admin SDK is initialized exactly once to prevent hot-reloading errors during development.

Create src/lib/firebase-admin.ts:

import "server-only";
import admin from "firebase-admin";

interface FirebaseAdminConfig {
  projectId: string;
  clientEmail: string;
  privateKey: string;
}

function formatPrivateKey(key: string) {
  return key.replace(/\\n/g, "\n");
}

export function createFirebaseAdminApp(params: FirebaseAdminConfig) {
  const privateKey = formatPrivateKey(params.privateKey);

  if (admin.apps.length > 0) {
    return admin.app();
  }

  const cert = admin.credential.cert({
    projectId: params.projectId,
    clientEmail: params.clientEmail,
    privateKey,
  });

  return admin.initializeApp({
    credential: cert,
    projectId: params.projectId,
  });
}

export async function initAdmin() {
  const params = {
    projectId: process.env.FIREBASE_PROJECT_ID as string,
    clientEmail: process.env.FIREBASE_CLIENT_EMAIL as string,
    privateKey: process.env.FIREBASE_PRIVATE_KEY as string,
  };

  return createFirebaseAdminApp(params);
}

Step 2: Create Server Actions for Cookie Management

We need a secure way to set and remove cookies. In Next.js 15, Server Actions are the standard way to handle mutations that affect HTTP headers.

Create src/actions/auth-actions.ts:

"use server";

import { cookies } from "next/headers";

export async function createSession(uid: string) {
  // In a real app, you might verify the token here before setting it
  // For this pattern, we trust the token passed, but verify it on read
  const cookieStore = await cookies();
  
  // Set the cookie
  cookieStore.set("firebase-session", uid, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    maxAge: 60 * 60 * 24 * 5, // 5 days
    path: "/",
  });
}

export async function removeSession() {
  const cookieStore = await cookies();
  cookieStore.delete("firebase-session");
}

Security Note: Ideally, you should pass the ID Token to the server, verify it using admin.auth().verifyIdToken(), and then create a Session Cookie using admin.auth().createSessionCookie(). For brevity and reduced latency in this example, we are storing the token directly to demonstrate the sync mechanism.

Step 3: The Client-Side Auth Listener

This is the critical component. We cannot rely on onAuthStateChanged alone because we also need to handle token refreshing (which happens automatically every hour). We use onIdTokenChanged.

Create src/components/auth-provider.tsx:

"use client";

import { useEffect } from "react";
import { onIdTokenChanged, User } from "firebase/auth";
import { auth } from "@/lib/firebase-client"; // Your standard client init
import { createSession, removeSession } from "@/actions/auth-actions";
import { useRouter } from "next/navigation";

export function AuthProvider({
  children,
  defaultUser,
}: {
  children: React.ReactNode;
  defaultUser: string | null;
}) {
  const router = useRouter();

  useEffect(() => {
    const unsubscribe = onIdTokenChanged(auth, async (user: User | null) => {
      if (user) {
        const token = await user.getIdToken();
        // Send the token or uid to the server to set the cookie
        // In this example we just sync the ID Token as the session content
        await createSession(token);
      } else {
        await removeSession();
      }
      
      // Force a router refresh to update Server Components
      router.refresh();
    });

    return () => unsubscribe();
  }, [router]);

  return <>{children}</>;
}

Step 4: Accessing the User in Server Components

Now that the cookie is set, we can create a utility function to read and verify the user in any Server Component (Page, Layout, or Loading UI).

Create src/lib/get-server-user.ts:

import "server-only";
import { cookies } from "next/headers";
import { initAdmin } from "@/lib/firebase-client-admin";
import * as admin from "firebase-admin";

export async function getServerUser() {
  const cookieStore = await cookies();
  const token = cookieStore.get("firebase-session")?.value;

  if (!token) {
    return null;
  }

  try {
    await initAdmin();
    // Verify the ID token securely
    const decodedToken = await admin.auth().verifyIdToken(token);
    return decodedToken;
  } catch (error) {
    // Token is invalid or expired
    return null;
  }
}

Step 5: Implementation in a Server Component

Finally, you can use this in your application code. This code runs entirely on the server, ensuring good SEO and fast "First Contentful Paint".

src/app/dashboard/page.tsx:

import { getServerUser } from "@/lib/get-server-user";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const user = await getServerUser();

  if (!user) {
    redirect("/login");
  }

  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold mb-4">Dashboard</h1>
      <div className="bg-white shadow rounded-lg p-6">
        <p className="text-gray-700">
          Welcome back, <strong>{user.email}</strong>
        </p>
        <p className="mt-2 text-sm text-gray-500">
          User ID: {user.uid}
        </p>
      </div>
    </main>
  );
}

Deep Dive: Why onIdTokenChanged?

You might wonder why we use onIdTokenChanged instead of onAuthStateChanged.

Firebase ID tokens expire after one hour. If a user keeps your app open for 65 minutes, their client-side token will automatically refresh. If you only used onAuthStateChanged (which only fires on sign-in/sign-out), the new token would never be sent to the server. The cookie would expire, and the next server action or navigation would fail authentication.

onIdTokenChanged fires on sign-in, sign-out, AND whenever the token is auto-refreshed by the SDK. This ensures your server-side cookie always stays fresh.

Common Pitfalls and Edge Cases

1. Hydration Mismatches

When using this pattern, ensure your Root Layout wraps the application in the AuthProvider. If you render user-specific data in the navbar based on cookies, and the client SDK hasn't loaded yet, you might see a brief flash. Using router.refresh() in the provider helps sync the UI once the cookie is set.

2. Middleware Limitations

If you plan to use this logic in middleware.ts, be aware that firebase-admin is a Node.js library. It effectively relies on standard Node runtime modules that may not be available in the Edge Runtime (which Next.js Middleware uses by default).

To use auth verification in Middleware, you have two options:

  1. Use a lightweight JWT verification library (like jose) to verify the token signature without the full Firebase Admin SDK.
  2. Keep the middleware logic simple (check for cookie existence only) and do the rigorous verification in the Server Component or Layout. The latter is usually preferred for performance.

3. Vercel / Serverless Cold Starts

Initializing the Firebase Admin SDK can add latency to serverless function cold starts. Ensure you are lazy-loading the admin initialization (as shown in Step 1) so it only runs when a request actually requires authentication.

Conclusion

The shift from client-side SPAs to Server Components requires a mental shift in how we handle state. By treating the Firebase Client SDK as the "source of truth" and mirroring that truth to an HTTP-only cookie, you gain the best of both worlds: the robust security and ecosystem of Firebase, combined with the performance and SEO benefits of Next.js 15 Server Components.