You’re seeing this error because the Firebase Web SDK is a browser library and Next.js does server-side rendering by default. When Node.js renders your component, document
doesn’t exist—so importing or running firebase/auth
on the server triggers ReferenceError: document is not defined
. You’ll fix it by isolating all Firebase client imports to Client Components (or to effects that only run in the browser) and using the Admin SDK on the server when needed.
Below you’ll find a complete, modern setup for Next.js App Router (13.4+) and Firebase JS SDK v11+ with precise guardrails to keep browser-only code out of SSR. The pattern scales from local dev to production without hacks. First I’ll show the minimal, reliable client-only import. Then I’ll cover common pitfalls, SSR/Edge gotchas, server-side alternatives, and a final checklist.
The root cause in one line
Next.js renders routes on the server unless a component is explicitly a Client Component. Importing firebase/auth
(or anything that touches browser persistence) in a server context leads to document
errors. Mark client code with "use client"
and never import firebase/auth
from a server file.
Gold-standard setup (App Router)
This structure avoids SSR pitfalls and duplicate initialization while keeping imports clean.
app/firebase/client.ts
— browser-only Firebase client
"use client";
import { initializeApp, getApps, getApp, type FirebaseApp } from "firebase/app";
import {
getAuth,
setPersistence,
browserLocalPersistence,
type Auth,
} 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!,
};
let app: FirebaseApp;
if (!getApps().length) {
app = initializeApp(firebaseConfig);
} else {
app = getApp();
}
/**
* Export `auth` only on the client. On the server, never import this file.
* This prevents `document`/`window` access during SSR.
*/
export const auth: Auth = getAuth(app);
// Optional: choose persistence explicitly to avoid implicit DOM access during boot.
setPersistence(auth, browserLocalPersistence).catch(() => {
// No-op on hydration race; you may log if desired.
});
app/(auth)/login/LoginForm.tsx
— client component that uses Auth
"use client";
import { useState } from "react";
import { auth } from "@/app/firebase/client";
import { signInWithEmailAndPassword } from "firebase/auth";
export default function LoginForm() {
const [email, setEmail] = useState("");
const [pw, setPw] = useState("");
const [err, setErr] = useState<string | null>(null);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
try {
await signInWithEmailAndPassword(auth, email, pw);
// redirect or refresh as needed
} catch (error: any) {
setErr(error?.message ?? "Login failed");
}
};
return (
<form onSubmit={onSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input value={pw} type="password" onChange={(e) => setPw(e.target.value)} />
<button type="submit">Sign in</button>
{err && <p role="alert">{err}</p>}
</form>
);
}
app/(auth)/login/page.tsx
— server component that renders a client child
// This is a Server Component by default.
// It does NOT import firebase/auth or the client module.
import LoginForm from "./LoginForm";
export default function Page() {
return <LoginForm />;
}
This arrangement ensures server code never imports firebase/auth
, while client code does so safely under "use client"
. It fixes the document
error and aligns with Next’s intended split of server vs. client responsibilities.
Quick alternatives when you cannot move a file to client
Sometimes a third-party widget or a legacy file still trips SSR. These patterns are safe stopgaps:
Dynamic import with SSR disabled (component-level):
import dynamic from "next/dynamic";
const ClientOnlyLoginForm = dynamic(() => import("./LoginForm"), { ssr: false });
export default function Page() {
return <ClientOnlyLoginForm />;
}
This defers rendering to the browser so document
exists when the component loads. Prefer the “Gold-standard setup” above for most pages; use ssr: false
sparingly for problematic libraries.
Guard inside effects for one-off DOM usage:
"use client";
import { useEffect } from "react";
useEffect(() => {
// Safe: runs only in the browser after mount
document.title = "Welcome";
}, []);
Effects run only on the client; keep any document
or window
usage here.
What not to do (and why)
- Do not import
firebase/auth
in a Server Component, route handler, or Server Action. Even if code “looks” unused, the module’s side effects (persistence, browser globals) can touchdocument
. Move those imports to Client Components, or inject them with a dynamic import as shown above. - Do not try to use the Web SDK for server logic. Use the Admin SDK (
firebase-admin
) on the server (Route Handlers, Server Actions, or background jobs). The Admin SDK is designed for Node.js and has no DOM assumptions.
Correct server-side usage (Admin SDK)
If you need to verify ID tokens, read/write protected Firestore documents, or manage users on the server, initialize Admin in a server-only module. Never import the client module here.
app/firebase/admin.ts
— server-only
// Server file: do NOT add "use client"
import { cert, getApps, initializeApp, getApp, type App } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
const firebaseAdminApp: App =
getApps().length
? getApp()
: 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"),
}),
});
export const adminAuth = getAuth(firebaseAdminApp);
Use adminAuth.verifyIdToken()
in Route Handlers or Server Actions to gate server data. This keeps the Web SDK out of SSR entirely.
Edge Runtime and deployment notes
If you target the Edge Runtime for Route Handlers or Server Actions, avoid any Node-only modules (Admin SDK requires Node APIs). Use runtime = "nodejs"
for endpoints that use firebase-admin
, or keep Admin work in traditional Node routes while using Edge elsewhere. This separation prevents runtime import errors and still lets you benefit from Edge where appropriate.
Common causes of document is not defined
with Firebase—and the fix
- Importing
firebase/auth
from a server file (e.g.,app/page.tsx
that’s a Server Component).
Fix: move Firebase imports into a Client Component ("use client"
at top) or wrap withdynamic(..., { ssr: false })
. - Creating a shared
firebase.ts
used by both server and client.
Fix: split into two files:firebase/client.ts
(Web SDK) andfirebase/admin.ts
(Admin SDK). Server files must not import the client one. - Using browser persistence implicitly during SSR.
Fix: initialize Auth only in client code and set explicit persistence (as shown earlier), or guard withtypeof window !== "undefined"
. - Calling Firebase Client SDK inside Server Actions or Route Handlers.
Fix: move to a Client Component or switch to Admin SDK on the server. Some environments note specific incompatibilities whenfirebase/auth
leaks into server contexts.
Minimal reproduction to confirm the fix
Create a fresh Next.js App Router project, add Firebase v11+, and drop in only the client module and client component above. If you can navigate and reload the login page without errors, your import boundaries are correct. If you reintroduce the error after adding new code, search for a stray server import of firebase/auth
or a third-party package that reads document
during SSR. General Next.js “document/window is not defined” patterns also apply here.
Troubleshooting checklist
- The file that imports
firebase/auth
starts with"use client"
. - No server file (Route Handler, Server Component, middleware, Server Action) imports the client module.
- Admin-only logic uses
firebase-admin
withruntime = "nodejs"
where required. - Suspicious third-party UI libraries are either wrapped with
dynamic(..., { ssr: false })
or loaded insideuseEffect
. - On reload and production build, the page renders without
document
errors.
If any box is unchecked, you’ll likely see the error again.
Where this leaves you
You’ve separated browser-only Firebase Auth from server rendering, so Next.js can SSR safely while your client code uses the Web SDK with no document
errors. From here, layer on features—protected routes, token verification with Admin on the server, and a deployment to DigitalOcean App Platform or your host of choice—without changing the import patterns you established today. If you later adopt Edge for select routes, keep Admin work on Node only to avoid runtime quirks.
Notes on versions and docs used while validating this approach
- The error arises from SSR accessing browser globals; fixes rely on Client Components, dynamic import, or effect scoping.
- Keep Firebase Web SDK (client) and Admin SDK (server) separate; use each in its intended environment.
If you follow the “Gold-standard setup” exactly, the “document is not defined” error will disappear and stay gone.