Главная / Статьи / Firebase Auth и Firestore в Next.js без плоских коллекций

Firebase Auth и Firestore в Next.js без плоских коллекций

Сохраняйте единственный экземпляр приложения Firebase, позволяйте вспомогательным функциям аутентификации выдавать типизированные ошибки, храните тренировки внутри каждого пользователя и проверяйте операции записи с помощью эмуляторов.

1049 слов

Шаблоны приложений 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 один раз за защитным механизмом и хранения документов тренировок под пользователем, являющимся их владельцем. Если пропустить хотя бы одно из этих решений, последующие функции будут страдать от этого.