How to render authenticated layouts with cookies() and Firebase user state

Render authenticated layouts with cookies() and Firebase user state (Next.js App Router)

We may earn an affiliate commission through purchases made from our guides and tutorials.

Most dashboards start life with a simple client-side onAuthStateChanged and grow into a server-rendered app that needs user context on the very first paint. This guide shows how to do that cleanly with Next.js App Router (/app), cookies() for request-time state, and Firebase Authentication’s session cookies so your layouts render with the right user immediately—no client “flash.” You’ll sign in on the client once, exchange the ID token for a secure, HTTP-only session cookie, and verify it on the server to render authenticated layouts. If you plan to deploy at the end, you’ll also want a DigitalOcean account ready for App Platform or a Droplet.

Assumptions. You’re using Next.js 15 with the App Router, Node 18+, Firebase JS SDK v12.x on the client, and Firebase Admin SDK on the server. In App Router, cookies() lets you read cookies in Server Components and Route Handlers; you set them via a Route Handler or Server Action, not directly in a Server Component.

Architecture you will build

You will:

  1. Sign in with Firebase on the client → obtain a Firebase ID token.
  2. POST that ID token to /api/session → the server exchanges it for a Firebase session cookie and sets it as Set-Cookie (HTTP-only, Secure, SameSite).
  3. On each request, your layout reads the cookie with cookies() and verifies it via the Admin SDK to get the user.
  4. Middleware optionally enforces route protection and fast-rejects unauthenticated hits.

Firebase’s Admin SDK exposes createSessionCookie() and verifySessionCookie() for this exact flow; session cookies are designed for SSR and can last up to two weeks (you choose the duration).

Project setup

Install the packages:

npm i firebase
npm i -D @types/node
npm i firebase-admin

Create environment variables. In development, use .env.local; in production, add the same keys to your deployment (e.g., DigitalOcean App Platform → “Environment Variables/Secrets”).

# Firebase client
NEXT_PUBLIC_FIREBASE_API_KEY=...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=...
NEXT_PUBLIC_FIREBASE_PROJECT_ID=...
NEXT_PUBLIC_FIREBASE_APP_ID=...

# Firebase Admin (Service Account JSON pasted as a single line or individual fields)
FIREBASE_PROJECT_ID=...
FIREBASE_CLIENT_EMAIL=...
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"

# Auth
SESSION_COOKIE_NAME=__session
SESSION_COOKIE_MAX_DAYS=5

Why __session? Firebase Hosting and some proxies treat __session as an allow-listed cookie name; it’s also a conventional default.

Firebase client initialization (client components only)

Create lib/firebaseClient.ts:

// lib/firebaseClient.ts
"use client";

import { initializeApp, getApps } from "firebase/app";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
};

export const app = getApps().length ? getApps()[0] : initializeApp(firebaseConfig);
export const auth = getAuth(app);

This uses the modern v9+ modular API (current in v12.x).

Firebase Admin initialization (server only)

Create lib/firebaseAdmin.ts:

// lib/firebaseAdmin.ts
import { App, cert, getApps, initializeApp } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";

let adminApp: App;

if (!getApps().length) {
  adminApp = initializeApp({
    credential: cert({
      projectId: process.env.FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
    }),
  });
} else {
  adminApp = getApps()[0]!;
}

export const adminAuth = getAuth(adminApp);

Session endpoint: exchange ID token → session cookie

Create app/api/session/route.ts. This handler accepts a Firebase ID token, issues a session cookie, and sets it securely. It also supports DELETE to sign out by clearing the cookie.

// app/api/session/route.ts
import { NextRequest, NextResponse } from "next/server";
import { adminAuth } from "@/lib/firebaseAdmin";

const COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "__session";

export async function POST(req: NextRequest) {
  try {
    const { idToken } = await req.json();
    if (!idToken) return NextResponse.json({ error: "Missing idToken" }, { status: 400 });

    // Optional: basic CSRF protection by requiring a custom header or a double-submit token.
    // const csrf = req.headers.get("x-csrf"); // validate as needed

    // Session length: up to 14 days. We'll read days from env.
    const expiresInDays = Number(process.env.SESSION_COOKIE_MAX_DAYS || 5);
    const expiresIn = expiresInDays * 24 * 60 * 60 * 1000;

    // Create session cookie from the ID token.
    const sessionCookie = await adminAuth.createSessionCookie(idToken, { expiresIn });

    const res = NextResponse.json({ ok: true });
    // Secure, httpOnly, lax same-site by default; consider 'strict' for pure web apps.
    res.cookies.set({
      name: COOKIE_NAME,
      value: sessionCookie,
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      path: "/",
      maxAge: Math.floor(expiresIn / 1000),
    });

    return res;
  } catch (e) {
    console.error(e);
    return NextResponse.json({ error: "Failed to create session" }, { status: 401 });
  }
}

export async function DELETE() {
  // Clear the cookie and (optionally) revoke backend sessions.
  const res = NextResponse.json({ ok: true });
  res.cookies.set({
    name: process.env.SESSION_COOKIE_NAME || "__session",
    value: "",
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: 0,
  });
  return res;
}

Firebase recommends exchanging a short-lived ID token for a server-verified session cookie and verifying that cookie on each request.

Server helper: read & verify the user from cookies()

Create lib/authServer.ts. This runs in Server Components/Route Handlers and uses cookies() to read the session cookie attached to the incoming request, then verifies it.

// lib/authServer.ts
import { cookies } from "next/headers";
import { adminAuth } from "@/lib/firebaseAdmin";

const COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "__session";

export type SessionUser = {
  uid: string;
  email?: string;
  name?: string;
  picture?: string;
  // add custom claims you expect to use in layouts
  admin?: boolean;
};

export async function getCurrentUser(): Promise<SessionUser | null> {
  const cookieStore = cookies();
  const sessionCookie = cookieStore.get(COOKIE_NAME)?.value;
  if (!sessionCookie) return null;

  try {
    // verifySessionCookie does NOT hit Google every time when correctly cached;
    // it verifies signature and checks revocation when asked.
    const decoded = await adminAuth.verifySessionCookie(sessionCookie, true); // checkRevoked=true
    const { uid, email, name, picture } = decoded;
    const admin = decoded.admin === true || decoded["https://example.com/roles"]?.includes("admin");
    return { uid, email, name, picture, admin };
  } catch {
    return null;
  }
}

cookies() is available in Server Components and Route Handlers (read-only in Server Components). You set the cookie in the Route Handler earlier.

Layout: render authenticated UI on the first paint

In the App Router you can fetch user state in the layout and render different shells.

// app/(app)/layout.tsx
import { ReactNode } from "react";
import { getCurrentUser } from "@/lib/authServer";

export default async function AppLayout({ children }: { children: ReactNode }) {
  const user = await getCurrentUser();

  if (!user) {
    // Public shell: minimal nav, marketing footer, etc.
    return (
      <html lang="en">
        <body>
          <header className="p-4 border-b">Public Header</header>
          <main className="p-6">{children}</main>
        </body>
      </html>
    );
  }

  // Authenticated shell with user context ready on the server render.
  return (
    <html lang="en">
      <body>
        <header className="p-4 border-b flex items-center justify-between">
          <span>App</span>
          <span className="text-sm">Signed in as {user.email ?? user.uid}</span>
        </header>
        <main className="p-6">{children}</main>
      </body>
    </html>
  );
}

Because getCurrentUser() reads the session cookie with cookies(), the layout has the user on the very first request, preventing client-side flicker. The cookie was written by your Route Handler, not the layout itself.

Optional: route protection in middleware for faster 401s

Middleware runs before your routes. You can read cookies from NextRequest and redirect unauthenticated users away from protected segments like /dashboard. Use it for fast rejects and to avoid rendering the public shell for protected paths.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

const COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "__session";
const PROTECTED_PREFIXES = ["/dashboard", "/settings"];

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  const matches = PROTECTED_PREFIXES.some((p) => pathname.startsWith(p));
  if (!matches) return NextResponse.next();

  const hasSession = !!req.cookies.get(COOKIE_NAME)?.value;
  if (!hasSession) {
    const url = new URL("/login", req.url);
    url.searchParams.set("next", pathname);
    return NextResponse.redirect(url);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};

You can access request cookies via req.cookies in middleware; write redirects with NextResponse.

Login component: sign in, then call the session endpoint

Use Firebase’s client SDK to sign in (email/password shown for clarity; OAuth providers are similar). After sign-in, obtain an ID token and POST it to /api/session to set the HTTP-only session cookie.

// app/login/LoginForm.tsx
"use client";

import { auth } from "@/lib/firebaseClient";
import { signInWithEmailAndPassword, signOut } from "firebase/auth";
import { useState } from "react";

export default function LoginForm() {
  const [email, setEmail] = useState("");
  const [pw, setPw] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleLogin(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setError(null);
    try {
      const cred = await signInWithEmailAndPassword(auth, email, pw);
      const idToken = await cred.user.getIdToken(/* forceRefresh */ true);

      const res = await fetch("/api/session", {
        method: "POST",
        headers: { "Content-Type": "application/json" /* add x-csrf if you implement */ },
        body: JSON.stringify({ idToken }),
      });
      if (!res.ok) throw new Error("Failed to set session");
      // Hard refresh to get SSR shell with user state
      window.location.assign("/dashboard");
    } catch (err: any) {
      setError(err.message ?? "Login failed");
    } finally {
      setLoading(false);
    }
  }

  async function handleLogout() {
    await fetch("/api/session", { method: "DELETE" });
    await signOut(auth);
    window.location.assign("/");
  }

  return (
    <form onSubmit={handleLogin} className="space-y-3">
      <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email" className="border p-2 w-full" />
      <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="password" className="border p-2 w-full" />
      <button disabled={loading} className="border px-3 py-2">{loading ? "…" : "Sign in"}</button>
      <button type="button" onClick={handleLogout} className="ml-2 underline">Sign out</button>
      {error ? <p className="text-red-600 text-sm">{error}</p> : null}
    </form>
  );
}

This keeps the ID token off Server Components and stores only an HTTP-only cookie accessible to the server. Firebase recommends session cookies for SSR since they avoid token leakage and reduce client waterfalls.

Using the user in Server Components and actions

Now that your layout receives user, any Server Component can safely use it. For example, a dashboard page that needs the user’s UID for a server-side query:

// app/dashboard/page.tsx
import { getCurrentUser } from "@/lib/authServer";
// import { noStore } from "next/cache"; // if you need to opt out of caching

export default async function DashboardPage() {
  const user = await getCurrentUser();
  if (!user) {
    // You can redirect on the server too:
    // redirect("/login");
    return <p>Redirecting…</p>;
  }

  // noStore(); // uncomment if you render user-specific data and want to bypass static caching
  // const data = await fetchUserData(user.uid);

  return (
    <section>
      <h1 className="text-xl font-semibold">Dashboard</h1>
      <p className="mt-2 text-sm text-gray-600">Welcome, {user.email ?? user.uid}.</p>
    </section>
  );
}

Revocation and logout behavior

Two places to consider:

  1. User-initiated logout: the DELETE /api/session handler clears the cookie; the client calls signOut() to stop token refreshes.
  2. Server-side revocation: if you revoke sessions (e.g., on password change or security event), verifySessionCookie(..., true) will fail and getCurrentUser() will return null on the next request, dropping the user back to the public shell.

Security notes that matter in production

  • Cookie attributes: httpOnly, secure, sameSite=lax/strict, path=/, and a sensible maxAge. These prevent client JS access and limit CSRF surface.
  • CSRF: consider a double-submit token or a header check on the session creation POST.
  • Setting vs reading cookies: read with cookies() in Server Components; set only in Route Handlers or Server Actions. You cannot mutate cookies from a Server Component.
  • Middleware: useful for coarse-grained protection, but still verify in your loaders/Server Components.

Deploying on DigitalOcean (quick pointers)

  • App Platform: add your environment variables and mark secrets (Firebase private key) as encrypted.
  • Ensure HTTPS is enforced so secure cookies are honored.
  • If you terminate TLS at the edge (load balancer), keep secure cookies intact; App Platform handles this by default.

Troubleshooting

“Cookie not set” in SSR even after login. You probably set the cookie from a Server Component; move it to a Route Handler or Server Action, then refresh.

“Can I set cookies in the layout?” No; layouts can read cookies, not write them. Use your /api/session handler for mutations.

“Why not just read the ID token in SSR?” ID tokens are short-lived and client-managed; session cookies are meant for SSR, reduce client→server chatter, and keep tokens out of Server Components.

What you now have

  • Client signs in once and exchanges the ID token for a server-managed session cookie.
  • The layout reads and verifies that cookie using cookies() and the Firebase Admin SDK.
  • Your authenticated shell renders on the first paint, without a client-side “loading → swap” flicker.
  • Middleware can block unauthenticated access early for protected routes.

If you continue from here, factor authorization (roles/claims) into getCurrentUser() and branch the UI or data loading accordingly. Next steps typically include adding OAuth providers, rotating sessions on password change, and hardening CSRF around session creation. For API routes or server actions that need the user, import getCurrentUser() for a consistent source of truth.

Was this helpful?

Thanks for your feedback!
Alex is the resident editor and oversees all of the guides published. His past work and experience include Colorlib, Stack Diary, Hostvix, and working with a number of editorial publications. He has been wrangling code and publishing his findings about it since the early 2000s.

Leave a comment

Your email address will not be published. Required fields are marked *