Home / Articles / Firebase Auth and Firestore in Next.js Without Flat Collections

This article is published in English.

Firebase Auth and Firestore in Next.js Without Flat Collections

Guard a single Firebase app instance, let auth helpers throw typed errors, nest workouts under each user, and prove writes with the emulators.

1049 words

Inherited Firebase + Next.js starters often share a pair of traps: one flat workouts collection holding every account’s documents, and catch blocks that log then return undefined so callers never learn a write failed. Demos with a single login hide both issues. Production traffic does not.

The auth-and-tracking core of a fitness app was rebuilt on Firebase JS SDK 12.17.1 and exercised against the Firebase emulators so writes could be verified end to end. The notes below focus on fixing structure early.

One Firebase instance, not five

A frequent pitfall is invoking initializeApp at the top of a module that several routes import. Next.js hot reload plus the split between server and client bundles can load that module twice; the second call then complains that the default app already exists. Guard it:

import { initializeApp, getApps, getApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
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!,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET!,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_SENDER_ID!,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
};const app = getApps().length ? getApp() : initializeApp(firebaseConfig);export const auth = getAuth(app);
export const db = getFirestore(app);

getApps().length ? getApp() : initializeApp(...) is the whole guard. Put config in NEXT_PUBLIC_ env vars rather than hardcoding — not because web Firebase config is secret (it ships to the browser by design), but because separate projects for development and production should not require editing source to switch.

Older tutorials paste "your-api-key" strings into the file. That is not a security hole by itself; it is a habit that eventually lands a real production config in a public repository.

Auth that returns something the caller can use

Ship this shape. Notice what it refuses to do: catch the error and return undefined.

import {
  createUserWithEmailAndPassword,
  type User,
} from "firebase/auth";
import { addDoc, collection, serverTimestamp } from "firebase/firestore";
import { auth, db } from "./firebase";
export async function registerUser(
  email: string,
  password: string,
): Promise<User> {
  const cred = await createUserWithEmailAndPassword(auth, email, password);
  return cred.user;
}

Older helpers wrap the call in try/catch, print a registration error, and hand back undefined. UI code then awaits registerUser(...) and touches user.uid on a maybe-missing value — so a bad password or duplicate email never becomes a clean auth error; it surfaces later as a property read on undefined, distant from the real failure.

Prefer rejection. The Firebase auth call raises typed failures (auth/email-already-in-use, auth/weak-password, and similar). Map those codes to readable copy in the form. Keep the data helper honest: succeed or throw.

Structure workouts by user from day one

This change matters most beyond a prototype. Stale examples write a flat workouts collection with a userId field:

// what everyone copies — one collection for the whole app
addDoc(collection(db, "workouts"), { userId, ...workout });

Querying “my workouts” against that scans a collection that grows with the entire user base, and security rules must filter on userId for every operation. Prefer a subcollection so each user’s workouts live under their own document:

export type WorkoutInput = {
  type: string;
  durationMinutes: number;
  caloriesBurned: number;
};
export async function addWorkoutSession(
  userId: string,
  workout: WorkoutInput,
): Promise<string> {
  const ref = await addDoc(collection(db, "users", userId, "workouts"), {
    ...workout,
    createdAt: serverTimestamp(),
  });
  return ref.id;
}

collection(db, "users", userId, "workouts") targets that account’s private subcollection. Security rules can then compare request.auth.uid to the {uid} path segment for both reads and writes, so each query stays inside one user’s documents.

Two details worth highlighting. Prefer serverTimestamp() over new Date(): client clocks (or malicious clients) write bad timestamps; serverTimestamp() is a sentinel Firestore fills with server time at commit and cannot be spoofed. Typing the payload as WorkoutInput instead of any catches field-name typos that otherwise only show up when a chart silently renders nothing.

Prove it actually writes

Do not trust Firebase code that never ran against the emulator — the SDK will accept calls a real security rule would reject. Point the SDK at local emulators and exercise the full path: register, write, read back:

import { getAuth, connectAuthEmulator, createUserWithEmailAndPassword } from "firebase/auth";
import { getFirestore, connectFirestoreEmulator, addDoc, getDocs, collection, serverTimestamp } from "firebase/firestore";
connectAuthEmulator(auth, "http://127.0.0.1:9099", { disableWarnings: true });
connectFirestoreEmulator(db, "127.0.0.1", 8080);const cred = await createUserWithEmailAndPassword(auth, email, "s3cret-pass");
const ref = await addDoc(
  collection(db, "users", cred.user.uid, "workouts"),
  { type: "run", durationMinutes: 32, caloriesBurned: 410, createdAt: serverTimestamp() },
);
const snap = await getDocs(collection(db, "users", cred.user.uid, "workouts"));

Running the same path through the emulator runner produced a concrete auth uid, a write id, and a read-back document whose createdAt was a real Firestore timestamp (seconds/nanoseconds) rather than the sentinel — proof the server filled the field. Bad paths or field types fail on the laptop, not after deploy.

Tooling caveat: the Firebase CLI now expects Java 21 or newer. An older JRE on a clean laptop will stop the emulator runner with a version error before any app code runs. Upgrade the JDK, then retry.

Where to take it next

Auth plus a correctly scoped write is the foundation. A finished product typically wires onAuthStateChanged for signed-in UI state, maps auth error codes in the form, and lists history with getDocs, orderBy("createdAt", "desc"), and a limit. Those features still rest on two choices made early: initialize Firebase once behind a guard, and nest workout documents under the owning user. Miss either, and later features inherit the damage.