Firebase Auth и Firestore в Next.js без плоских коллекций
Сохраняйте единственный экземпляр приложения Firebase, позволяйте вспомогательным функциям аутентификации выдавать типизированные ошибки, храните тренировки внутри каждого пользователя и проверяйте операции записи с помощью эмуляторов.
Шаблоны приложений Firebase + Next.js, заимствованные из других проектов, часто сталкиваются с двумя проблемами: первая — это коллекция workouts без разделения, в которой хранятся документы всех аккаунтов, а также блоки обработки ошибок, записывающие информацию и возвращающие undefined, из-за чего вызывающий код не узнаёт о неудаче записи. Демо-версии с одним способом входа скрывают обе эти проблемы. Однако в реальных условиях они проявляются.
Основные компоненты приложения для фитнеса, отвечающие за аутентификацию и отслеживание данных, были переписаны с использованием Firebase JS SDK версии 12.17.1, причём тестирование проводилось с использованием эмуляторов Firebase для проверки процесса записи данных от начала до конца. Приведённые ниже заметки посвящены своевременной корректировке структуры кода.
Одна инстанция Firebase, а не пять
Частой ошибкой является вызов функции initializeApp в начале модуля, который импортируется несколькими маршрутами. Функция горячей замены в Next.js наряду с разделением кода на серверную и клиентскую части может привести к двукратной загрузке этого модуля; при втором вызове появляется ошибка о том, что уже существует дефолтное приложение. Чтобы избежать этого, необходимо добавить защиту:
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(...) — вот вся защита. Храните конфигурацию в переменных окружения NEXT_PUBLIC_, а не задавайте её вручную — не потому, что конфигурация Firebase для веба является секретной (она по замыслу передаётся в браузер), а потому, что проекты для разработки и производства не должны требовать изменения исходного кода для смены конфигурации.
В старых учебниках в файл вставляют строки "your-api-key". Сама по себе это не является уязвимостью; это привычка, которая в конечном итоге приводит к тому, что настоящая конфигурация производства оказывается в публичном репозитории.
Аутентификация, возвращающая данные, которые можно использовать
Используйте такой формат. Обратите внимание, чего он не делает: он не ловит ошибки и не возвращает 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;
}
Старые версии помощников оборачивают вызов в конструкцию try/catch, выводят сообщение об ошибке регистрации и возвращают значение undefined. Код интерфейса затем ожидает вызова registerUser(...) и пытается обратиться к свойству user.uid, что приводит к обработке отсутствующего значения — поэтому неверный пароль или дублирующаяся электронная почта никогда не становятся четкой ошибкой аутентификации; они проявляются позже в виде попытки чтения свойства у undefined, что отдаляет проблему от её истинной природы.
Лучше использовать механизм отклонения. Вызовы Firebase для аутентификации генерируют специфичные ошибки (auth/email-already-in-use, auth/weak-password и т. д.). Сопоставьте эти коды с понятными сообщениями об ошибках. Пусть помощники по работе с данными ведут себя честно: либо выполняют задачу успешно, либо выбрасывают исключение.
Структурирование тренировок по пользователям с самого начала
Это изменение особенно важно за пределами прототипа. Устаревшие примеры создают одномерный набор workouts с полем userId:
// what everyone copies — one collection for the whole app
addDoc(collection(db, "workouts"), { userId, ...workout });
Поиск записей «мои тренировки» в таком случае включает сканирование коллекции, которая расширяется по мере добавления новых пользователей, и правила безопасности должны фильтровать данные по параметру userId для каждой операции. Лучше использовать подколлекцию, чтобы тренировки каждого пользователя хранились в отдельном документе:
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“) обращается к приватной подколлекции конкретного пользователя. Затем правила безопасности могут сравнивать значение request.auth.uid с элементом пути {uid} как при чтении, так и при записи, тем самым обеспечивая, что каждый запрос обрабатывается только в документах одного пользователя.
Два момента, которые стоит отметить. Лучше использовать serverTimestamp(), чем new Date(): клиентские часы (или злонамеренные клиенты) могут задавать некорректные временные метки; serverTimestamp() — это специальный параметр, который Firestore заполняет временем сервера при сохранении данных, и его невозможно подделать. Использование типа данных WorkoutInput вместо any позволяет выявить опечатки в именах полей, которые иначе проявятся только тогда, когда график бесшумно не отобразит ничего.
Докажите, что данные действительно записываются
Не доверяйте коду Firebase, который никогда не тестировался на эмуляторе — SDK может принимать запросы, которые реальные правила безопасности отклонят. Настройте SDK на локальные эмуляторы и протестируйте весь процесс: регистрацию, запись и чтение данных.
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"));
Запуск того же пути через эмулятор привёл к получению конкретного идентификатора авторизации, идентификатора записи и документа с данными после чтения, в котором поле createdAt содержало реальное время из Firestore в формате (секунды/наносекунды), а не специальное значение-маркер — это доказательство того, что сервер заполнил это поле. Неправильные пути или типы полей вызывают сбои на ноутбуке ещё до развертывания, а не после него.
Важное замечание относительно инструментов: Firebase CLI теперь требует Java 21 или новее. Старая версия JRE на чистом ноутбуке приведёт к сбою эмулятора из-за ошибки версии ещё до запуска кода приложения. Обновите JDK, затем попробуйте снова.
Как двигаться дальше
Аутентификация в сочетании с правильно настроенными операциями записи является основой. Готовое решение обычно использует функцию onAuthStateChanged для отслеживания состояния входа пользователя, преобразует коды ошибок аутентификации и отображает историю изменений с помощью getDocs, orderBy("createdAt", "desc") и параметра limit. Все эти функции по-прежнему зависят от двух ранних решений: инициализации Firebase один раз за защитным механизмом и хранения документов тренировок под пользователем, являющимся их владельцем. Если пропустить хотя бы одно из этих решений, последующие функции будут страдать от этого.